From 0bc4795083d72123353eb8cc9dbdfa8af8fc3339 Mon Sep 17 00:00:00 2001 From: KitsuneSemCalda Date: Fri, 9 Oct 2026 22:50:29 -0300 Subject: [PATCH 001/203] Add OmaStore manifest --- omastore.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 omastore.toml diff --git a/omastore.toml b/omastore.toml new file mode 100644 index 00000000..8d1ee53a --- /dev/null +++ b/omastore.toml @@ -0,0 +1,11 @@ +kind = "app" +categories = ["AudioVideo", "Video", "AudioVideoEditing"] +icon = "assets/app-icon/hicolor/256x256/apps/ai.storyteller.filmcraft.png" + +[linux.x86_64] +asset = "filmcraft-{version}-linux-x86_64.tar.gz" +exec = "filmcraft-{version}-linux-x86_64/usr/bin/filmcraft" + +[linux.aarch64] +asset = "filmcraft-{version}-linux-aarch64.tar.gz" +exec = "filmcraft-{version}-linux-aarch64/usr/bin/filmcraft" From 1b83994c936df4a9f3d53ffc73d7f1f7b93da6a1 Mon Sep 17 00:00:00 2001 From: grib_job Date: Sat, 10 Oct 2026 13:40:22 +0300 Subject: [PATCH 002/203] feat: Add Russian (ru) localization (updated) --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 +- apps/filmcraft/src/native_menu.rs | 2 + crates/engine/src/settings.rs | 1 + crates/ui-egui/src/i18n/mod.rs | 34 +- crates/ui-egui/src/i18n/ru.tsv | 356 +++++++++++++++++++++ crates/ui-egui/src/i18n/ru.tsv.attribution | 8 + crates/ui-egui/src/menus.rs | 5 +- 8 files changed, 404 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/i18n/ru.tsv create mode 100644 crates/ui-egui/src/i18n/ru.tsv.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 3e94fa45..ce7bd609 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| +| `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | | `assets/fonts/Inter-Medium.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | diff --git a/ROADMAP.md b/ROADMAP.md index 7a32f549..e8e5f928 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese` and `app.language.ukrainian` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Russian and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian` and `app.language.russian` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish and Japanese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet). Searches match the displayed labels as well as English sources. Brazilian Portuguese, Ukrainian and Russian cover the core menus; untranslated labels use English. Ukrainian and Russian use the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Existing vertical text support is preserved. diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index c63f8295..65aa26b5 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -101,6 +101,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::PtBr } else if checked("app.language.ukrainian") { filmcraft_ui_egui::i18n::Language::Uk + } else if checked("app.language.russian") { + filmcraft_ui_egui::i18n::Language::Ru } else { filmcraft_ui_egui::i18n::Language::En }; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 9243483f..e2deb5b8 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -687,6 +687,7 @@ static CATEGORIES: &[Category] = &[ ("es", "Español"), ("pt-br", "Português (Brasil)"), ("uk", "Українська"), + ("ru", "Русский"), ]), true, ), diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 3f4b845e..71c58151 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -35,15 +35,17 @@ pub enum Language { #[serde(rename = "pt-br")] PtBr, Uk, + Ru, } static JAPANESE: OnceLock = OnceLock::new(); static SPANISH: OnceLock = OnceLock::new(); static PORTUGUESE: OnceLock = OnceLock::new(); static UKRAINIAN: OnceLock = OnceLock::new(); +static RUSSIAN: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 5] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk]; + pub const ALL: [Self; 6] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::Ru]; pub fn name(self) -> &'static str { match self { @@ -52,6 +54,7 @@ impl Language { Self::Es => "Español", Self::PtBr => "Português (Brasil)", Self::Uk => "Українська", + Self::Ru => "Русский", } } @@ -62,6 +65,7 @@ impl Language { "es" => Some(Self::Es), "pt-br" => Some(Self::PtBr), "uk" => Some(Self::Uk), + "ru" => Some(Self::Ru), _ => None, } } @@ -70,7 +74,7 @@ impl Language { /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 5] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk)]; + const PRIMARY: [(&str, Language); 6] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk), ("ru", Language::Ru)]; tags.iter() .find_map(|tag| { let primary = tag.split(['-', '_', '.', '@']).next().unwrap_or_default(); @@ -87,6 +91,7 @@ impl Language { Self::Es => "es", Self::PtBr => "pt-br", Self::Uk => "uk", + Self::Ru => "ru", } } @@ -98,6 +103,7 @@ impl Language { Self::Es => Some(SPANISH.get_or_init(|| Catalog::parse(include_str!("es.tsv")))), Self::PtBr => Some(PORTUGUESE.get_or_init(|| Catalog::parse(include_str!("pt-br.tsv")))), Self::Uk => Some(UKRAINIAN.get_or_init(|| Catalog::parse(include_str!("uk.tsv")))), + Self::Ru => Some(RUSSIAN.get_or_init(|| Catalog::parse(include_str!("ru.tsv")))), } } @@ -253,7 +259,7 @@ mod tests { #[test] fn catalogs_are_well_formed() { for (code, text) in - [("es", include_str!("es.tsv")), ("ja", include_str!("ja.tsv")), ("pt-br", include_str!("pt-br.tsv")), ("uk", include_str!("uk.tsv"))] + [("es", include_str!("es.tsv")), ("ja", include_str!("ja.tsv")), ("pt-br", include_str!("pt-br.tsv")), ("uk", include_str!("uk.tsv")), ("ru", include_str!("ru.tsv"))] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -280,6 +286,10 @@ mod tests { assert_eq!(Language::Uk.tr("File"), "Файл"); assert_eq!(Language::Uk.tr("мій кліп.mp4"), "мій кліп.mp4"); assert_eq!(Language::Uk.tr("An untranslated label"), "An untranslated label"); + assert_eq!(Language::Ru.name(), "Русский"); + assert_eq!(Language::Ru.tr("File"), "Файл"); + assert_eq!(Language::Ru.tr("мой клип.mp4"), "мой клип.mp4"); + assert_eq!(Language::Ru.tr("An untranslated label"), "An untranslated label"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -732,6 +742,24 @@ mod tests { } } + #[test] + fn russian_entries_cover_the_original_menu_catalog() { + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let items = crate::menus::menu_items(&app); + let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); + let (entries, _) = catalog::parse_entries(include_str!("ru.tsv")); + for (_, en, _) in entries { + assert!(known(&en) || en == "Settings", "not a menu label: {en}"); + } + let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + for (_, en, _) in portuguese { + assert!(Language::Ru.has(&en), "missing Russian menu label: {en}"); + } + for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + assert_ne!(Language::Ru.tr(en), en, "untranslated Russian menu: {en}"); + } + } + #[test] fn ukrainian_renders_with_bundled_fonts() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv new file mode 100644 index 00000000..b8daace5 --- /dev/null +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -0,0 +1,356 @@ +# Russian (ru) catalog. Columns separated by TAB: context source translation +# Empty context is a plain UI string. Escapes: \n, \t, \\; preserve placeholders and trailing … +# Original clean-room translations from the meaning of FilmCraft's English menu labels, +# using Russian video-editing vocabulary (Premiere-style). No proprietary localization resources were used. +# Core menu coverage; other interface strings fall back to English. + + About FilmCraft О FilmCraft + Add Audio Submix Track Добавить дорожку подмикширования звука + Add Caption at Playhead Добавить субтитр в позиции курсора воспроизведения + Add Chapter Marker… Добавить маркер главы… + Add Edit Добавить склейку + Add Edit to All Tracks Добавить склейку на все дорожки + Add Flash Cue Marker… Добавить маркер Flash Cue… + Add Frame Hold Добавить стоп-кадр + Add Guide… Добавить направляющую… + Add Marker Добавить маркер + Add New Caption Track… Добавить новую дорожку субтитров… + Add Range Marker Добавить маркер диапазона + Add Range Marker to In and Out Добавить маркер диапазона между точками входа и выхода + Add Tracks… Добавить дорожки… + Adjustment Layer… Корректирующий слой… + Align to Selection Выровнять по выделению + Align to Video Frame Выровнять по видеокадру + Align to Video Frame as Group Выровнять как группу по видеокадру + All Panels Все панели + Alpha Альфа + Appearance Оформление + Appearance… Оформление… + Apply Audio Transition Применить аудиопереход + Apply Default Transitions to Selection Применить переходы по умолчанию к выделению + Apply Video Transition Применить видеопереход + Arrange Упорядочить + ArtCraft Website Веб-сайт ArtCraft + Assembly Сборка + Attach Proxies… Прикрепить прокси… + Audio Аудио + Audio Channels… Аудиоканалы… + Audio Clip Mixer Микшер аудиоклипов + Audio Gain… Усиление звука… + Audio Hardware… Аудиооборудование… + Audio In Вход аудио + Audio Meters Индикаторы звука + Audio Options Параметры аудио + Audio Out Выход аудио + Audio Track Mixer Микшер аудиодорожек + Audio Waveform Форма звуковой волны + Auto Save… Автосохранение… + Automate to Sequence… Автоматизировать в эпизод… + Bars and Tone… Цветные полосы и тон… + Bin Подборка + Bin From Selection Подборка из выделения + Black Video… Черное видео… + Blue Синий + Bottom По нижнему краю + Breakout to Mono Разбить на моно + Bring Forward Переместить вперед + Bring to Front Переместить на передний план + Brown Коричневый + Captions Субтитры + Captions and Graphics Субтитры и графика + Captions… Субтитры… + Caribbean Карибский + Center Horizontally По центру по горизонтали + Center Vertically По центру по вертикали + Cerulean Лазурный + Clear Очистить + Clear Guides Очистить направляющие + Clear In Снять точку входа + Clear In and Out Снять точки входа и выхода + Clear Markers Удалить маркеры + Clear Out Снять точку выхода + Clear Selected Marker Удалить выбранный маркер + Clip Клип + Close Закрыть + Close All Other Projects Закрыть все остальные проекты + Close All Projects Закрыть все проекты + Close Gap Удалить пробел + Close Project Закрыть проект + Color Management… Управление цветом… + Color Matte… Цветная подложка… + Comparison View Режим сравнения + Composite Video Составное видео + Consolidate Duplicates Объединить дубликаты + Copy Копировать + Copy Paste Includes Sequence Markers Копирование и вставка включают маркеры эпизода + Create Captions from Transcript… Создать субтитры из расшифровки… + Create Multi-Camera Source Sequence… Создать многокамерный исходный эпизод… + Create Proxies… Создать прокси… + Cut Вырезать + Dark Темный + Darkest Самый темный + Delete Render Files Удалить файлы рендеринга + Delete Render Files In to Out Удалить файлы рендеринга между точками входа и выхода + Delete Tracks… Удалить дорожки… + Delete Transcript Удалить расшифровку + Demo Footage Демонстрационные материалы + Demo Project Демо-проект + Deselect All Отменить выделение + Detach Proxies Отсоединить прокси + Display Mode Режим отображения + Distribute Распределить + Distribute Horizontally Распределить по горизонтали + Distribute Space Horizontally Распределить интервалы по горизонтали + Distribute Space Vertically Распределить интервалы по вертикали + Distribute Vertically Распределить по вертикали + Duplicate Дублировать + Dynamic Audio Waveforms Динамические формы звуковой волны + Edit Правка + Edit Offline… Редактировать автономный клип… + Edit Original Редактировать оригинал + Edit Subclip… Редактировать субклип… + Editing Монтаж + Effect Controls Элементы управления эффектами + Effects Эффекты + Ellipse Эллипс + Enable Включить + Enable Remix Включить ремикс + Essential Graphics Основные графические элементы + Essential Sound Основной звук + Events События + Export Экспорт + Export As Motion Graphics Template… Экспортировать как шаблон анимированной графики… + Extract Извлечь + Extract Audio Извлечь аудио + Field Options… Параметры полей… + File Файл + File… Файл… + Fill frame Заполнить кадр + FilmCraft Help… Справка FilmCraft… + FilmCraft on GitHub FilmCraft на GitHub + FilmCraft on getartcraft.com FilmCraft на getartcraft.com + Find Next Найти далее + Find… Найти… + Fit По размеру + Fit to frame Вписать в кадр + Flatten Свести + Forest Лесной + Frame Blending Смешение кадров + Frame Hold Options… Параметры стоп-кадра… + Frame Sampling Выборка кадров + From Bin Из подборки + From Source Monitor Из монитора источника + From Source Monitor, Match Frame Из монитора источника, сопоставление кадров + From file… Из файла… + Full Полное + Generate Audio Waveform Сгенерировать форму звуковой волны + Get Media File Properties for Свойства медиафайла для + Go to Gap Перейти к промежутку + Go to In Перейти к точке входа + Go to Next Caption Segment Перейти к следующему сегменту субтитров + Go to Next Marker Перейти к следующему маркеру + Go to Out Перейти к точке выхода + Go to Previous Caption Segment Перейти к предыдущему сегменту субтитров + Go to Previous Marker Перейти к предыдущему маркеру + Go to Split Перейти к раздельной точке + Graphics and Titles Графика и титры + Graphics… Графика… + Green Зеленый + Group Сгруппировать + Guide Templates Шаблоны направляющих + Help Справка + Hide All Caption Tracks Скрыть все дорожки субтитров + High Quality Playback Высокое качество воспроизведения + History История + Import From Импортировать из + Import Image Sequence… Импортировать последовательность изображений… + Import from Media Browser Импортировать из медиабраузера + Import… Импорт… + Info Инфо + Ingest Settings… Параметры инжеста… + Insert Вставить + Insert Frame Hold Segment Вставить сегмент стоп-кадра + Install Motion Graphics Template… Установить шаблон анимированной графики… + Interpret Footage… Интерпретировать материал… + Join the ArtCraft Discord… Присоединиться к ArtCraft Discord… + Keyboard Shortcuts… Сочетания клавиш… + Label Метка + Labels… Метки… + Language Язык + Lavender Лавандовый + Learning Обучение + Left По левому краю + Libraries Библиотеки + Lift Поднять + Light Светлый + Link Связать + Link Media… Привязать медиаданные… + Linked Selection Связанное выделение + Lock Guides Закрепить направляющие + Lumetri Color Цвет Lumetri + Lumetri Scopes Видеографика Lumetri + Magnification Масштаб + Make Offline… Сделать автономным… + Make Subclip… Создать субклип… + Make Subsequence Создать вложенный эпизод + Manage Guides… Управление направляющими… + Mark Clip Пометить клип + Mark In Отметить точку входа + Mark Out Отметить точку выхода + Mark Selection Пометить выделение + Mark Split Отметить раздельную точку + Markers Маркеры + Match Frame Сопоставить кадр + Media Analysis & Transcription… Анализ медиа и расшифровка… + Media Browser Медиабраузер + Media Cache… Кэш медиаданных… + Media… Медиа… + Memory… Память… + Merge Clips… Объединить клипы… + Metadata Метаданные + Modify Изменить + Motion Graphics Template… Шаблон анимированной графики… + Multi-Camera Мультикамера + Multi-Camera View Мультикамерный режим + Nest… Вложить… + New Создать + New Layer Новый слой + New Project Panel Новая панель проекта + Next in Sequence Следующий в эпизоде + Next in Track Следующий на дорожке + Normalize Mix Track… Нормализовать мастер-дорожку… + Offline File… Автономный файл… + Open Project… Открыть проект… + Optical Flow Оптический поток + Overwrite Перезаписать + Paste Вставить + Paste Attributes… Вставить атрибуты… + Paste Insert Вставить со сдвигом + Paused Resolution Разрешение на паузе + Playback Resolution Разрешение воспроизведения + Playback… Воспроизведение… + Plugins… Плагины… + Polygon Многоугольник + Preferences Настройки + Previous in Sequence Предыдущий в эпизоде + Previous in Track Предыдущий на дорожке + Program Программа + Progress Прогресс + Project Проект + Project Manager… Диспетчер проектов… + Project Settings Настройки проекта + Project… Проект… + Properties Свойства + Purple Пурпурный + Reconnect Full Resolution Media… Переподключить медиаданные полного разрешения… + Recover Unsaved Changes… Восстановить несохраненные изменения… + Rectangle Прямоугольник + Red Красный + Redo Повторить + Reference Monitor Контрольный монитор + Remix Ремикс + Remix Properties… Свойства ремикса… + Remove Attributes… Удалить атрибуты… + Remove Filler Words Удалить слова-паразиты + Remove Pauses Удалить паузы + Remove Unused Удалить неиспользуемые + Rename… Переименовать… + Render Audio Рендеринг аудио + Render Effects In to Out Рендеринг эффектов от точки входа до точки выхода + Render In to Out Рендеринг от точки входа до точки выхода + Render Selection Рендеринг выделения + Replace Fonts in Projects… Заменить шрифты в проектах… + Replace With Clip Заменить клипом + Report an Issue… Сообщить о проблеме… + Reset All Parameters Сбросить все параметры + Reset Duration Сбросить длительность + Reset to Saved Layout Сбросить к сохраненному макету + Restore Captions from Source Clip Восстановить субтитры из исходного клипа + Reveal Log Files… Показать файлы журнала… + Reverse Match Frame Обратное сопоставление кадра + Revert Вернуть к сохраненному + Revert Remix Отменить ремикс + Review Рецензирование + Right По правому краю + Ripple Delete Удаление со сдвигом + Ripple Sequence Markers Сдвигать маркеры эпизода + Rose Розовый + Safe Margins Безопасные поля + Save Сохранить + Save All Сохранить все + Save As… Сохранить как… + Save Guides as Template… Сохранить направляющие как шаблон… + Save a Copy… Сохранить копию… + Save as Template… Сохранить как шаблон… + Scale to Frame Size Масштабировать по размеру кадра + Scene Edit Detection… Обнаружение склеек сцен… + Scratch Disks… Рабочие диски… + Search Bin Поисковая подборка + Select Выделение + Select All Выделить все + Select All Matching Выделить все совпадающие + Select Label Group Выделить группу по метке + Select Next Graphic Выбрать следующий графический элемент + Select Next Layer Выбрать следующий слой + Select Previous Graphic Выбрать предыдущий графический элемент + Select Previous Layer Выбрать предыдущий слой + Selection Follows Playhead Выделение следует за курсором воспроизведения + Selection as FilmCraft Project… Выделение как проект FilmCraft… + Selection… Выделение… + Send Backward Переместить назад + Send to Back Переместить на задний план + Sequence Эпизод + Sequence From Clip Эпизод из клипа + Sequence Settings… Настройки эпизода… + Sequence… Эпизод… + Settings Параметры + Show Active Caption Tracks Only Показать только активные дорожки субтитров + Show All Caption Tracks Показать все дорожки субтитров + Show All Marker Colors Показать все цвета маркеров + Show Guides Показать направляющие + Show Rulers Показать линейки + Show Through Edits Показать сквозные склейки + Simplify Sequence… Упростить эпизод… + Snap in Program Monitor Привязка в программном мониторе + Snap in Timeline Привязка на таймлайне + Source Источник + Source Settings… Настройки источника… + Speed/Duration… Скорость/Длительность… + Synchronize… Синхронизировать… + System Compatibility Report… Отчет о совместимости системы… + Tan Рыжевато-коричневый + Teal Бирюзовый + Text Текст + Time Interpolation Интерполяция времени + Timecode Тайм-код + Timecode… Тайм-код… + Timeline Таймлайн + Timeline… Таймлайн… + Toggle Proxies Переключить прокси + Tools Инструменты + Top По верхнему краю + Transcribe Sequence… Транскрибировать эпизод… + Transcribe… Транскрибировать… + Transcript Расшифровка + Transparent Video… Прозрачное видео… + Trim Edit Режим подрезки + Trim… Подрезка… + Undo Отменить + Ungroup Разгруппировать + Universal Counting Leader… Универсальный ракорд… + Update Metadata… Обновить метаданные… + Upgrade Caption to Graphic Преобразовать субтитр в графику + Upgrade to Source Graphic Преобразовать в исходную графику + Vertical Text Вертикальный текст + Video In Вход видео + Video Options Параметры видео + Video Out Выход видео + Video and Audio Waveform Split Раздельное отображение видео и аудиоволны + View Просмотр + Violet Фиолетовый + Window Окно + Workspaces Рабочие среды + Yellow Желтый + Zoom In Увеличить + Zoom Out Уменьшить + Zoom to Sequence Масштабировать по размеру эпизода diff --git a/crates/ui-egui/src/i18n/ru.tsv.attribution b/crates/ui-egui/src/i18n/ru.tsv.attribution new file mode 100644 index 00000000..23c128ac --- /dev/null +++ b/crates/ui-egui/src/i18n/ru.tsv.attribution @@ -0,0 +1,8 @@ +asset: ru.tsv +title: Russian interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-10 by grib_job +notes: Original English-to-Russian menu translations using clean-room NLE vocabulary. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5cac050e..581af9a3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -27,6 +27,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.spanish", "Español", ["Edit", "Language"], None), uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None), uic!("app.language.ukrainian", "Українська", ["Edit", "Language"], None), + uic!("app.language.russian", "Русский", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -170,7 +171,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: let object = params.as_object_mut().ok_or("command parameters must be an object")?; object.insert("target".into(), json!("source")); } - if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese" | "app.language.ukrainian") { + if matches!(id, "app.language.english" | "app.language.japanese" | "app.language.spanish" | "app.language.portuguese" | "app.language.ukrainian" | "app.language.russian") { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into()); @@ -180,6 +181,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.spanish" => crate::i18n::Language::Es, "app.language.portuguese" => crate::i18n::Language::PtBr, "app.language.ukrainian" => crate::i18n::Language::Uk, + "app.language.russian" => crate::i18n::Language::Ru, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -551,6 +553,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.spanish" => it.checked = Some(app.ui.language == crate::i18n::Language::Es), "app.language.portuguese" => it.checked = Some(app.ui.language == crate::i18n::Language::PtBr), "app.language.ukrainian" => it.checked = Some(app.ui.language == crate::i18n::Language::Uk), + "app.language.russian" => it.checked = Some(app.ui.language == crate::i18n::Language::Ru), _ => {} } if it.id.starts_with("view.") { From f91b86dd48e180161fd67e5b4537cfbb18f7820c Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:26:28 +0700 Subject: [PATCH 003/203] codecs: import FLAC audio from MP4/MOV (pass STREAMINFO from dfLa) The ISO-BMFF path created the FLAC decoder with no extra data, so every FLAC track in an MP4 or MOV failed with "flac: missing extra data". The decoder now gets the STREAMINFO body that `dfLa` carries after its 4-byte block header, as the Matroska path already did. Regression test `mp4_flac_audio` (an ffmpeg-made MP4 with FLAC audio) failed before this change. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio.rs | 3 ++- crates/codecs/src/tests.rs | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 5f7c02f4..0a24e610 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -139,7 +139,8 @@ impl PacketDecoder { C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None), C::Ac3 { .. } => Self::ac3(), C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())), - C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None), + // the decoder needs the STREAMINFO body: `dfLa` holds it after the 4-byte block header + C::Flac(f) => Self::new(CODEC_TYPE_FLAC, rate, f.metadata_blocks.get(4..38).map(<[u8]>::to_vec)), C::Opus(o) => Self::opus(filmcraft_opus::OpusHead::from_dops(&o.to_bytes()).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), other => Err(CodecError::Unsupported(format!("{} audio", other.name()))), } diff --git a/crates/codecs/src/tests.rs b/crates/codecs/src/tests.rs index 3f1f5047..05bfcf0d 100644 --- a/crates/codecs/src/tests.rs +++ b/crates/codecs/src/tests.rs @@ -224,6 +224,18 @@ fn matroska_hevc_flac() { check_mkv("blue_hevc_flac.mkv", &["-c:v", "libx265", "-x265-params", "log-level=error", "-pix_fmt", "yuv420p"], &["-c:a", "flac"]); } +/// FLAC in MP4 (`fLaC` + `dfLa`): the decoder gets the STREAMINFO from `dfLa` (it was given +/// nothing, and every FLAC track in an MP4 failed with "missing extra data"). +#[test] +fn mp4_flac_audio() { + let args = ["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:d=2", "-c:a", "flac", "-strict", "-2"]; + let Some(b) = fixture("tone_flac.mp4", &args) else { return }; + let src = crate::open_bytes("tone_flac.mp4", b).expect("open"); + let a = src.audio(24_000, 4800, 48_000).expect("audio"); + let peak = a.channels[0].iter().fold(0f32, |m, s| m.max(s.abs())); + assert!(peak > 0.05 && peak < 1.0, "audio peak {peak}"); +} + #[test] fn matroska_prores_pcm() { check_mkv("blue_prores_pcm.mkv", &["-c:v", "prores_ks", "-profile:v", "2"], &["-c:a", "pcm_s16le"]); From 055e95ab1763c21771c234befcb145c1c9c6af83 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:26:28 +0700 Subject: [PATCH 004/203] Export: FLAC (clean-room encoder; .flac files and FLAC audio in MP4/MOV) New L0 crate filmcraft-flac, a FLAC encoder written from RFC 9639: constant / verbatim / fixed / LPC subframes (Levinson-Durbin on Tukey- windowed autocorrelation, up to order 12), partitioned Rice residuals, left/side, side/right and mid/side stereo, CRC-8/CRC-16, STREAMINFO with MD5 (RFC 1321, own implementation), levels 0-8, all lossless. Export: - Format::Flac, an audio-only .flac (16/24-bit), streamed one second at a time like WAV/AIFF; STREAMINFO is rewritten at the end with frame sizes, total samples and MD5. Preset "FLAC 48 kHz 24-bit". - AudioCodec::Flac in MP4 and QuickTime (fLaC + dfLa), one frame per sample with the short last frame timed exactly; the dfLa STREAMINFO is replaced before the moov is written (Mp4Writer::set_sample_entry). MXF stays PCM. - AudioSettings::flac_level (default 5); Export panel: FLAC (lossless) audio format and Compression Level; file.exportMedia `format: "flac"`, `audioCodec`, `flacLevel`. Spanish and Japanese strings. ffmpeg oracle: every stream decodes bit-exactly with the STREAMINFO MD5 equal to ffmpeg's hash; .flac and FLAC-in-MP4/MOV exports decode to the same samples as a WAV export of the range. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 + Cargo.toml | 4 + crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 3 +- crates/engine/src/export_tools.rs | 6 + crates/export/Cargo.toml | 1 + crates/export/src/job.rs | 10 +- crates/export/src/lib.rs | 116 +++++++- crates/export/src/pcm.rs | 2 +- crates/export/src/presets.rs | 7 +- crates/export/src/settings.rs | 33 ++- crates/export/src/settings_tests.rs | 122 ++++++++- crates/flac/Cargo.toml | 17 ++ crates/flac/README.md | 62 +++++ crates/flac/src/crc.rs | 57 ++++ crates/flac/src/frame.rs | 203 ++++++++++++++ crates/flac/src/lib.rs | 247 +++++++++++++++++ crates/flac/src/md5.rs | 118 ++++++++ crates/flac/src/predict.rs | 327 +++++++++++++++++++++++ crates/flac/tests/ffmpeg_oracle.rs | 181 +++++++++++++ crates/isobmff/src/codec.rs | 10 + crates/isobmff/src/mux.rs | 8 + crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/ja.tsv | 6 + crates/ui-egui/src/panels/export_mode.rs | 23 +- xtask/src/main.rs | 1 + 26 files changed, 1553 insertions(+), 29 deletions(-) create mode 100644 crates/flac/Cargo.toml create mode 100644 crates/flac/README.md create mode 100644 crates/flac/src/crc.rs create mode 100644 crates/flac/src/frame.rs create mode 100644 crates/flac/src/lib.rs create mode 100644 crates/flac/src/md5.rs create mode 100644 crates/flac/src/predict.rs create mode 100644 crates/flac/tests/ffmpeg_oracle.rs diff --git a/Cargo.lock b/Cargo.lock index 4803e4b1..9a4b2aee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1895,6 +1895,7 @@ dependencies = [ "filmcraft-codecs", "filmcraft-color", "filmcraft-dnx", + "filmcraft-flac", "filmcraft-frame", "filmcraft-geom", "filmcraft-h264enc", @@ -1915,6 +1916,15 @@ dependencies = [ "web-time", ] +[[package]] +name = "filmcraft-flac" +version = "0.5.0" +dependencies = [ + "filmcraft-bitstream", + "filmcraft-testkit", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-format" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 58a21642..4fb6e3a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ filmcraft-dnx = { path = "crates/dnx" } filmcraft-apv = { path = "crates/apv" } filmcraft-av1 = { path = "crates/av1" } filmcraft-aac = { path = "crates/aac" } +filmcraft-flac = { path = "crates/flac" } filmcraft-opus = { path = "crates/opus" } filmcraft-h264enc = { path = "crates/h264enc" } filmcraft-audio-dsp = { path = "crates/audio-dsp" } @@ -111,6 +112,9 @@ opt-level = 3 [profile.dev.package.filmcraft-aac] opt-level = 3 +[profile.dev.package.filmcraft-flac] +opt-level = 3 + # Audio effects: realtime-factor tests and long convolution / spectral runs. [profile.dev.package.filmcraft-audio-dsp] opt-level = 3 diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 70cdeb65..3bf96052 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1154,7 +1154,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|flac"?,"audioCodec":"auto|aac|pcm|flac"?,"flacLevel":0..8=5,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba..2ca25897 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -144,10 +144,11 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let Some(j) = probe(&out) else { continue }; let fmt_name = j["format"]["format_name"].as_str().unwrap(); match fmt.as_str() { - "wav" | "aiff" => { + "wav" | "aiff" | "flac" => { let a = stream(&j, "audio").unwrap(); let bits = settings["audio"]["bits"].as_u64().unwrap(); let codec = match (fmt.as_str(), bits) { + ("flac", _) => "flac", ("wav", 24) => "pcm_s24le", ("wav", _) => "pcm_s16le", (_, 24) => "pcm_s24be", diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index a2ef4ab4..e92c9ef3 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -242,6 +242,12 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option if let Some(v) = str_p(p, "apvProfile") { settings.apv_profile = v.to_string(); } + if let Some(v) = str_p(p, "audioCodec") { + settings.audio.codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "audioCodec: auto | aac | pcm | flac"))?; + } + if let Some(v) = u64_p(p, "flacLevel") { + settings.audio.flac_level = v.min(8) as u8; + } if let Some(v) = str_p(p, "mxfVideoCodec") { settings.mxf_video_codec = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "mxfVideoCodec: dnxhr | proRes | h264"))?; } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index d058e4e6..3732c7fb 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -25,6 +25,7 @@ filmcraft-dnx = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-mxf = { workspace = true } filmcraft-aac = { workspace = true } +filmcraft-flac = { workspace = true } filmcraft-h264enc = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 88864eaf..e5e6007c 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -257,6 +257,7 @@ impl Exporter { audio_factories().read().unwrap_or_else(|e| e.into_inner()).iter().find_map(|f| f(settings.format, a.sr, a.channels as u32, &settings)); Some(found.ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??) } + (Some(a), AudioCodec::Flac) => Some(Box::new(crate::FlacAudio::new(a.sr, a.channels as u32, &settings)?)), _ => None, }; let measured = !settings.effects.loudness.enabled || audio.is_none(); @@ -484,11 +485,12 @@ impl Exporter { self.write_video(tail)?; self.write_audio(mixed)?; if let (Some(at), Some(a)) = (self.at, self.aenc.as_mut()) { - let fs = a.frame_size(); let mux = self.mux.as_mut().ok_or_else(|| ExportError::Encode("internal: the container writer was not created".into()))?; - for au in a.flush()? { - mux.write_sample(at, WriteSample { data: &au, duration: fs, composition_offset: 0, is_sync: true }) - .map_err(|e| ExportError::Io(e.to_string()))?; + for (au, duration) in a.flush_timed()? { + mux.write_sample(at, WriteSample { data: &au, duration, composition_offset: 0, is_sync: true }).map_err(|e| ExportError::Io(e.to_string()))?; + } + if let Some(entry) = a.final_sample_entry() { + mux.set_sample_entry(at, entry).map_err(|e| ExportError::Io(e.to_string()))?; } } let (bytes, extra_files) = match self.mxf.take() { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 2821b57b..29e76d6b 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -98,6 +98,9 @@ pub enum Format { Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, + /// FLAC (RFC 9639): lossless compressed audio. + #[serde(rename = "flac", alias = "Flac")] + Flac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, @@ -124,6 +127,7 @@ impl Format { "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, + "flac" | "fla" => Format::Flac, _ => return None, }) } @@ -142,6 +146,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", + Format::Flac => "flac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } @@ -156,6 +161,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", + Format::Flac => "flac", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } @@ -173,10 +179,15 @@ impl Format { Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", + Format::Flac => "FLAC", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } } + /// Audio only: WAV, AIFF or FLAC. + pub fn is_audio_only(self) -> bool { + matches!(self, Format::Wav | Format::Aiff | Format::Flac) + } /// An MXF container format. pub fn is_mxf(self) -> bool { matches!(self, Format::MxfOp1a | Format::MxfOpAtom) @@ -190,7 +201,7 @@ impl Format { pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) } - pub const ALL: [Format; 14] = [ + pub const ALL: [Format; 15] = [ Format::H264, Format::Hevc, Format::ProRes, @@ -203,6 +214,7 @@ impl Format { Format::Gif, Format::Wav, Format::Aiff, + Format::Flac, Format::MxfOp1a, Format::MxfOpAtom, ]; @@ -319,6 +331,12 @@ impl std::fmt::Debug for OutputSink { } } +/// How an audio-only export codes its samples. +enum AudioWriter { + Pcm(pcm::PcmContainer), + Flac(Box), +} + /// An export's output file: on disk, or in memory for an [`OutputSink`]. enum Out { File(std::io::BufWriter), @@ -623,6 +641,17 @@ pub trait AudioEncoder: Send { fn priming(&self) -> u32; /// Samples per access unit (1024 for AAC). fn frame_size(&self) -> u32; + /// [`AudioEncoder::flush`] with each access unit's duration, for codecs whose last unit is + /// shorter than [`AudioEncoder::frame_size`] (FLAC); by default every unit is a full frame. + fn flush_timed(&mut self) -> Result, u32)>> { + let n = self.frame_size(); + Ok(self.flush()?.into_iter().map(|au| (au, n)).collect()) + } + /// The sample entry once everything is encoded, when it differs from the first one (the FLAC + /// STREAMINFO learns its frame sizes and MD5 at the end). + fn final_sample_entry(&self) -> Option { + None + } } pub type EncoderFactory = fn(format: Format, width: u32, height: u32, rate: FrameRate, settings: &ExportSettings) -> Option>>; @@ -872,6 +901,53 @@ impl AudioEncoder for AacEncoder { } } +/// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit. +pub(crate) struct FlacAudio { + enc: filmcraft_flac::Encoder, + bits: u16, + /// Samples of the frame `flush` returns (shorter than a full block). + tail: u32, +} + +impl FlacAudio { + pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result { + let bits = if s.audio.bits >= 24 { 24 } else { 16 }; + let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?; + let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, bits as u8); + cfg.level = s.audio.flac_level.min(8); + let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?; + Ok(FlacAudio { enc, bits, tail: 0 }) + } +} + +impl AudioEncoder for FlacAudio { + fn sample_entry(&self) -> SampleEntry { + SampleEntry::flac(&self.enc.streaminfo()) + } + fn encode(&mut self, planar: &[Vec]) -> Result>> { + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, self.bits)).collect()).collect(); + let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); + self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}"))) + } + fn flush(&mut self) -> Result>> { + Ok(self.flush_timed()?.into_iter().map(|(au, _)| au).collect()) + } + fn flush_timed(&mut self) -> Result, u32)>> { + self.tail = self.enc.pending_samples() as u32; + let last = self.enc.finish(); + Ok(if last.is_empty() { Vec::new() } else { vec![(last, self.tail)] }) + } + fn priming(&self) -> u32 { + 0 + } + fn frame_size(&self) -> u32 { + 4096 + } + fn final_sample_entry(&self) -> Option { + Some(SampleEntry::flac(&self.enc.streaminfo())) + } +} + fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option>> { // AAC caps a frame at 6144 bits per channel (ISO/IEC 14496-3 §4.5.3.2): 6 bits per sample, so // 264.6 kbps for stereo at 22.05 kHz; a higher setting is capped instead of refused @@ -1473,13 +1549,24 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let cancelled = || progress.cancel.load(Ordering::Relaxed); let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { - Format::Wav | Format::Aiff => { + Format::Wav | Format::Aiff | Format::Flac => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); - let container = if settings.format == Format::Wav { pcm::PcmContainer::Wav } else { pcm::PcmContainer::Aiff }; let frames = a.remaining(); // the sizes are known up front: refuse an AIFF that cannot hold them before measuring - let head = pcm::header(container, ch, sr, bits, frames).map_err(ExportError::Unsupported)?; + let mut writer = match settings.format { + Format::Flac => { + let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, if bits >= 24 { 24 } else { 16 }); + cfg.level = settings.audio.flac_level.min(8); + AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?)) + } + Format::Wav => AudioWriter::Pcm(pcm::PcmContainer::Wav), + _ => AudioWriter::Pcm(pcm::PcmContainer::Aiff), + }; + let head = match &writer { + AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, bits, frames).map_err(ExportError::Unsupported)?, + AudioWriter::Flac(enc) => enc.header(), + }; // one second per chunk: memory stays flat however long the range is let chunk = i64::from(sr.max(1)); if !settings.part_of_batch { @@ -1497,13 +1584,30 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so return Err(ExportError::Cancelled); } buf.clear(); - pcm::encode(container, &audio_out::interleave(&planar), bits, &mut buf); + match &mut writer { + AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), bits, &mut buf), + AudioWriter::Flac(enc) => { + let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| pcm::quantise(s, bits)).collect()).collect(); + let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); + buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?; + } + } out.write_all(&buf).map_err(io)?; if !settings.part_of_batch { progress.done.fetch_add(1, Ordering::Relaxed); } } - out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?; + match &mut writer { + AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, bits)).map_err(io)?, + AudioWriter::Flac(enc) => { + out.write_all(&enc.finish()).map_err(io)?; + // the frame sizes and MD5 are known now: fill them into STREAMINFO + use std::io::{Seek, SeekFrom}; + out.seek(SeekFrom::Start(filmcraft_flac::STREAMINFO_OFFSET)).map_err(io)?; + out.write_all(&enc.streaminfo()).map_err(io)?; + out.seek(SeekFrom::End(0)).map_err(io)?; + } + } let n = out.finish(settings)?; if settings.part_of_batch { progress.done.store(1, Ordering::Relaxed); diff --git a/crates/export/src/pcm.rs b/crates/export/src/pcm.rs index 03b88a74..7601886f 100644 --- a/crates/export/src/pcm.rs +++ b/crates/export/src/pcm.rs @@ -13,7 +13,7 @@ pub enum PcmContainer { } /// Quantise one sample to a signed integer of `bits` (16 or 24). -fn quantise(s: f32, bits: u16) -> i32 { +pub(crate) fn quantise(s: f32, bits: u16) -> i32 { let max = if bits >= 24 { 8_388_607.0 } else { 32_767.0 }; (s.clamp(-1.0, 1.0) * max).round() as i32 } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 3aa37d05..12ee53ad 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -41,7 +41,7 @@ fn h264(size: Option<(u32, u32)>, kbps: u32, max_kbps: u32) -> ExportSettings { bitrate_kbps: kbps, max_bitrate_kbps: Some(max_kbps), bitrate_mode: BitrateMode::Vbr1Pass, - audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16, ..Default::default() }, ..Default::default() } } @@ -53,7 +53,7 @@ fn adaptive(bpp: f32) -> ExportSettings { fn mov(format: Format, profile: &str) -> ExportSettings { let mut s = ExportSettings { format, - audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24 }, + audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 24, ..Default::default() }, render_at_max_depth: true, ..Default::default() }; @@ -82,7 +82,7 @@ fn mxf(format: Format, codec: MxfVideoCodec, profile: &str) -> ExportSettings { fn audio_only(format: Format, bits: u16) -> ExportSettings { ExportSettings { format, - audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits }, + audio: AudioSettings { codec: AudioCodec::Pcm, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 0, bits, ..Default::default() }, ..Default::default() } } @@ -153,6 +153,7 @@ pub fn builtin_presets() -> Vec { p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)), p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)), p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)), + p("FLAC 48 kHz 24-bit", "Audio Only", "FLAC, stereo 48 kHz 24-bit, lossless", audio_only(Format::Flac, 24)), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index dad3b0e4..e841f645 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -140,7 +140,8 @@ impl MxfVideoCodec { } } -/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF. +/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF, FLAC in FLAC. MP4 and +/// QuickTime can also carry FLAC; MXF is always PCM. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioCodec { @@ -148,6 +149,8 @@ pub enum AudioCodec { Auto, Aac, Pcm, + /// Lossless compression (the FLAC format). + Flac, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -160,13 +163,15 @@ pub struct AudioSettings { pub channels: u32, /// AAC bitrate. pub bitrate_kbps: u32, - /// PCM sample size: 16 or 24. + /// PCM and FLAC sample size: 16 or 24. pub bits: u16, + /// FLAC compression level, 0 (fastest) to 8 (smallest). Every level is lossless. + pub flac_level: u8, } impl Default for AudioSettings { fn default() -> Self { - AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16 } + AudioSettings { codec: AudioCodec::Auto, sample_rate: None, channels: 2, bitrate_kbps: 320, bits: 16, flac_level: 5 } } } @@ -405,7 +410,10 @@ impl ExportSettings { /// The audio codec actually used. pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { + (Format::Flac, _) => AudioCodec::Flac, (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, + // MP4 and QuickTime both carry FLAC (`fLaC` + `dfLa`) + (_, AudioCodec::Flac) => AudioCodec::Flac, (f, _) if f.is_h26x() && self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_h26x() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -420,7 +428,7 @@ impl ExportSettings { /// Whether the format carries video. pub fn has_video(&self) -> bool { - !matches!(self.format, Format::Wav | Format::Aiff) + !self.format.is_audio_only() } /// File extension of the output. @@ -460,12 +468,15 @@ impl ExportSettings { Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, - Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, + Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; - let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio_bps = if self.has_audio() || self.format.is_audio_only() { + let pcm = r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }; match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, - _ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }, + // typical of music and dialogue; noise compresses less, silence far more + AudioCodec::Flac => pcm * 0.6, + _ => pcm, } } else { 0.0 @@ -534,7 +545,7 @@ impl ExportSettings { } v }; - let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio = if self.has_audio() || self.format.is_audio_only() { let ch = match r.channels { 1 => "Mono", 6 => "5.1", @@ -542,6 +553,12 @@ impl ExportSettings { }; match self.audio_codec() { AudioCodec::Aac => format!("AAC, {} kbps, {} Hz, {ch}", self.audio.bitrate_kbps, r.sample_rate), + AudioCodec::Flac => format!( + "FLAC (lossless) {}-bit, level {}, {} Hz, {ch}", + if self.audio.bits >= 24 { 24 } else { 16 }, + self.audio.flac_level.min(8), + r.sample_rate + ), _ => format!("Uncompressed {}-bit PCM, {} Hz, {ch}", if self.audio.bits >= 24 { 24 } else { 16 }, r.sample_rate), } } else { diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index f0841484..4e86346a 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -167,6 +167,126 @@ fn wav_and_aiff_audio_only() { } } +#[test] +fn flac_is_lossless_against_wav() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("flac"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2))); + let audio = AudioSettings { channels: 2, bits: 24, ..Default::default() }; + let (wav, flac) = (dir.path("a.wav"), dir.path("a.flac")); + let w = ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() }; + export(&p, seq, &w, &m, &Progress::default()).unwrap(); + let f = ExportSettings { format: Format::Flac, path: flac.clone(), range, audio, ..Default::default() }; + let prog = Progress::default(); + let r = export(&p, seq, &f, &m, &prog).unwrap(); + assert_eq!(r.frames, 72_000, "1.5 s at 48 kHz"); + // one progress step per second of audio + assert_eq!((prog.total.load(Ordering::Relaxed), prog.done.load(Ordering::Relaxed)), (2, 2)); + let (wb, fb) = (std::fs::read(&wav).unwrap(), std::fs::read(&flac).unwrap()); + assert_eq!(&fb[..4], b"fLaC"); + assert!(fb.len() * 2 < wb.len(), "a tone compresses: FLAC {} bytes, WAV {}", fb.len(), wb.len()); + // STREAMINFO was filled in at the end: total samples + let si = &fb[8..42]; + assert_eq!((u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes(si[14..18].try_into().unwrap())), 72_000); + + // the same samples through FilmCraft's own readers + let decode = |name: &str, bytes: Vec| filmcraft_codecs::open_bytes(name, bytes.into()).unwrap().audio(0, 72_000, 48_000).unwrap(); + let (a, b) = (decode("a.wav", wb), decode("a.flac", fb)); + assert_eq!(a.channels.len(), 2); + for (ca, cb) in a.channels.iter().zip(&b.channels) { + assert_eq!(ca.len(), cb.len()); + let worst = ca.iter().zip(cb).map(|(x, y)| (x - y).abs()).fold(0.0f32, f32::max); + assert!(worst < 1e-6, "differs by {worst}"); + assert!(ca.iter().any(|v| v.abs() > 0.1), "the tone is there"); + } + // and bit for bit through ffmpeg + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac export") { + let raw = |path: &str| { + let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-i", path, "-f", "s32le", "-c:a", "pcm_s32le", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{}", String::from_utf8_lossy(&out.stderr)); + out.stdout + }; + let (a, b) = (raw(&wav), raw(&flac)); + assert_eq!(a.len(), 72_000 * 2 * 4); + assert!(a == b, "FLAC must decode to the WAV's samples exactly"); + } + if let Some(j) = ffprobe_json(&["-show_streams"], &flac) { + let st = &j["streams"][0]; + assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["bits_per_raw_sample"].as_str()), (Some("flac"), Some("48000"), Some("24"))); + } +} + +#[test] +fn flac_audio_in_mp4_and_mov() { + let (p, seq, m) = matte([0.2, 0.4, 0.6, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("flac-in"); + // 1.5 s at 48 kHz: 17 full FLAC frames of 4096 samples and a last one of 2368 + let range = Some(TimeRange::new(Tick::ZERO, Tick(3 * TICKS_PER_SECOND / 2))); + let audio = AudioSettings { codec: AudioCodec::Flac, bits: 24, ..Default::default() }; + let wav = dir.path("ref.wav"); + export(&p, seq, &ExportSettings { format: Format::Wav, path: wav.clone(), range, audio: audio.clone(), ..Default::default() }, &m, &Progress::default()) + .unwrap(); + for (format, mux, name) in + [(Format::H264, Multiplexer::Mp4, "a.mp4"), (Format::H264, Multiplexer::Mov, "b.mov"), (Format::ProRes, Multiplexer::Mp4, "c.mov")] + { + let path = dir.path(name); + let s = ExportSettings { format, multiplexer: mux, path: path.clone(), range, audio: audio.clone(), ..Default::default() }; + assert_eq!(s.audio_codec(), AudioCodec::Flac, "{name}"); + assert!(s.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)).audio.starts_with("FLAC (lossless) 24-bit"), "{name}"); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes = std::fs::read(&path).unwrap(); + // `dfLa` was rewritten at the end: STREAMINFO with frame sizes, total samples and the MD5 + let at = bytes.windows(4).position(|w| w == b"dfLa").unwrap() + 4 + 4 + 4; + let si = &bytes[at..at + 34]; + assert_eq!(bytes[at - 4], 0x80, "{name}: STREAMINFO is the last metadata block"); + assert!(si[4..10].iter().any(|&b| b != 0), "{name}: frame sizes"); + assert_eq!(u32::from_be_bytes(si[14..18].try_into().unwrap()), 72_000, "{name}: total samples"); + assert!(si[18..34].iter().any(|&b| b != 0), "{name}: MD5"); + // FilmCraft's own importer reads the FLAC track back, sample-accurately long + let src = filmcraft_codecs::open_bytes(name, bytes.into()).unwrap(); + let a = src.audio(0, 72_000, 48_000).unwrap(); + assert!(a.channels.iter().all(|c| c.len() == 72_000) && a.channels[0].iter().any(|v| v.abs() > 0.1), "{name}"); + if let Some(j) = ffprobe_json(&["-show_streams", "-count_packets"], &path) { + let st = j["streams"].as_array().unwrap().iter().find(|s| s["codec_type"] == "audio").unwrap().clone(); + assert_eq!((st["codec_name"].as_str(), st["sample_rate"].as_str(), st["channels"].as_u64()), (Some("flac"), Some("48000"), Some(2)), "{name}"); + assert_eq!(st["nb_read_packets"].as_str(), Some("18"), "{name}: one FLAC frame per packet"); + assert_eq!(st["duration_ts"].as_u64(), Some(72_000), "{name}: the short last frame is timed exactly"); + } + // the audio decodes to the WAV export's samples bit for bit + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("flac in containers") { + let raw = |path: &str| { + let out = std::process::Command::new(&ffmpeg) + .args(["-v", "error", "-i", path, "-map", "0:a", "-f", "s32le", "-c:a", "pcm_s32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{name}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout + }; + assert!(raw(&path) == raw(&wav), "{name}: FLAC must decode to the WAV's samples exactly"); + } + } + // MXF stays PCM whatever is asked for + let mxf = ExportSettings { format: Format::MxfOp1a, audio: audio.clone(), ..Default::default() }; + assert_eq!(mxf.audio_codec(), AudioCodec::Pcm); +} + +#[test] +fn flac_settings_round_trip_and_summary() { + let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "flac"})).unwrap(); + assert_eq!((s.format, s.audio_codec(), s.audio.flac_level, s.has_video()), (Format::Flac, AudioCodec::Flac, 5, false)); + assert_eq!((s.extension(), Format::from_name("FLAC"), Format::Flac.id()), ("flac", Some(Format::Flac), "flac")); + // settings saved before FLAC read the default level + let old: AudioSettings = serde_json::from_value(serde_json::json!({"bits": 24})).unwrap(); + assert_eq!(old.flac_level, 5); + let sum = ExportSettings { format: Format::Flac, ..Default::default() }.summary(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + assert!(sum.audio.starts_with("FLAC (lossless) 16-bit, level 5, 48000 Hz"), "{}", sum.audio); + assert_eq!(sum.video, "No video"); + // estimated below PCM + let pcm = ExportSettings { format: Format::Wav, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + let flac = ExportSettings { format: Format::Flac, ..Default::default() }.estimate_bytes(64, 36, FrameRate::FPS_24, 48_000, Tick(TICKS_PER_SECOND)); + assert!(flac < pcm, "{flac} < {pcm}"); +} + #[test] fn invalid_export_allocations_are_rejected_before_rendering() { let (project, sequence, _) = matte([1.0, 0.0, 0.0, 1.0], 64, 36, None); @@ -496,7 +616,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 14); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/flac/Cargo.toml b/crates/flac/Cargo.toml new file mode 100644 index 00000000..8d6d0509 --- /dev/null +++ b/crates/flac/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "filmcraft-flac" +description = "Clean-room FLAC encoder (RFC 9639)" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/flac/README.md b/crates/flac/README.md new file mode 100644 index 00000000..3909ab79 --- /dev/null +++ b/crates/flac/README.md @@ -0,0 +1,62 @@ +# filmcraft-flac + +A clean-room FLAC encoder (Free Lossless Audio Codec, [RFC 9639](https://www.rfc-editor.org/rfc/rfc9639)), +written from the RFC. Layer L0: it depends only on `filmcraft-bitstream` and `thiserror`, has no +`unsafe`, and builds for `wasm32-unknown-unknown`. + +No libFLAC, FFmpeg or other FLAC implementation was read or ported. ffmpeg is used only as an +external **test oracle** (decoding our streams). Decoding FLAC in FilmCraft is still done by the +symphonia bootstrap decoder in `filmcraft-codecs`. + +## API + +```rust +use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; + +let mut cfg = EncoderConfig::new(48_000, 2, 24); // rate, channels (1–8), bits (8–24) +cfg.level = 8; // 0 fastest … 8 smallest; 5 by default +let mut enc = Encoder::new(cfg)?; +let mut file = enc.header(); // fLaC + STREAMINFO + VORBIS_COMMENT (vendor) +file.extend(enc.encode(&[&left, &right])?); // planar i32 samples, any chunk size → whole frames +file.extend(enc.finish()); // the last, shorter frame +// frame sizes, total samples and MD5 are only known now: patch STREAMINFO (or seek back in a file) +let at = STREAMINFO_OFFSET as usize; +file[at..at + 34].copy_from_slice(&enc.streaminfo()); + +// MP4 / QuickTime (`fLaC` + `dfLa`): one frame per sample, the STREAMINFO body in `dfLa` +let frames: Vec> = enc.encode_frames(&[&left, &right])?; // each a full block +let last_len = enc.pending_samples(); // duration of the frame finish() writes +``` + +## What it writes + +| Part | | +|---|---| +| Metadata | STREAMINFO (block size, frame sizes, rate, channels, sample size, total samples, MD5 of the audio) and a VORBIS_COMMENT with the vendor string | +| Frames | fixed block size (4096 by default), frame numbers, sample rate and size codes, CRC-8 header and CRC-16 frame checksums | +| Subframes | constant, verbatim, fixed predictors (order 0–4) and linear prediction (up to order 12), whichever is smallest | +| Linear prediction | Tukey(0.5) windowed autocorrelation, Levinson–Durbin, 12/15-bit quantised coefficients with error feedback | +| Residual | partitioned Rice codes (4- or 5-bit parameters), the partition order chosen per subframe | +| Stereo | independent, left/side, side/right or mid/side per frame, whichever is smallest | + +Not written: variable block sizes, wasted-bits shifts, escape-coded partitions, 32-bit samples, +seek tables. + +| Level | LPC order | Rice partition order | Stereo decorrelation | +|---|---|---|---| +| 0 | fixed only | ≤ 3 | no | +| 1–2 | fixed only | ≤ 3 | yes | +| 3 | ≤ 6 | ≤ 4 | no | +| 4 | ≤ 8 | ≤ 4 | yes | +| 5 (default) | ≤ 8 | ≤ 5 | yes | +| 6 | ≤ 8 | ≤ 6 | yes | +| 7–8 | ≤ 12 | ≤ 6 | yes | + +## Tests + +`cargo test -p filmcraft-flac`: CRC check values, the RFC 1321 MD5 suite, frame-number coding, +predictor round trips, and `tests/ffmpeg_oracle.rs`, which decodes every stream with ffmpeg and +requires every sample back exactly, no decoder errors, and the STREAMINFO MD5 equal to ffmpeg's +hash of the decoded audio. It covers music-like, sweep, noise, full-scale square, silence and click +signals; 8/16/20/24-bit; mono, stereo and 5.1; common and odd sample rates; block sizes 16 to 65535; +and every level. Skipped (with a `SKIPPED` line) without ffmpeg. diff --git a/crates/flac/src/crc.rs b/crates/flac/src/crc.rs new file mode 100644 index 00000000..0cc4869b --- /dev/null +++ b/crates/flac/src/crc.rs @@ -0,0 +1,57 @@ +//! The two frame checksums of RFC 9639 §9.1.8 and §9.3: CRC-8 (polynomial x⁸+x²+x+1) over the +//! frame header and CRC-16 (x¹⁶+x¹⁵+x²+1) over the whole frame, both MSB-first with a zero start. + +const fn table8() -> [u8; 256] { + let mut t = [0u8; 256]; + let mut i = 0; + while i < 256 { + let mut c = i as u8; + let mut b = 0; + while b < 8 { + c = if c & 0x80 != 0 { (c << 1) ^ 0x07 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +const fn table16() -> [u16; 256] { + let mut t = [0u16; 256]; + let mut i = 0; + while i < 256 { + let mut c = (i as u16) << 8; + let mut b = 0; + while b < 8 { + c = if c & 0x8000 != 0 { (c << 1) ^ 0x8005 } else { c << 1 }; + b += 1; + } + t[i] = c; + i += 1; + } + t +} + +static T8: [u8; 256] = table8(); +static T16: [u16; 256] = table16(); + +pub fn crc8(data: &[u8]) -> u8 { + data.iter().fold(0u8, |c, &b| T8[usize::from(c ^ b)]) +} + +pub fn crc16(data: &[u8]) -> u16 { + data.iter().fold(0u16, |c, &b| (c << 8) ^ T16[usize::from((c >> 8) as u8 ^ b)]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn check_values() { + // the standard check value of each polynomial over "123456789" + assert_eq!(crc8(b"123456789"), 0xF4); + assert_eq!(crc16(b"123456789"), 0xFEE8); + } +} diff --git a/crates/flac/src/frame.rs b/crates/flac/src/frame.rs new file mode 100644 index 00000000..6ccd8a50 --- /dev/null +++ b/crates/flac/src/frame.rs @@ -0,0 +1,203 @@ +//! Writing one frame (RFC 9639 §9): header with CRC-8, one subframe per channel, zero padding to a +//! byte, CRC-16. + +use filmcraft_bitstream::BitWriter; + +use crate::crc::{crc8, crc16}; +use crate::predict::{Residual, Subframe}; + +/// Inter-channel decorrelation of a stereo frame (§9.1.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Stereo { + Independent, + /// Left, then side (left − right). + LeftSide, + /// Side, then right. + SideRight, + /// Mid ((left + right) >> 1), then side. + MidSide, +} + +/// Frame header block size bits: 4096 has its own code, others are stored after the header. +fn block_size_code(n: u16) -> (u8, Option<(u32, u32)>) { + match n { + 4096 => (0b1100, None), + 1..=256 => (0b0110, Some((u32::from(n - 1), 8))), + _ => (0b0111, Some((u32::from(n.saturating_sub(1)), 16))), + } +} + +/// Frame header sample rate code; 0 refers to STREAMINFO. +fn sample_rate_code(rate: u32) -> u8 { + match rate { + 88_200 => 1, + 176_400 => 2, + 192_000 => 3, + 8_000 => 4, + 16_000 => 5, + 22_050 => 6, + 24_000 => 7, + 32_000 => 8, + 44_100 => 9, + 48_000 => 10, + 96_000 => 11, + _ => 0, + } +} + +/// Frame header sample size code; 0 refers to STREAMINFO. +fn sample_size_code(bps: u8) -> u8 { + match bps { + 8 => 1, + 12 => 2, + 16 => 4, + 20 => 5, + 24 => 6, + _ => 0, + } +} + +/// The frame number in the UTF-8-like coding of §9.1.5 (up to 36 bits). +fn coded_number(v: u64, out: &mut Vec) { + if v < 0x80 { + out.push(v as u8); + return; + } + let tail = match v { + 0..0x800 => 1, + 0x800..0x1_0000 => 2, + 0x1_0000..0x20_0000 => 3, + 0x20_0000..0x400_0000 => 4, + 0x400_0000..0x8000_0000 => 5, + _ => 6, + }; + let lead_mask: u8 = !(0xFFu8 >> (tail + 1)); + out.push(lead_mask | (v >> (6 * tail)) as u8 & (0x7F >> (tail + 1))); + for i in (0..tail).rev() { + out.push(0x80 | ((v >> (6 * i)) & 0x3F) as u8); + } +} + +pub struct FrameSpec<'a> { + pub number: u64, + pub sample_rate: u32, + pub bps: u8, + pub stereo: Option, + /// The signals as coded (mid/side already formed), with their subframes. + pub channels: &'a [(&'a [i32], Subframe, u8)], +} + +fn write_residual(w: &mut BitWriter, residual: &Residual, r: &[i64], block: usize, order: usize) { + let wide = residual.wide(); + w.write_bits(u32::from(wide), 2); + w.write_bits(u32::from(residual.partition_order), 4); + let parts = 1usize << residual.partition_order; + let len = block / parts; + let mut start = 0usize; + for (p, &k) in residual.params.iter().enumerate() { + w.write_bits(u32::from(k), if wide { 5 } else { 4 }); + let n = if p == 0 { len.saturating_sub(order) } else { len }; + for &v in r.get(start..start + n).unwrap_or_default() { + let u = if v >= 0 { (v as u64) << 1 } else { ((-v - 1) as u64) << 1 | 1 }; + let mut q = u >> k; + while q >= 32 { + w.write_bits(0, 32); + q -= 32; + } + w.write_bits(1, q as u32 + 1); + w.write_bits((u & ((1u64 << k) - 1)) as u32, u32::from(k)); + } + start += n; + } +} + +fn write_subframe(w: &mut BitWriter, x: &[i32], sub: &Subframe, bps: u8) { + let b = u32::from(bps); + let sample = |w: &mut BitWriter, v: i32| w.write_bits(v as u32, b); + w.write_bit(false); + match sub { + Subframe::Constant(v) => { + w.write_bits(0, 6); + w.write_bit(false); + sample(w, *v); + } + Subframe::Verbatim => { + w.write_bits(1, 6); + w.write_bit(false); + x.iter().for_each(|&v| sample(w, v)); + } + Subframe::Fixed { order, residual } => { + w.write_bits(0b001000 | u32::from(*order), 6); + w.write_bit(false); + let o = usize::from(*order); + x.iter().take(o).for_each(|&v| sample(w, v)); + let r = crate::predict::fixed_residual(x, o).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + Subframe::Lpc { coefs, precision, shift, residual } => { + let o = coefs.len(); + w.write_bits(0b100000 | (o as u32 - 1), 6); + w.write_bit(false); + x.iter().take(o).for_each(|&v| sample(w, v)); + w.write_bits(u32::from(*precision) - 1, 4); + w.write_bits(u32::from(*shift), 5); + coefs.iter().for_each(|&c| w.write_bits(c as u32, u32::from(*precision))); + let r = crate::predict::lpc_residual(x, coefs, *shift).unwrap_or_default(); + write_residual(w, residual, &r, x.len(), o); + } + } +} + +/// One complete frame. +pub fn write(spec: &FrameSpec) -> Vec { + let block = spec.channels.first().map_or(0, |c| c.0.len()); + let (bs_code, bs_extra) = block_size_code(u16::try_from(block).unwrap_or(u16::MAX)); + let channel_code = match spec.stereo { + Some(Stereo::LeftSide) => 8, + Some(Stereo::SideRight) => 9, + Some(Stereo::MidSide) => 10, + _ => spec.channels.len().saturating_sub(1) as u8, + }; + let mut h = vec![0xFF, 0xF8, bs_code << 4 | sample_rate_code(spec.sample_rate), channel_code << 4 | sample_size_code(spec.bps) << 1]; + coded_number(spec.number, &mut h); + if let Some((v, n)) = bs_extra { + if n == 8 { + h.push(v as u8); + } else { + h.extend_from_slice(&(v as u16).to_be_bytes()); + } + } + h.push(crc8(&h)); + let mut w = BitWriter::new(); + w.write_bytes(&h); + for (x, sub, bps) in spec.channels { + write_subframe(&mut w, x, sub, *bps); + } + let mut out = w.finish(); + let crc = crc16(&out); + out.extend_from_slice(&crc.to_be_bytes()); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn coded_numbers() { + let code = |v| { + let mut o = Vec::new(); + coded_number(v, &mut o); + o + }; + assert_eq!(code(0), [0]); + assert_eq!(code(0x7F), [0x7F]); + assert_eq!(code(0x80), [0xC2, 0x80]); + assert_eq!(code(0x7FF), [0xDF, 0xBF]); + assert_eq!(code(0x800), [0xE0, 0xA0, 0x80]); + assert_eq!(code(0xFFFF), [0xEF, 0xBF, 0xBF]); + assert_eq!(code(0x1_0000), [0xF0, 0x90, 0x80, 0x80]); + assert_eq!(code(0x7FFF_FFFF), [0xFD, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + assert_eq!(code(0xF_FFFF_FFFF), [0xFE, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF, 0xBF]); + } +} diff --git a/crates/flac/src/lib.rs b/crates/flac/src/lib.rs new file mode 100644 index 00000000..5e278365 --- /dev/null +++ b/crates/flac/src/lib.rs @@ -0,0 +1,247 @@ +//! Clean-room FLAC encoder (RFC 9639, Free Lossless Audio Codec). +//! +//! - [`Encoder`]: planar integer samples (8–24 bit, 1–8 channels) → a `.flac` stream: the +//! [`Encoder::header`] (`fLaC`, STREAMINFO, a VORBIS_COMMENT naming the encoder), then frames of +//! a fixed block size. Each channel of each frame is coded as constant, verbatim, a fixed +//! polynomial predictor or linear prediction, whichever is smallest, with partitioned Rice codes; +//! stereo frames also try left/side, side/right and mid/side. +//! - The STREAMINFO sizes and MD5 are only known at the end: write the header first, then overwrite +//! [`STREAMINFO_OFFSET`] with [`Encoder::streaminfo`] once [`Encoder::finish`] has run (or keep +//! the placeholder, whose zeros mean "unknown" and decode fine). +//! +//! Lossless at every compression level; the level (0–8) only trades encoding time for size. +//! +//! Layer L0: depends only on `filmcraft-bitstream` (+ `thiserror`); no `unsafe`; builds for +//! `wasm32-unknown-unknown`. +//! +//! ``` +//! use filmcraft_flac::{Encoder, EncoderConfig, STREAMINFO_OFFSET}; +//! +//! let left: Vec = (0..48_000).map(|i| ((i as f64 * 0.05).sin() * 20_000.0) as i32).collect(); +//! let right = left.clone(); +//! let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16))?; +//! let mut file = enc.header(); +//! file.extend(enc.encode(&[&left, &right])?); +//! file.extend(enc.finish()); +//! let at = STREAMINFO_OFFSET as usize; +//! file[at..at + 34].copy_from_slice(&enc.streaminfo()); +//! assert_eq!(&file[..4], b"fLaC"); +//! assert!(file.len() < 48_000 * 2 * 2 / 2); // well under half of 16-bit PCM +//! # Ok::<(), filmcraft_flac::Error>(()) +//! ``` + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod crc; +mod frame; +mod md5; +mod predict; + +use frame::{FrameSpec, Stereo}; +use predict::{Effort, Subframe}; + +/// Byte offset of the 34-byte STREAMINFO body in [`Encoder::header`]. +pub const STREAMINFO_OFFSET: u64 = 8; + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum Error { + #[error("invalid configuration: {0}")] + InvalidConfig(&'static str), + #[error("expected {expected} channels, got {got}")] + Channels { expected: usize, got: usize }, + #[error("channels of unequal length")] + UnequalChannels, + #[error("sample {0} does not fit the configured sample size")] + SampleRange(i32), +} + +#[derive(Clone, Debug)] +pub struct EncoderConfig { + /// 1 to 1 048 575 Hz. + pub sample_rate: u32, + /// 1–8 (FLAC channel order: L R C LFE Ls Rs … for 5.1). + pub channels: u8, + /// 8–24. + pub bits_per_sample: u8, + /// Samples per channel per frame, 16–65535 (4096 by default). + pub block_size: u16, + /// 0 (fastest) to 8 (smallest); 5 by default. + pub level: u8, +} + +impl EncoderConfig { + pub fn new(sample_rate: u32, channels: u8, bits_per_sample: u8) -> Self { + EncoderConfig { sample_rate, channels, bits_per_sample, block_size: 4096, level: 5 } + } +} + +pub struct Encoder { + cfg: EncoderConfig, + effort: Effort, + /// Samples waiting for a full block, per channel. + pending: Vec>, + frame_number: u64, + total_samples: u64, + min_frame: u32, + max_frame: u32, + md5: md5::Md5, +} + +impl Encoder { + pub fn new(cfg: EncoderConfig) -> Result { + if !(1..=8).contains(&cfg.channels) { + return Err(Error::InvalidConfig("1 to 8 channels")); + } + if !(8..=24).contains(&cfg.bits_per_sample) { + return Err(Error::InvalidConfig("8 to 24 bits per sample")); + } + if !(1..=1_048_575).contains(&cfg.sample_rate) { + return Err(Error::InvalidConfig("sample rate 1 to 1048575 Hz")); + } + if cfg.block_size < 16 { + return Err(Error::InvalidConfig("block size 16 to 65535")); + } + let effort = Effort::level(cfg.level); + Ok(Encoder { + pending: vec![Vec::new(); usize::from(cfg.channels)], + cfg, + effort, + frame_number: 0, + total_samples: 0, + min_frame: 0, + max_frame: 0, + md5: md5::Md5::default(), + }) + } + + /// `fLaC`, the STREAMINFO block (sizes and MD5 unknown yet: see [`Encoder::streaminfo`]) and a + /// VORBIS_COMMENT block with the vendor string. + pub fn header(&self) -> Vec { + let mut v = b"fLaC".to_vec(); + v.extend_from_slice(&[0, 0, 0, 34]); + v.extend_from_slice(&self.streaminfo_with(0, 0, 0, [0; 16])); + let vendor = concat!("filmcraft-flac ", env!("CARGO_PKG_VERSION")); + let len = 4 + vendor.len() + 4; + // last metadata block, type 4 (VORBIS_COMMENT), little-endian lengths inside + v.push(0x84); + v.extend_from_slice(&(len as u32).to_be_bytes()[1..]); + v.extend_from_slice(&(vendor.len() as u32).to_le_bytes()); + v.extend_from_slice(vendor.as_bytes()); + v.extend_from_slice(&0u32.to_le_bytes()); + v + } + + /// The final STREAMINFO body: frame sizes, total samples and MD5 of what has been encoded. + pub fn streaminfo(&self) -> [u8; 34] { + self.streaminfo_with(self.min_frame, self.max_frame, self.total_samples, self.md5.digest()) + } + + fn streaminfo_with(&self, min_frame: u32, max_frame: u32, total: u64, md5: [u8; 16]) -> [u8; 34] { + let mut w = filmcraft_bitstream::BitWriter::new(); + let bs = u32::from(self.cfg.block_size); + w.write_bits(bs, 16); + w.write_bits(bs, 16); + w.write_bits(min_frame.min(0xFF_FFFF), 24); + w.write_bits(max_frame.min(0xFF_FFFF), 24); + w.write_bits(self.cfg.sample_rate, 20); + w.write_bits(u32::from(self.cfg.channels) - 1, 3); + w.write_bits(u32::from(self.cfg.bits_per_sample) - 1, 5); + // 36 bits; 0 means unknown, and a count that does not fit is left unknown + let total = if total >> 36 == 0 { total } else { 0 }; + w.write_bits((total >> 32) as u32, 4); + w.write_bits(total as u32, 32); + w.write_bytes(&md5); + let mut out = [0u8; 34]; + out.copy_from_slice(w.bytes().get(..34).unwrap_or(&[0; 34])); + out + } + + /// Add samples (one slice per channel, equal lengths); returns the frames completed. + pub fn encode(&mut self, planar: &[&[i32]]) -> Result, Error> { + Ok(self.encode_frames(planar)?.concat()) + } + + /// [`Encoder::encode`] with each completed frame on its own (one MP4 / QuickTime sample each). + pub fn encode_frames(&mut self, planar: &[&[i32]]) -> Result>, Error> { + if planar.len() != self.pending.len() { + return Err(Error::Channels { expected: self.pending.len(), got: planar.len() }); + } + let n = planar.first().map_or(0, |c| c.len()); + if planar.iter().any(|c| c.len() != n) { + return Err(Error::UnequalChannels); + } + let max = (1i32 << (self.cfg.bits_per_sample - 1)) - 1; + if let Some(&bad) = planar.iter().flat_map(|c| c.iter()).find(|&&s| s > max || s < -max - 1) { + return Err(Error::SampleRange(bad)); + } + for (p, c) in self.pending.iter_mut().zip(planar) { + p.extend_from_slice(c); + } + let bs = usize::from(self.cfg.block_size); + let mut out = Vec::new(); + while self.pending.first().is_some_and(|p| p.len() >= bs) { + let block: Vec> = self.pending.iter_mut().map(|p| p.drain(..bs).collect()).collect(); + out.push(self.frame(&block)); + } + Ok(out) + } + + /// Samples per channel waiting for a full block: the length of the frame [`Encoder::finish`] + /// would write. + pub fn pending_samples(&self) -> usize { + self.pending.first().map_or(0, Vec::len) + } + + /// Encode what is left as a final, shorter frame. + pub fn finish(&mut self) -> Vec { + if self.pending.first().is_none_or(Vec::is_empty) { + return Vec::new(); + } + let block: Vec> = self.pending.iter_mut().map(std::mem::take).collect(); + self.frame(&block) + } + + fn frame(&mut self, block: &[Vec]) -> Vec { + let n = block.first().map_or(0, Vec::len); + let bps = self.cfg.bits_per_sample; + // the MD5 of the unencoded audio: interleaved, little-endian, whole bytes per sample + let bytes = usize::from(bps.div_ceil(8)); + let mut raw = Vec::with_capacity(n * block.len() * bytes); + for i in 0..n { + for c in block { + raw.extend_from_slice(c.get(i).copied().unwrap_or(0).to_le_bytes().get(..bytes).unwrap_or_default()); + } + } + self.md5.update(&raw); + + let effort = self.effort; + let code = |x: &[i32], b: u8| predict::choose(x, b, effort); + let data = match block { + [l, r] if effort.stereo => { + let side: Vec = l.iter().zip(r).map(|(&a, &b)| a - b).collect(); + let mid: Vec = l.iter().zip(r).map(|(&a, &b)| (a + b) >> 1).collect(); + let (cl, cr, cs, cm) = (code(l, bps), code(r, bps), code(&side, bps + 1), code(&mid, bps)); + let options = + [(Stereo::Independent, cl.1 + cr.1), (Stereo::LeftSide, cl.1 + cs.1), (Stereo::SideRight, cs.1 + cr.1), (Stereo::MidSide, cm.1 + cs.1)]; + let best = options.iter().min_by_key(|o| o.1).map_or(Stereo::Independent, |o| o.0); + let channels: Vec<(&[i32], Subframe, u8)> = match best { + Stereo::Independent => vec![(l, cl.0, bps), (r, cr.0, bps)], + Stereo::LeftSide => vec![(l, cl.0, bps), (&side, cs.0, bps + 1)], + Stereo::SideRight => vec![(&side, cs.0, bps + 1), (r, cr.0, bps)], + Stereo::MidSide => vec![(&mid, cm.0, bps), (&side, cs.0, bps + 1)], + }; + frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: Some(best), channels: &channels }) + } + _ => { + let channels: Vec<(&[i32], Subframe, u8)> = block.iter().map(|c| (c.as_slice(), code(c, bps).0, bps)).collect(); + frame::write(&FrameSpec { number: self.frame_number, sample_rate: self.cfg.sample_rate, bps, stereo: None, channels: &channels }) + } + }; + self.frame_number += 1; + self.total_samples += n as u64; + let size = u32::try_from(data.len()).unwrap_or(u32::MAX); + self.min_frame = if self.min_frame == 0 { size } else { self.min_frame.min(size) }; + self.max_frame = self.max_frame.max(size); + data + } +} diff --git a/crates/flac/src/md5.rs b/crates/flac/src/md5.rs new file mode 100644 index 00000000..ca99c9e3 --- /dev/null +++ b/crates/flac/src/md5.rs @@ -0,0 +1,118 @@ +//! MD5 (RFC 1321), for the STREAMINFO signature of the unencoded audio (RFC 9639 §8.2): every +//! sample, interleaved, little-endian, in the fewest whole bytes that hold the sample size. + +const S: [u32; 64] = [ + 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, + 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21, +]; + +/// `floor(abs(sin(i + 1)) × 2³²)`, RFC 1321 §3.4. +const K: [u32; 64] = [ + 0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee, 0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501, 0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be, 0x6b901122, + 0xfd987193, 0xa679438e, 0x49b40821, 0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa, 0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8, 0x21e1cde6, 0xc33707d6, + 0xf4d50d87, 0x455a14ed, 0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a, 0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c, 0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, + 0xbebfbc70, 0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05, 0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665, 0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039, + 0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1, 0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1, 0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391, +]; + +#[derive(Clone)] +pub struct Md5 { + state: [u32; 4], + block: [u8; 64], + filled: usize, + len: u64, +} + +impl Default for Md5 { + fn default() -> Self { + Md5 { state: [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476], block: [0; 64], filled: 0, len: 0 } + } +} + +impl Md5 { + pub fn update(&mut self, mut data: &[u8]) { + self.len = self.len.wrapping_add(data.len() as u64); + while !data.is_empty() { + let take = (64 - self.filled).min(data.len()); + let (head, rest) = data.split_at(take); + if let Some(dst) = self.block.get_mut(self.filled..self.filled + take) { + dst.copy_from_slice(head); + } + self.filled += take; + data = rest; + if self.filled == 64 { + let block = self.block; + self.compress(&block); + self.filled = 0; + } + } + } + + fn compress(&mut self, block: &[u8; 64]) { + let mut m = [0u32; 16]; + for (w, c) in m.iter_mut().zip(block.as_chunks::<4>().0) { + *w = u32::from_le_bytes(*c); + } + let [mut a, mut b, mut c, mut d] = self.state; + for i in 0..64 { + let (f, g) = match i / 16 { + 0 => ((b & c) | (!b & d), i), + 1 => ((d & b) | (!d & c), (5 * i + 1) % 16), + 2 => (b ^ c ^ d, (3 * i + 5) % 16), + _ => (c ^ (b | !d), (7 * i) % 16), + }; + let f = f.wrapping_add(a).wrapping_add(K[i]).wrapping_add(m[g]); + a = d; + d = c; + c = b; + b = b.wrapping_add(f.rotate_left(S[i])); + } + for (s, v) in self.state.iter_mut().zip([a, b, c, d]) { + *s = s.wrapping_add(v); + } + } + + /// The digest so far (the state is not consumed: more data may follow). + pub fn digest(&self) -> [u8; 16] { + let mut h = self.clone(); + let bits = h.len.wrapping_mul(8); + h.update(&[0x80]); + while h.filled != 56 { + h.update(&[0]); + } + h.update(&bits.to_le_bytes()); + let mut out = [0u8; 16]; + for (o, s) in out.as_chunks_mut::<4>().0.iter_mut().zip(h.state) { + o.copy_from_slice(&s.to_le_bytes()); + } + out + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex(d: [u8; 16]) -> String { + d.iter().map(|b| format!("{b:02x}")).collect() + } + + #[test] + fn rfc_1321_test_suite() { + let md5 = |s: &[u8]| { + let mut m = Md5::default(); + m.update(s); + hex(m.digest()) + }; + assert_eq!(md5(b""), "d41d8cd98f00b204e9800998ecf8427e"); + assert_eq!(md5(b"abc"), "900150983cd24fb0d6963f7d28e17f72"); + assert_eq!(md5(b"message digest"), "f96b697d7cb7938d525a2f31aaf161d0"); + assert_eq!(md5(b"12345678901234567890123456789012345678901234567890123456789012345678901234567890"), "57edf4a22be3c955ac49da2e2107b67a"); + // fed in pieces across block boundaries + let mut m = Md5::default(); + for c in b"12345678901234567890123456789012345678901234567890123456789012345678901234567890".chunks(7) { + m.update(c); + } + assert_eq!(hex(m.digest()), "57edf4a22be3c955ac49da2e2107b67a"); + } +} diff --git a/crates/flac/src/predict.rs b/crates/flac/src/predict.rs new file mode 100644 index 00000000..f5c681de --- /dev/null +++ b/crates/flac/src/predict.rs @@ -0,0 +1,327 @@ +//! Subframe prediction (RFC 9639 §9.2.5–9.2.6) and residual coding (§9.2.7): the fixed polynomial +//! predictors, linear prediction from the windowed autocorrelation (Levinson–Durbin), and +//! partitioned Rice codes. Everything here only measures and chooses; [`crate::frame`] writes. + +/// Residuals larger than this are not Rice coded (a 5-bit parameter tops out at 30). +const MAX_RESIDUAL: i64 = (1 << 30) - 1; + +/// A residual coded with partitioned Rice codes. +#[derive(Clone, Debug, PartialEq)] +pub struct Residual { + /// Rice parameter per partition; `1 << order` partitions. + pub params: Vec, + pub partition_order: u8, + /// Total bits of the residual section, coding method included. + pub bits: u64, +} + +impl Residual { + /// The 5-bit parameter method is needed when any parameter passes 14 (15 is the escape code). + pub fn wide(&self) -> bool { + self.params.iter().any(|&k| k > 14) + } +} + +/// How one channel of one block is coded. +#[derive(Clone, Debug, PartialEq)] +pub enum Subframe { + Constant(i32), + Verbatim, + Fixed { order: u8, residual: Residual }, + Lpc { coefs: Vec, precision: u8, shift: u8, residual: Residual }, +} + +/// Folded residual: 0, -1, 1, -2, 2 … → 0, 1, 2, 3, 4 …. +fn fold(r: i64) -> u64 { + if r >= 0 { (r as u64) << 1 } else { ((-r - 1) as u64) << 1 | 1 } +} + +/// Bits of `n` folded values summing to `sum` with Rice parameter `k`: unary quotient, stop bit, +/// `k` low bits. The quotient total is approximated by `sum >> k`, exact up to the per-value +/// carries; [`rice_bits_exact`] gives the true cost for the chosen parameter. +fn rice_estimate(sum: u64, n: u64, k: u32) -> u64 { + (sum >> k) + n * (u64::from(k) + 1) +} + +fn rice_bits_exact(u: &[u64], k: u32) -> u64 { + u.iter().map(|&v| (v >> k) + u64::from(k) + 1).sum() +} + +/// The best parameter for a partition of folded values. +fn best_param(u: &[u64]) -> (u8, u64) { + let n = u.len() as u64; + let sum: u64 = u.iter().sum(); + if n == 0 { + return (0, 0); + } + let mean = sum / n; + let guess = if mean == 0 { 0 } else { 63 - mean.leading_zeros() }; + let mut best = (0u32, u64::MAX); + for k in guess.saturating_sub(1)..=(guess + 1).min(30) { + let b = rice_estimate(sum, n, k); + if b < best.1 { + best = (k, b); + } + } + (best.0 as u8, rice_bits_exact(u, best.0)) +} + +/// Choose the partition order and parameters for the residual of a block of `block` samples whose +/// first `order` samples are warm-up (`u` holds the folded residual after them). +fn code_residual(u: &[u64], block: usize, order: usize, max_partition_order: u8) -> Residual { + let mut best: Option = None; + for po in 0..=max_partition_order { + let parts = 1usize << po; + if !block.is_multiple_of(parts) { + break; + } + let len = block / parts; + if len <= order { + break; + } + let mut params = Vec::with_capacity(parts); + let mut bits = 0u64; + let mut start = 0usize; + for p in 0..parts { + let n = if p == 0 { len - order } else { len }; + let Some(chunk) = u.get(start..start + n) else { break }; + let (k, b) = best_param(chunk); + params.push(k); + bits += b; + start += n; + } + let wide = params.iter().any(|&k| k > 14); + bits += 2 + 4 + parts as u64 * if wide { 5 } else { 4 }; + if best.as_ref().is_none_or(|b| bits < b.bits) { + best = Some(Residual { params, partition_order: po, bits }); + } + } + best.unwrap_or(Residual { params: vec![0], partition_order: 0, bits: u64::MAX }) +} + +/// The residual of fixed predictor `order` (0–4), or None when it does not fit. +pub fn fixed_residual(x: &[i32], order: usize) -> Option> { + let x: Vec = x.iter().map(|&v| i64::from(v)).collect(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let s = |j: usize| x.get(i - j).copied().unwrap_or(0); + let r = match order { + 0 => s(0), + 1 => s(0) - s(1), + 2 => s(0) - 2 * s(1) + s(2), + 3 => s(0) - 3 * s(1) + 3 * s(2) - s(3), + _ => s(0) - 4 * s(1) + 6 * s(2) - 4 * s(3) + s(4), + }; + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// The residual of quantised linear prediction, or None when it does not fit. +pub fn lpc_residual(x: &[i32], coefs: &[i32], shift: u8) -> Option> { + let order = coefs.len(); + let mut out = Vec::with_capacity(x.len().saturating_sub(order)); + for i in order..x.len() { + let hist = x.get(i - order..i)?; + // coefs[0] weighs the previous sample + let pred: i64 = coefs.iter().zip(hist.iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + let r = i64::from(*x.get(i)?) - (pred >> shift); + if r.abs() > MAX_RESIDUAL { + return None; + } + out.push(r); + } + Some(out) +} + +/// Tukey(0.5) window: flat in the middle half, raised-cosine tapers. +fn window(n: usize) -> Vec { + let taper = (n / 4).max(1); + (0..n) + .map(|i| { + let edge = i.min(n - 1 - i); + if edge >= taper { 1.0 } else { 0.5 * (1.0 - (std::f64::consts::PI * edge as f64 / taper as f64).cos()) } + }) + .collect() +} + +/// Linear prediction coefficients of every order 1..=`max` (Levinson–Durbin on the windowed +/// autocorrelation); empty for a silent or degenerate block. +fn lpc_orders(x: &[i32], max: usize) -> Vec> { + let n = x.len(); + if n <= max { + return Vec::new(); + } + let w = window(n); + let s: Vec = x.iter().zip(&w).map(|(&v, &w)| f64::from(v) * w).collect(); + let r: Vec = (0..=max).map(|lag| s.iter().zip(s.iter().skip(lag)).map(|(a, b)| a * b).sum()).collect(); + let Some(&r0) = r.first() else { return Vec::new() }; + if r0 <= 0.0 { + return Vec::new(); + } + let mut err = r0 * (1.0 + 1e-9); + let mut a: Vec = Vec::new(); + let mut all = Vec::with_capacity(max); + for i in 0..max { + let ri = r.get(i + 1).copied().unwrap_or(0.0); + let acc: f64 = a.iter().enumerate().map(|(j, &aj)| aj * r.get(i - j).copied().unwrap_or(0.0)).sum(); + let k = (ri - acc) / err; + if !k.is_finite() { + break; + } + let prev = a.clone(); + a.push(k); + for j in 0..i { + if let (Some(aj), Some(&p)) = (a.get_mut(j), prev.get(i - 1 - j)) { + *aj -= k * p; + } + } + err *= 1.0 - k * k; + all.push(a.clone()); + if err <= 0.0 { + break; + } + } + all +} + +/// Quantise coefficients to `precision`-bit integers with a right shift (0–15), carrying the +/// rounding error forward so the quantised filter stays close to the real one. +fn quantise(coefs: &[f64], precision: u8) -> Option<(Vec, u8)> { + let cmax = coefs.iter().fold(0.0f64, |m, c| m.max(c.abs())); + if !(cmax.is_finite() && cmax > 0.0) { + return None; + } + let lim = (1i64 << (precision - 1)) - 1; + // the largest shift that keeps the biggest coefficient inside the precision + let log = cmax.log2().floor() as i32 + 1; + let shift = (i32::from(precision) - 1 - log).clamp(0, 15); + let scale = f64::from(1u32 << shift); + let mut carry = 0.0; + let mut q = Vec::with_capacity(coefs.len()); + for &c in coefs { + carry += c * scale; + let v = (carry.round() as i64).clamp(-lim - 1, lim); + carry -= v as f64; + q.push(v as i32); + } + Some((q, shift as u8)) +} + +/// Encoding effort, from the compression level. +#[derive(Clone, Copy, Debug)] +pub struct Effort { + pub max_lpc_order: usize, + pub max_partition_order: u8, + pub stereo: bool, +} + +impl Effort { + pub fn level(level: u8) -> Effort { + let (max_lpc_order, max_partition_order, stereo) = match level { + 0 => (0, 3, false), + 1 | 2 => (0, 3, true), + 3 => (6, 4, false), + 4 => (8, 4, true), + 5 => (8, 5, true), + 6 => (8, 6, true), + _ => (12, 6, true), + }; + Effort { max_lpc_order, max_partition_order, stereo } + } +} + +/// The cheapest coding of one channel of `bps`-bit samples, and its size in bits. +pub fn choose(x: &[i32], bps: u8, effort: Effort) -> (Subframe, u64) { + let n = x.len(); + let b = u64::from(bps); + let head = 8; // zero bit, type, wasted-bits flag + let first = x.first().copied().unwrap_or(0); + if x.iter().all(|&v| v == first) { + return (Subframe::Constant(first), head + b); + } + let mut best = (Subframe::Verbatim, head + n as u64 * b); + let mut consider = |sub: Subframe, bits: u64| { + if bits < best.1 { + best = (sub, bits); + } + }; + let folded = |r: &[i64]| r.iter().map(|&v| fold(v)).collect::>(); + for order in 0..=4usize.min(n.saturating_sub(1)) { + let Some(r) = fixed_residual(x, order) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * b + residual.bits; + consider(Subframe::Fixed { order: order as u8, residual }, bits); + } + // 15-bit coefficients, fewer for short blocks (their side information weighs more) + let precision: u8 = if n <= 1152 { 12 } else { 15 }; + for coefs in lpc_orders(x, effort.max_lpc_order) { + let Some((q, shift)) = quantise(&coefs, precision) else { continue }; + let order = q.len(); + let Some(r) = lpc_residual(x, &q, shift) else { continue }; + let residual = code_residual(&folded(&r), n, order, effort.max_partition_order); + let bits = head + order as u64 * (b + u64::from(precision)) + 4 + 5 + residual.bits; + consider(Subframe::Lpc { coefs: q, precision, shift, residual }, bits); + } + best +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folding() { + assert_eq!([0, -1, 1, -2, 2].map(fold), [0, 1, 2, 3, 4]); + } + + #[test] + fn predictable_signals_beat_verbatim() { + // a ramp is exactly predicted by the order-2 fixed predictor + let ramp: Vec = (0..4096).map(|i| i * 3 - 6000).collect(); + let (sub, bits) = choose(&ramp, 16, Effort::level(5)); + assert!(matches!(sub, Subframe::Fixed { order: 2, .. } | Subframe::Lpc { .. }), "{sub:?}"); + assert!(bits < 4096 * 2, "{bits}"); + // a sine is well predicted by LPC + let sine: Vec = (0..4096).map(|i| ((i as f64 * 0.05).sin() * 20000.0) as i32).collect(); + let (_, bits) = choose(&sine, 16, Effort::level(5)); + assert!(bits < 4096 * 6, "{bits}"); + assert_eq!(choose(&[7; 100], 16, Effort::level(5)), (Subframe::Constant(7), 24)); + } + + #[test] + fn lpc_residual_inverts() { + let x: Vec = (0..500).map(|i| ((i as f64 * 0.1).sin() * 1000.0) as i32).collect(); + let coefs = lpc_orders(&x, 4).pop().unwrap(); + let (q, shift) = quantise(&coefs, 15).unwrap(); + let r = lpc_residual(&x, &q, shift).unwrap(); + // rebuild the signal from the warm-up and the residual, as a decoder would + let mut y: Vec = x[..q.len()].to_vec(); + for (i, &res) in r.iter().enumerate() { + let p: i64 = q.iter().zip(y[i..i + q.len()].iter().rev()).map(|(&c, &s)| i64::from(c) * i64::from(s)).sum(); + y.push((res + (p >> shift)) as i32); + } + assert_eq!(y, x); + } + + #[test] + fn noise_and_extremes_do_not_panic() { + let mut seed = 1u32; + let noise: Vec = (0..4096) + .map(|_| { + seed = seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + (seed >> 8) as i32 - (1 << 23) + }) + .collect(); + let (_, bits) = choose(&noise, 24, Effort::level(8)); + assert!(bits <= 8 + 4096 * 24); + let square: Vec = (0..4096).map(|i| if i % 2 == 0 { (1 << 23) - 1 } else { -(1 << 23) }).collect(); + choose(&square, 24, Effort::level(8)); + choose(&[], 16, Effort::level(5)); + choose(&[1], 16, Effort::level(5)); + choose(&[1, 2], 16, Effort::level(8)); + } +} diff --git a/crates/flac/tests/ffmpeg_oracle.rs b/crates/flac/tests/ffmpeg_oracle.rs new file mode 100644 index 00000000..80a0c753 --- /dev/null +++ b/crates/flac/tests/ffmpeg_oracle.rs @@ -0,0 +1,181 @@ +//! ffmpeg as an external decoder oracle (skipped when absent): every stream must decode without an +//! error to exactly the samples that went in, with the STREAMINFO MD5 matching ffmpeg's hash of the +//! decoded audio. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use filmcraft_flac::{Encoder, EncoderConfig, Error, STREAMINFO_OFFSET}; + +fn ffmpeg() -> Option { + filmcraft_testkit::ffmpeg_or_skip("flac oracle") +} + +struct Rng(u64); +impl Rng { + fn next(&mut self) -> f64 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + (self.0 >> 11) as f64 / (1u64 << 53) as f64 * 2.0 - 1.0 + } +} + +/// Test signals at `bps`, full scale: music-like (two sines and noise), a sweep, white noise, +/// a full-scale square, silence and a lone click. +fn signal(kind: &str, n: usize, bps: u8, seed: u64) -> Vec { + let max = f64::from((1i32 << (bps - 1)) - 1); + let mut r = Rng(seed | 1); + (0..n) + .map(|i| { + let t = i as f64 / 48_000.0; + let v = match kind { + "music" => 0.5 * (2.0 * std::f64::consts::PI * 220.0 * t).sin() + 0.3 * (2.0 * std::f64::consts::PI * 1375.0 * t).sin() + 0.05 * r.next(), + "sweep" => (2.0 * std::f64::consts::PI * (40.0 + 4000.0 * t) * t).sin() * 0.9, + "noise" => r.next(), + "square" => { + if (i / 37) % 2 == 0 { + 1.0 + } else { + -1.0 + } + } + "click" => f64::from(u8::from(i == n / 2)), + _ => 0.0, + }; + ((v * max).round() as i32).clamp(-(max as i32) - 1, max as i32) + }) + .collect() +} + +fn encode(planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Result, Error> { + let mut enc = Encoder::new(cfg)?; + let mut file = enc.header(); + let n = planar.first().map_or(0, Vec::len); + let mut at = 0; + while at < n { + let end = (at + chunk).min(n); + let parts: Vec<&[i32]> = planar.iter().map(|c| &c[at..end]).collect(); + file.extend(enc.encode(&parts)?); + at = end; + } + file.extend(enc.finish()); + let o = STREAMINFO_OFFSET as usize; + file[o..o + 34].copy_from_slice(&enc.streaminfo()); + Ok(file) +} + +fn run(ffmpeg: &Path, args: &[&str], input: &Path) -> (Vec, String) { + let out = Command::new(ffmpeg).args(["-v", "error", "-i"]).arg(input).args(args).output().unwrap(); + assert!(out.status.success(), "ffmpeg failed: {}", String::from_utf8_lossy(&out.stderr)); + (out.stdout, String::from_utf8_lossy(&out.stderr).into_owned()) +} + +/// Encode, decode with ffmpeg, compare every sample and the MD5; returns the file size. +fn round_trip(name: &str, planar: &[Vec], cfg: EncoderConfig, chunk: usize) -> Option { + let ffmpeg = ffmpeg()?; + let bps = cfg.bits_per_sample; + let rate = cfg.sample_rate; + let file = encode(planar, cfg, chunk).unwrap(); + let path = filmcraft_testkit::fixtures_dir("flac").join(format!("{name}.flac")); + std::fs::write(&path, &file).unwrap(); + + // samples: s32 holds each one shifted to the top bits + let (raw, err) = run(&ffmpeg, &["-f", "s32le", "-c:a", "pcm_s32le", "-"], &path); + assert!(err.is_empty(), "{name}: decoder errors: {err}"); + let decoded: Vec = raw.as_chunks::<4>().0.iter().map(|b| i32::from_le_bytes(*b) >> (32 - bps)).collect(); + let n = planar[0].len(); + assert_eq!(decoded.len(), n * planar.len(), "{name}: sample count"); + for (i, frame) in decoded.chunks_exact(planar.len()).enumerate() { + for (c, &v) in frame.iter().enumerate() { + assert_eq!(v, planar[c][i], "{name}: channel {c} sample {i}"); + } + } + + // STREAMINFO: total samples, rate, and the MD5 against ffmpeg's hash of the decoded audio. FLAC + // hashes samples unscaled; ffmpeg rescales other sizes (20-bit becomes 24-bit), so only 8, 16 + // and 24 bit compare + let si = &file[STREAMINFO_OFFSET as usize..STREAMINFO_OFFSET as usize + 34]; + let pcm = match bps { + 8 => Some("s8"), + 16 => Some("s16le"), + 24 => Some("s24le"), + _ => None, + }; + if let Some(pcm) = pcm { + let (hash, _) = run(&ffmpeg, &["-c:a", &format!("pcm_{pcm}"), "-f", "hash", "-hash", "md5", "-"], &path); + let hash = String::from_utf8_lossy(&hash); + let ours: String = si[18..34].iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(hash.trim().strip_prefix("MD5=").unwrap_or(""), ours, "{name}: MD5"); + } + let total = (u64::from(si[13] & 0x0F) << 32) | u64::from(u32::from_be_bytes([si[14], si[15], si[16], si[17]])); + assert_eq!(total, n as u64, "{name}: total samples"); + let si_rate = (u32::from(si[10]) << 12) | (u32::from(si[11]) << 4) | (u32::from(si[12]) >> 4); + assert_eq!(si_rate, rate); + Some(file.len()) +} + +#[test] +fn every_signal_round_trips_exactly() { + for (kind, seed) in [("music", 1), ("sweep", 2), ("noise", 3), ("square", 4), ("silence", 5), ("click", 6)] { + for bps in [16u8, 24] { + let planar = vec![signal(kind, 20_000, bps, seed), signal(kind, 20_000, bps, seed + 100)]; + if round_trip(&format!("{kind}-{bps}"), &planar, EncoderConfig::new(48_000, 2, bps), 4800).is_none() { + return; + } + } + } +} + +#[test] +fn channel_layouts_rates_and_sizes() { + let cases: [(&str, u8, u8, u32, usize); 6] = [ + ("mono-8bit", 1, 8, 8_000, 3_001), + ("mono-24-44k1", 1, 24, 44_100, 9_999), + ("stereo-20bit", 2, 20, 96_000, 10_000), + ("5.1-24", 6, 24, 48_000, 12_345), + ("odd-rate", 2, 16, 37_800, 5_000), + ("tiny", 2, 16, 48_000, 1), + ]; + for (name, ch, bps, rate, n) in cases { + let planar: Vec> = (0..ch).map(|c| signal(if c == 3 { "silence" } else { "music" }, n, bps, u64::from(c) + 7)).collect(); + if round_trip(name, &planar, EncoderConfig::new(rate, ch, bps), 1000).is_none() { + return; + } + } +} + +#[test] +fn levels_and_block_sizes_trade_time_for_size() { + let planar = vec![signal("music", 48_000, 16, 11), signal("sweep", 48_000, 16, 12)]; + let mut sizes = Vec::new(); + for level in 0..=8 { + let cfg = EncoderConfig { level, ..EncoderConfig::new(48_000, 2, 16) }; + let Some(size) = round_trip(&format!("level{level}"), &planar, cfg, 48_000) else { return }; + sizes.push(size); + } + // the effortful levels are never larger than the fastest, and all beat PCM + assert!(sizes[8] <= sizes[0] && sizes[5] <= sizes[0], "{sizes:?}"); + assert!(sizes.iter().all(|&s| s < 48_000 * 4), "{sizes:?}"); + for bs in [16u16, 192, 1152, 4608, 65_535] { + let cfg = EncoderConfig { block_size: bs, ..EncoderConfig::new(48_000, 2, 16) }; + round_trip(&format!("block{bs}"), &planar, cfg, 777); + } +} + +#[test] +fn bad_input_is_an_error_not_a_panic() { + assert!(Encoder::new(EncoderConfig::new(48_000, 0, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 9, 16)).is_err()); + assert!(Encoder::new(EncoderConfig::new(48_000, 2, 32)).is_err()); + assert!(Encoder::new(EncoderConfig::new(0, 2, 16)).is_err()); + assert!(Encoder::new(EncoderConfig { block_size: 8, ..EncoderConfig::new(48_000, 2, 16) }).is_err()); + let mut enc = Encoder::new(EncoderConfig::new(48_000, 2, 16)).unwrap(); + assert_eq!(enc.encode(&[&[0, 1]]), Err(Error::Channels { expected: 2, got: 1 })); + assert_eq!(enc.encode(&[&[0, 1], &[0]]), Err(Error::UnequalChannels)); + assert_eq!(enc.encode(&[&[40_000], &[0]]), Err(Error::SampleRange(40_000))); + assert_eq!(enc.encode(&[&[-32_769], &[0]]), Err(Error::SampleRange(-32_769))); + // nothing encoded: no frames, and a header that still says "unknown" + assert!(enc.finish().is_empty()); + assert_eq!(&enc.header()[..4], b"fLaC"); +} diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index ee926cb5..f5c39b5f 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -1310,6 +1310,16 @@ impl SampleEntry { pub fn aac(asc: Vec, channels: u32, sample_rate: u32) -> Self { Self::audio(FourCc(*b"mp4a"), CodecConfig::Aac(AacConfig::from_asc(asc)), channels, sample_rate as f64, 16) } + /// FLAC entry (`fLaC` + `dfLa`) from the 34-byte STREAMINFO body: the only metadata block. + pub fn flac(streaminfo: &[u8; 34]) -> Self { + let mut blocks = vec![0x80, 0, 0, 34]; + blocks.extend_from_slice(streaminfo); + let mut full = vec![0; 4]; + full.extend_from_slice(&blocks); + let cfg = FlacConfig::parse(&full).unwrap_or(FlacConfig { metadata_blocks: blocks, ..Default::default() }); + let (channels, rate, bits) = (u32::from(cfg.channels), f64::from(cfg.sample_rate), u16::from(cfg.bits_per_sample)); + Self::audio(FourCc(*b"fLaC"), CodecConfig::Flac(cfg), channels, rate, bits) + } /// PCM entry. The muxer picks the on-disk format (`sowt`/`twos`/`lpcm` for MOV, `ipcm`/`fpcm` for MP4). pub fn pcm(cfg: PcmConfig) -> Self { Self::audio(FourCc(*b"lpcm"), CodecConfig::Pcm(cfg), cfg.channels, cfg.sample_rate, cfg.bits) diff --git a/crates/isobmff/src/mux.rs b/crates/isobmff/src/mux.rs index 0ea81477..880d49a2 100644 --- a/crates/isobmff/src/mux.rs +++ b/crates/isobmff/src/mux.rs @@ -541,6 +541,14 @@ impl Mp4Writer { Ok(self.tracks.len() - 1) } + /// Replace a track's sample entry before the file is finished (the `moov` is written last), for + /// codecs whose configuration is only complete at the end, like the FLAC STREAMINFO in `dfLa`. + pub fn set_sample_entry(&mut self, track: usize, entry: SampleEntry) -> Result<()> { + let t = self.tracks.get_mut(track).ok_or_else(|| Error::Invalid(format!("no track {track}")))?; + t.cfg.entry = entry; + Ok(()) + } + /// Add a QuickTime timecode track referenced by `video_track`. Its single sample (the start /// frame number) is written at finish, spanning the video track's duration. pub fn add_timecode_track(&mut self, video_track: usize, tc: TimecodeConfig, start_frame: u32) -> Result { diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index aa156831..21a88bbd 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2962,6 +2962,12 @@ AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits + FLAC FLAC + FLAC 48 kHz 24-bit FLAC 48 kHz 24 bits + FLAC, stereo 48 kHz 24-bit, lossless FLAC, estéreo, 48 kHz y 24 bits, sin pérdida + FLAC (lossless) FLAC (sin pérdida) + Compression Level Nivel de compresión + 0 fastest, 8 smallest 0 más rápido, 8 más pequeño APV APV APV 422-10 APV 422-10 APV 422-12 APV 422-12 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 14a5c02d..ce9bd1cb 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2956,6 +2956,12 @@ AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM + FLAC FLAC + FLAC 48 kHz 24-bit FLAC 48 kHz 24-bit + FLAC, stereo 48 kHz 24-bit, lossless FLAC、ステレオ 48 kHz 24-bit、ロスレス + FLAC (lossless) FLAC (ロスレス) + Compression Level 圧縮レベル + 0 fastest, 8 smallest 0 が最速、8 が最小 APV APV APV 422-10 APV 422-10 APV 422-12 APV 422-12 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 4838ce5f..c961a94b 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -750,17 +750,22 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } row(ui, t, tl!("Audio Format"), |ui| { - let fixed = s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf(); + let fixed = audio_only || s.format.is_mxf(); let cur = match s.audio_codec() { AudioCodec::Aac => "AAC", + AudioCodec::Flac => tl!("FLAC (lossless)"), _ => tl!("Uncompressed (PCM)"), }; - let labels = - vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))]; + // MP4 takes AAC or FLAC; QuickTime any of the three + let labels = vec![ + ("AAC".to_string(), !audio_only), + (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4)), + (tl!("FLAC (lossless)").to_string(), true), + ]; if fixed { ui.label(cur); } else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) { - s.audio.codec = if i == 0 { AudioCodec::Aac } else { AudioCodec::Pcm }; + s.audio.codec = [AudioCodec::Aac, AudioCodec::Pcm, AudioCodec::Flac][i.min(2)]; } }); row(ui, t, tl!("Sample Rate"), |ui| { @@ -799,6 +804,16 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } + if s.audio_codec() == AudioCodec::Flac { + // every level is lossless: higher levels take longer for smaller files + row(ui, t, tl!("Compression Level"), |ui| { + let mut level = f64::from(s.audio.flac_level.min(8)); + if drag(ui, reg, "export.audio.flacLevel", &mut level, 0.0..=8.0, 0.05, "", 0) { + s.audio.flac_level = level.round() as u8; + } + ui.label(egui::RichText::new(tl!("0 fastest, 8 smallest")).size(12.0)); + }); + } } fn placement_combo(ui: &mut egui::Ui, reg: &mut Reg, id: &str, p: &mut Placement) { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index fb541946..8ae75434 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -47,6 +47,7 @@ const LAYERS: &[(&str, u8)] = &[ ("apv", 0), ("av1", 0), ("aac", 0), + ("flac", 0), ("opus", 0), ("frame", 1), ("media", 1), From a72d3fe1b97494ec65fe7f8a4a9c80f77b38c4ac Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 16:26:07 +0530 Subject: [PATCH 005/203] Media: Automatically refresh growing files works (and catches re-renders) Settings > Media > Automatically refresh growing files and Refresh growing files every were listed but not wired. The desktop app now stamps each file media item's file (size + modification time) on a worker thread every N seconds and as soon as its window comes back to the front, and reads changed files again the way import opens them: the item gets the new MediaInfo (length, frame size) and identity, and the media pool swaps in a new source (the old one indexes the file as it was). Not an undo step and a clean project stays clean (bump_view). This also covers a render written over the file a clip uses (an EffectCraft or After Effects round trip). A file that can't be opened yet is tried again. media.refreshChanged runs one look (CLI, MCP). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/lib.rs | 10 ++ crates/engine/src/media_watch.rs | 169 +++++++++++++++++++++++++ crates/engine/src/media_watch_tests.rs | 100 +++++++++++++++ crates/engine/src/relink.rs | 10 ++ crates/engine/src/settings.rs | 4 +- crates/ui-egui/src/lib.rs | 37 ++++++ 7 files changed, 329 insertions(+), 2 deletions(-) create mode 100644 crates/engine/src/media_watch.rs create mode 100644 crates/engine/src/media_watch_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index bd80f88b..f3865ea0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -159,6 +159,7 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ## Log +- **2026-10-10 (changed media):** Settings ▸ Media ▸ Automatically refresh growing files works: media files that change on disk while the project is open (a recording still being written, a render written over the file a clip uses) are read again every *Refresh growing files every* seconds and as soon as the window comes back to the front: the new length, picture and identity, without an undo step (`media.refreshChanged`, `media_watch`). - **2026-10-08 (Source range dragging):** implicit full-clip In/Out, video-only/audio-only/linked both controls, picture/waveform drag gestures and one-command undoable timeline placement. Shared Source/Program draggable frame-bounded handles and range translation, cancellation, Source marker/navigation routing and marked-range playback/looping. - **2026-10-08 (Source playback):** normal forward playback for video/audio media and subclips in the Source monitor, an independent Source clock, Play/Pause and focused Space, frame stepping, device-clock audio with wall-clock fallback, seeking, and end-of-clip stopping. Windows desktop review verified a real video file and standalone WAV, including In/Out marking. Reverse/shuttle Source playback and source sequences remain unsupported; no parity percentage changed. - **2026-10-08 (night):** HDR H.265 (HEVC Main 10, PQ and HLG, BT.2020, limited range) export on Windows through NVENC (#30): an HDR sequence now exports as real 10-bit HDR (`rgbf_to_yuv420_10` into P010 input buffers; VUI BT.2020 + PQ / HLG; the HDR10 mastering-display and content-light messages as SEI on every IDR for PQ, through the generic SEI payload array because NVENC 12.1 has no mastering-display field; `colr` / `mdcv` / `clli` in the sample entry), where the GPU reports 10-bit HEVC (new `register_hdr_probe` / `hdr_available` in the export crate; elsewhere, and on macOS, HDR sequences still tone-map to 8-bit SDR HEVC, and `settings.sdr` forces that). ffprobe reads `hevc` / `Main 10` / `hvc1` / `yuv420p10le` / `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv` with the mastering-display and content-light side data for PQ; ffmpeg's decode equals ours code for code; the in-process round trip is at 71.1 dB luma PSNR on the 10-bit scale with 803 distinct levels on a ramp and the peak code 940 intact (RTX 5060). 8-bit SDR H.265 is unchanged. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 26df0e69..13b48227 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -27,6 +27,7 @@ mod marker_export; pub mod masks; pub mod media_browser; pub mod media_pool; +pub mod media_watch; pub mod mixer; pub mod multicam; pub mod panels; @@ -382,6 +383,11 @@ pub struct Session { pub browser: media_browser::BrowserState, /// Nesting depth of [`Session::execute`] (commands that run other commands). exec_depth: u32, + /// Each media file's size and modification time when last seen (Settings ▸ Media ▸ + /// Automatically refresh growing files, [`media_watch`]). + pub media_stamps: std::collections::HashMap, + /// The stamps being taken on a worker thread ([`Session::start_media_scan`]). + pub media_scan: Option, } /// An export advanced a batch at a time on the host's thread ([`Session::pump_jobs`]). @@ -469,6 +475,8 @@ impl Session { log: Default::default(), browser: Default::default(), exec_depth: 0, + media_stamps: Default::default(), + media_scan: None, } } @@ -1065,6 +1073,8 @@ mod media_browser_tests; #[cfg(test)] mod media_test_util; #[cfg(test)] +mod media_watch_tests; +#[cfg(test)] mod mixer_tests; #[cfg(test)] mod multicam_tests; diff --git a/crates/engine/src/media_watch.rs b/crates/engine/src/media_watch.rs new file mode 100644 index 00000000..b951f1f4 --- /dev/null +++ b/crates/engine/src/media_watch.rs @@ -0,0 +1,169 @@ +//! Settings ▸ Media ▸ Automatically refresh growing files: media files that change on disk while +//! the project is open are read again, whether they are still being written (a recording, an +//! ingest) or were replaced (a render from EffectCraft or After Effects written over the file a +//! clip uses). +//! +//! Each file media item's file is compared by size and modification time with what it was when +//! last seen. The desktop app takes the stamps every *Refresh growing files every* seconds and as +//! soon as its window comes back to the front, on a worker thread so a file on a slow network share +//! never holds up the interface ([`Session::start_media_scan`], [`Session::poll_media_scan`]); +//! `media.refreshChanged` takes them on the spot (CLI, agents). +//! +//! Refreshing opens the file again: the item gets the file's new [`MediaInfo`] (a longer +//! duration, another frame size) and identity (so relinking compares against the new file), the +//! media pool swaps in the new source (the old one still indexes the file as it was), and the +//! view is refreshed without an undo step or modifying a clean project ([`Session::bump_view`]): +//! the file changed, not the edit. A file seen for the first time is only remembered; one that +//! can't be opened yet keeps its old stamp, so the next look tries again. +//! +//! [`MediaInfo`]: filmcraft_media::MediaInfo + +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, Mutex, PoisonError}; + +use filmcraft_project::{ItemId, ItemKind, MediaRef, Project}; +use serde_json::{Value, json}; + +use crate::{Result, Session}; + +/// A file's size and modification time (nanoseconds since 1970). +pub type Stamp = (u64, u128); + +/// Stamps of the project's media files taken on a worker thread. +pub type MediaScan = Arc)>>>>; + +#[cfg(not(target_arch = "wasm32"))] +fn stamp(path: &str) -> Option { + let meta = std::fs::metadata(path).ok()?; + let modified = meta.modified().ok()?.duration_since(std::time::UNIX_EPOCH).ok()?; + Some((meta.len(), modified.as_nanos())) +} + +#[cfg(target_arch = "wasm32")] +fn stamp(_: &str) -> Option { + None +} + +/// The files of `p`'s file media items (made-offline ones aside). +pub fn media_files(p: &Project) -> BTreeSet { + p.items + .values() + .filter_map(|i| match &i.kind { + ItemKind::Media(m) if !m.offline => match &m.media { + MediaRef::File { path } => Some(path.clone()), + _ => None, + }, + _ => None, + }) + .collect() +} + +/// `media.refreshChanged`: stamp the project's media files now and read the changed ones again. +pub(crate) fn refresh_changed(s: &mut Session, _: &Value) -> Result { + let now = media_files(&s.project).into_iter().map(|p| (p.clone(), stamp(&p))).collect(); + s.apply_media_stamps(now) +} + +impl Session { + /// Start stamping the project's media files on a worker thread, unless a look is still + /// running (or the build has no files to look at: the web app). + pub fn start_media_scan(&mut self) { + if self.media_scan.is_some() || cfg!(target_arch = "wasm32") { + return; + } + let files = media_files(&self.project); + if files.is_empty() { + return; + } + let slot: MediaScan = Arc::default(); + let out = slot.clone(); + let spawned = std::thread::Builder::new().name("media-watch".into()).spawn(move || { + let stamps = files.into_iter().map(|p| (p.clone(), stamp(&p))).collect(); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(stamps); + }); + if spawned.is_ok() { + self.media_scan = Some(slot); + } + } + + /// Apply a finished look (`None` while there is none, or it is still running). + pub fn poll_media_scan(&mut self) -> Option> { + let stamps = self.media_scan.as_ref()?.lock().unwrap_or_else(PoisonError::into_inner).take()?; + self.media_scan = None; + Some(self.apply_media_stamps(stamps)) + } + + /// Compare the media files' stamps with the ones last seen and read the changed files + /// again. Returns `{refreshed: [item ids]}`. + fn apply_media_stamps(&mut self, now: Vec<(String, Option)>) -> Result { + // Only the files the project uses now are remembered. + let before: HashMap = std::mem::take(&mut self.media_stamps); + let mut changed = BTreeSet::new(); + for (path, stamp) in now { + match (before.get(&path), stamp) { + // Not there right now (an app saving by delete and rename): keep the last stamp. + (Some(old), None) => { + self.media_stamps.insert(path, *old); + } + (old, Some(new)) => { + if old.is_some_and(|o| *o != new) { + changed.insert(path.clone()); + } + self.media_stamps.insert(path, new); + } + (None, None) => {} + } + } + if changed.is_empty() { + return Ok(json!({"refreshed": []})); + } + let mut fresh = Vec::new(); + let mut failed = BTreeSet::new(); + for (id, it) in &self.project.items { + let Some(m) = it.as_media().filter(|m| !m.offline) else { continue }; + let MediaRef::File { path } = &m.media else { continue }; + if !changed.contains(path) || failed.contains(path) { + continue; + } + // Opened the way import opens it, so the info is what a new import would get. + match self.media.open_file(path, &*self.services) { + Ok(src) => { + let mut info = src.info().clone(); + if info.kind == filmcraft_media::MediaKind::Still + && let Some(v) = info.video.as_mut() + { + v.frame_rate = crate::settings::timebase_rate(&self.prefs.media.indeterminate_timebase); + } + let identity = crate::relink::identity_of(&*self.services, path).ok(); + fresh.push((*id, path.clone(), info, identity, src)); + } + Err(_) => { + failed.insert(path.clone()); + } + } + } + // Not readable yet (still being written): the next look tries again. + for path in &failed { + if let Some(old) = before.get(path) { + self.media_stamps.insert(path.clone(), *old); + } + } + if fresh.is_empty() { + return Ok(json!({"refreshed": []})); + } + let p = Arc::make_mut(&mut self.project); + let mut refreshed: Vec = Vec::new(); + for (id, path, info, identity, src) in fresh { + if let Some(m) = p.item_mut(id).and_then(|i| i.as_media_mut()) { + m.info = info; + if identity.is_some() { + m.identity = identity; + } + } + self.media.insert_file(id, &path, src); + refreshed.push(id); + } + self.bump_view(); + Ok(json!({"refreshed": refreshed.iter().map(|i| i.0).collect::>()})) + } +} diff --git a/crates/engine/src/media_watch_tests.rs b/crates/engine/src/media_watch_tests.rs new file mode 100644 index 00000000..44c9b409 --- /dev/null +++ b/crates/engine/src/media_watch_tests.rs @@ -0,0 +1,100 @@ +//! Media files changing on disk while the project is open (Settings ▸ Media ▸ Automatically +//! refresh growing files, `media_watch`): a re-rendered movie is read again (new length, new +//! picture, new identity) without an undo step, a file still being written is tried again, and +//! the background look does the same. + +use filmcraft_media::DemoScene; +use serde_json::json; + +use crate::Session; +use crate::media_test_util::{frame_rgba, make_movie, psnr, session_with, tmp_dir}; + +const W: u32 = 160; +const H: u32 = 90; + +/// Write a movie over `path` so its modification time moves even on coarse file systems. +fn rerender(path: &std::path::Path, scene: DemoScene, frames: i64) { + std::thread::sleep(std::time::Duration::from_millis(30)); + make_movie(path, scene, W, H, frames); +} + +fn refresh(s: &mut Session) -> serde_json::Value { + s.execute("media.refreshChanged", json!({})).unwrap() +} + +/// What frame 3 of a fresh import of `scene` looks like. +fn reference(name: &str, scene: DemoScene, frames: i64) -> Vec { + let root = tmp_dir(name); + let f = root.join("ref.mov"); + make_movie(&f, scene, W, H, frames); + let (mut r, _, _) = session_with(&[&f]); + frame_rgba(&mut r, 3, 1.0).2 +} + +#[test] +fn a_rerendered_movie_is_read_again_without_an_undo_step() { + let root = tmp_dir("watch-rerender"); + let a = root.join("a.mov"); + make_movie(&a, DemoScene::Aurora, W, H, 12); + let (mut s, items, _) = session_with(&[&a]); + let item = items[0]; + let before = frame_rgba(&mut s, 3, 1.0).2; + let length = s.project.item(item).unwrap().duration(); + s.saved_revision = s.revision; + let undo = s.history.undo.len(); + // First look: remembered, nothing to read. + assert_eq!(refresh(&mut s), json!({"refreshed": []})); + rerender(&a, DemoScene::CityNight, 24); + assert_eq!(refresh(&mut s), json!({"refreshed": [item.0]})); + let m = s.project.item(item).unwrap().as_media().unwrap().clone(); + assert_eq!(m.info.duration.0, length.0 * 2, "the new length"); + assert_eq!(m.identity, crate::relink::identity_of(&*s.services, &a.to_string_lossy()).ok(), "relinking compares with the new file"); + let after = frame_rgba(&mut s, 3, 1.0).2; + assert!(psnr(&after, &reference("watch-rerender-ref", DemoScene::CityNight, 24)) > 40.0, "the new picture"); + assert!(psnr(&after, &before) < 30.0, "not the old one"); + assert_eq!(s.history.undo.len(), undo, "the file changed, not the edit"); + assert!(!s.is_dirty()); + assert_eq!(refresh(&mut s), json!({"refreshed": []}), "acted on once"); +} + +#[test] +fn a_file_still_being_written_is_tried_again() { + let root = tmp_dir("watch-partial"); + let a = root.join("a.mov"); + make_movie(&a, DemoScene::Aurora, W, H, 12); + let (mut s, items, _) = session_with(&[&a]); + refresh(&mut s); + // The other app has only written the start of the file so far. + let full = root.join("full.mov"); + make_movie(&full, DemoScene::CityNight, W, H, 24); + let bytes = std::fs::read(&full).unwrap(); + std::thread::sleep(std::time::Duration::from_millis(30)); + std::fs::write(&a, &bytes[..64]).unwrap(); + assert_eq!(refresh(&mut s), json!({"refreshed": []})); + std::thread::sleep(std::time::Duration::from_millis(30)); + std::fs::write(&a, &bytes).unwrap(); + assert_eq!(refresh(&mut s), json!({"refreshed": [items[0].0]})); +} + +#[test] +fn a_background_look_reads_changed_media_again() { + let root = tmp_dir("watch-scan"); + let a = root.join("a.mov"); + make_movie(&a, DemoScene::Aurora, W, H, 12); + let (mut s, items, _) = session_with(&[&a]); + let look = |s: &mut Session| { + s.start_media_scan(); + let t0 = std::time::Instant::now(); + loop { + if let Some(r) = s.poll_media_scan() { + return r.unwrap(); + } + assert!(t0.elapsed().as_secs() < 10, "the look never finished"); + std::thread::yield_now(); + } + }; + assert_eq!(look(&mut s), json!({"refreshed": []})); + rerender(&a, DemoScene::CityNight, 24); + assert_eq!(look(&mut s), json!({"refreshed": [items[0].0]})); + assert!(s.media_scan.is_none()); +} diff --git a/crates/engine/src/relink.rs b/crates/engine/src/relink.rs index 7037404a..95c8a7ef 100644 --- a/crates/engine/src/relink.rs +++ b/crates/engine/src/relink.rs @@ -653,6 +653,16 @@ pub fn commands() -> Vec { run: relink, journal: true, }, + CommandSpec { + id: "media.refreshChanged", + label: "Refresh Changed Media", + menu: &[], + shortcut: None, + params: "{}", + enabled: always, + run: crate::media_watch::refresh_changed, + journal: true, + }, CommandSpec { id: "media.replaceFootage", label: "Replace Footage…", diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 2f020835..10db5c87 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -964,9 +964,9 @@ static CATEGORIES: &[Category] = &[ Row::Group( "Growing Files", &[ - b("media.refreshGrowingFiles", "Automatically refresh growing files", false), + b("media.refreshGrowingFiles", "Automatically refresh growing files", true), b("media.resumeGrowingPlayback", "Automatically resume playback for growing files in Source Monitor", false), - sub("media.growingRefreshSeconds", "Refresh growing Files Every", int(1.0, 3600.0, "seconds"), "media.refreshGrowingFiles", false), + sub("media.growingRefreshSeconds", "Refresh growing Files Every", int(1.0, 3600.0, "seconds"), "media.refreshGrowingFiles", true), ], ), b("media.hardwareDecoding", "Enable hardware accelerated decoding (requires restart)", false), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index d5437e97..f3cf83c3 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1384,6 +1384,41 @@ impl FilmcraftApp { // ---------------------------------------------------------------- frame + /// Settings ▸ Media ▸ Automatically refresh growing files: every *Refresh growing files every* + /// seconds, and as soon as the window comes back to the front (after a render in another app), + /// stamp the project's media files on a worker thread and read the changed ones again + /// ([`filmcraft_engine::media_watch`]). + #[cfg(not(target_arch = "wasm32"))] + fn tick_media_refresh(&mut self, ctx: &egui::Context) { + if let Some(r) = self.session.poll_media_scan() { + match r { + Ok(r) => { + if let Some(n) = r["refreshed"].as_array().map(Vec::len).filter(|n| *n > 0) { + self.toast = Some((format!("Refreshed {n} media item(s) changed on disk"), ctx.input(|i| i.time))); + } + } + Err(e) => self.toast = Some((e.to_string(), ctx.input(|i| i.time))), + } + } + let media = &self.session.prefs.media; + let (on, every) = (media.refresh_growing_files, f64::from(media.growing_refresh_seconds.max(1))); + let (last_key, focus_key) = (egui::Id::new("mediaWatch.lastScan"), egui::Id::new("mediaWatch.focused")); + let now = ctx.input(|i| i.time); + let focused = ctx.input(|i| i.focused); + let was_focused: bool = ctx.data(|d| d.get_temp(focus_key)).unwrap_or(focused); + ctx.data_mut(|d| d.insert_temp(focus_key, focused)); + if !on || filmcraft_engine::media_watch::media_files(&self.session.project).is_empty() { + return; + } + let last: f64 = ctx.data(|d| d.get_temp(last_key)).unwrap_or(f64::NEG_INFINITY); + if now - last >= every || (focused && !was_focused) { + ctx.data_mut(|d| d.insert_temp(last_key, now)); + self.session.start_media_scan(); + } + let wait = if self.session.media_scan.is_some() { 0.1 } else { (every - (now - last)).clamp(0.1, every) }; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait)); + } + fn frame(&mut self, ui: &mut egui::Ui) { i18n::set_current(self.ui.language); if std::mem::take(&mut self.panic_next_frame) { @@ -1400,6 +1435,8 @@ impl FilmcraftApp { } self.sync_pool(); self.apply_prefs(&ctx); + #[cfg(not(target_arch = "wasm32"))] + self.tick_media_refresh(&ctx); for ev in self.session.drain_events() { match ev { filmcraft_engine::Event::OpenSequence(_) => { From de6ab8d2395fe01c6af8331f1d9affe1c21990a2 Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 17:07:43 +0530 Subject: [PATCH 006/203] Media: translate Refresh Changed Media (es, ja, zh-CN) Portuguese and Ukrainian catalogs only hold menu labels, so they keep the English name like the other menu-less commands. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + 3 files changed, 3 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 8d15cde2..a0546d03 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -192,6 +192,7 @@ Light Claro Link Vincular Link Media… Vincular medios… + Refresh Changed Media Actualizar medios cambiados Linked Selection Selección vinculada Lock Guides Bloquear guías Lumetri Color Color Lumetri diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index aa86c493..2c96adc2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3501,3 +3501,4 @@ Solid べた塗り Clear Transition トランジションを消去 Set Transition Duration トランジションのデュレーションを設定 + Refresh Changed Media 変更されたメディアを更新 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 476c4159..67223944 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -187,6 +187,7 @@ Light 浅 Link 链接 Link Media… 链接媒体… + Refresh Changed Media 刷新已更改的媒体 Linked Selection 关联选择 Lock Guides 锁定参考线 Lumetri Color Lumetri 颜色 From 696f428faf57ffcbe0ebb0a74600099eda2cbbd8 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 19:24:37 +0700 Subject: [PATCH 007/203] #374: trim preview keeps the waveform in place instead of squeezing it While an edge was dragged, the Timeline drew the clip at its new length from the untrimmed item, so draw_waveform spread the clip's whole source range over the shorter (or longer) box: the waveform squeezed and the sound under the pointer moved, which made it hard to trim to where the music starts (#374, item 13). Trim and roll previews now draw from the item the edit will leave, so every timeline time still covered shows the same media and the waveform is cut off at the moving edge. Video thumbnails show the new first frame the same way. Rate Stretch, which really fits the same media into a new length, still squeezes. The item update is factored out of trim and ripple_trim_group into filmcraft_edit::move_edge, which the preview uses too, so preview and edit agree for speed-changed, reversed and frame-held clips. Rendered mid-drag against the released result, the music clip's waveform matched in 15% of pixel columns before and 100% after. Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 60 +++++++-------- crates/edit/src/tests.rs | 37 +++++++++ crates/ui-egui/src/panels/timeline.rs | 95 ++++++++++++++++++++++-- crates/ui-egui/tests/timeline_trim_ui.rs | 34 +++++++++ 4 files changed, 188 insertions(+), 38 deletions(-) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..2c7dc21e 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -569,6 +569,31 @@ pub fn clamp_trim(seq: &Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delt Ok(d) } +/// What moving one edge by `d` (already clamped) does to the item itself: its length, the media +/// it starts at (a reversed clip consumes media from its other end; a frame hold has none to +/// move), and, for the In edge with `move_start`, where it starts. The trims and the timeline's +/// live trim preview share it, so the preview shows the media the edit will keep (#374). +pub fn move_edge(it: &mut TrackItem, edge: Edge, d: Tick, move_start: bool) { + let speed = it.speed.abs(); + match edge { + Edge::In => { + if !it.reverse && it.frame_hold.is_none() { + it.source_in += src_of(d, speed); + } + it.duration -= d; + if move_start { + it.start += d; + } + } + Edge::Out => { + if it.reverse { + it.source_in -= src_of(d, speed); + } + it.duration += d; + } + } +} + /// Trim one edge of an item (Selection-tool edge drag = Regular; Ripple tool = Ripple). /// Linked partners should be trimmed by the caller with the same delta. pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, ctx: &mut EditCtx) -> Result { @@ -580,27 +605,10 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: let old_end = it.end(); let old_start = it.start; let own_link = it.link; - let speed = it.speed.abs(); let mut work = seq.clone(); { let (_, it) = work.find_item_mut(clip).ok_or(EditError::NoItem(clip))?; - match edge { - Edge::In => { - if !it.reverse && it.frame_hold.is_none() { - it.source_in += src_of(d, speed); - } - it.duration -= d; - if mode == TrimMode::Regular { - it.start += d; - } - } - Edge::Out => { - if it.reverse { - it.source_in -= src_of(d, speed); - } - it.duration += d; - } - } + move_edge(it, edge, d, mode == TrimMode::Regular); } if mode == TrimMode::Ripple { let (at, shift) = match edge { @@ -658,25 +666,11 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta let mut origins: Vec<(TrackId, Tick)> = Vec::new(); for c in clips { let (tid, it) = work.find_item_mut(*c).ok_or(EditError::NoItem(*c))?; - let speed = it.speed.abs(); let from = match edge { Edge::In => it.start + Tick(1), Edge::Out => it.end(), }; - match edge { - Edge::In => { - if !it.reverse && it.frame_hold.is_none() { - it.source_in += src_of(d, speed); - } - it.duration -= d; - } - Edge::Out => { - if it.reverse { - it.source_in -= src_of(d, speed); - } - it.duration += d; - } - } + move_edge(it, edge, d, false); if !origins.iter().any(|(t, _)| *t == tid) { origins.push((tid, from)); } diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ae258cb0..dc0a3e20 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -749,3 +749,40 @@ fn roll_reversed_clips_keeps_media_mapping() { let (_, r) = fx.seq.find_item(ib).unwrap(); assert_eq!((r.start, r.duration, r.source_in), (f(40), f(20), f(50))); } + +/// #374: moving an edge must not change which media sits under any timeline time the clip still +/// covers (the timeline's live trim preview draws its waveform from this item), and must leave the +/// item as the trim itself does. +#[test] +fn moving_an_edge_keeps_the_media_under_the_timeline() { + let fx = Fx::new(); + let v1 = fx.v(0); + for (speed, reverse, hold) in [(1.0, false, None), (2.0, false, None), (0.5, false, None), (1.0, true, None), (2.0, true, None), (1.0, false, Some(f(50)))] + { + for (edge, d) in [(Edge::In, f(7)), (Edge::In, -f(4)), (Edge::Out, -f(9)), (Edge::Out, f(3))] { + let mut fresh = Fx::new(); + let c = fresh.put(v1, 100, 40, 200); + { + let it = fresh.seq.find_item_mut(c).unwrap().1; + (it.speed, it.reverse, it.frame_hold) = (speed, reverse, hold); + } + let before = fresh.seq.find_item(c).unwrap().1.clone(); + let mut moved = before.clone(); + move_edge(&mut moved, edge, d, true); + let label = format!("speed {speed} reverse {reverse} hold {hold:?} {edge:?} {d:?}"); + // every frame still covered shows the same media as before the move + let mut t = moved.start.max(before.start); + while t < moved.end().min(before.end()) { + let (a, b) = (before.source_time_at(t), moved.source_time_at(t)); + assert!((a - b).0.abs() <= 1, "{label}: at {t:?} {a:?} became {b:?}"); + t += f(1); + } + // and the item is what the real (unclamped) trim makes of it + let mut n = fresh.next; + let got = trim(&mut fresh.seq, c, edge, TrimMode::Regular, d, &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(got, d, "{label}: not clamped"); + let trimmed = fresh.seq.find_item(c).unwrap().1; + assert_eq!((trimmed.start, trimmed.duration, trimmed.source_in), (moved.start, moved.duration, moved.source_in), "{label}"); + } + } +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..c1b3c69d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -370,7 +370,10 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let visible = (layout.tick_at(content.min.x - 2.0), layout.tick_at(content.max.x + 2.0)); let selection: Vec = app.session.state.selection.clone(); let mut previews: HashMap)> = HashMap::new(); // live drag preview: (start, dur, track) - preview_drag(app, &seq, &layout, &mut previews); + // clips whose media changes in the preview (a trimmed or rolled edge): drawn from the edited + // item, so the waveform and thumbnail stay where the media is instead of squeezing (#374) + let mut edited: HashMap = HashMap::new(); + preview_drag(app, &seq, &mut previews, &mut edited); for r in &rows { let Some(tr) = seq.track(r.track) else { continue }; let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip }; @@ -381,6 +384,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); let volume_line = r.kind == TrackKind::Audio && !r.lane; for it in &tr.items { + let it = edited.get(&it.id).unwrap_or(it); let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None)); if moved_track.is_some_and(|m| m != r.track) { continue; @@ -1518,7 +1522,14 @@ pub fn locate(app: &FilmcraftApp, clip: u64, edge: Option<&str>) -> Option<(f32, Some((x, y)) } -fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut HashMap)>) { +fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap)>, edited: &mut HashMap) { + // the item as the edge move leaves it, and its place in the preview + let mut moved = |it: &TrackItem, edge: filmcraft_edit::Edge, delta: Tick| { + let mut e = it.clone(); + filmcraft_edit::move_edge(&mut e, edge, delta, true); + out.insert(e.id, (e.start, e.duration, None)); + edited.insert(e.id, e); + }; let Some(d) = &app.tl.drag else { return }; match d { Drag::Move { clips, offset, track_delta, .. } => { @@ -1534,7 +1545,15 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut out.insert(*clip, (it.start, it.duration + *delta, None)); } } - Drag::Trim { clip, edge, delta, .. } | Drag::Stretch { clip, edge, delta, .. } => { + Drag::Trim { clip, edge, delta, .. } => { + for c in filmcraft_engine::commands::with_links(&app.session, &[*clip]) { + if let Some((_, it)) = seq.find_item(c) { + moved(it, *edge, *delta); + } + } + } + // a rate stretch keeps the same media in a new length: that one does squeeze + Drag::Stretch { clip, edge, delta, .. } => { let ids = filmcraft_engine::commands::with_links(&app.session, &[*clip]); for c in ids { if let Some((_, it)) = seq.find_item(c) { @@ -1548,10 +1567,10 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, _layout: &Layout, out: &mut } Drag::Roll { left, right, delta, .. } => { if let Some((_, l)) = seq.find_item(*left) { - out.insert(*left, (l.start, l.duration + *delta, None)); + moved(l, filmcraft_edit::Edge::Out, *delta); } if let Some((_, r)) = seq.find_item(*right) { - out.insert(*right, (r.start + *delta, r.duration - *delta, None)); + moved(r, filmcraft_edit::Edge::In, *delta); } } Drag::Slide { clip, delta } => { @@ -2464,6 +2483,72 @@ pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec { .collect() } +/// #374: trimming an audio clip must cut its waveform off at the moving edge, not squeeze the whole +/// waveform into the shorter clip: the preview draws from the item the trim will leave, which shows +/// the same media at every timeline time it still covers. +#[cfg(test)] +mod trim_preview_tests { + use super::*; + + fn demo() -> (FilmcraftApp, Sequence) { + let mut s = filmcraft_engine::Session::default(); + s.execute("file.openDemoProject", serde_json::json!({})).unwrap(); + let seq = s.active_sequence().unwrap().clone(); + (FilmcraftApp::new(s), seq) + } + + fn assert_same_media(before: &TrackItem, after: &TrackItem) { + let mut t = after.start.max(before.start); + while t < after.end().min(before.end()) { + assert!((before.source_time_at(t) - after.source_time_at(t)).0.abs() <= 1, "{}: media moved at {t:?}", before.name); + t += Tick(filmcraft_time::TICKS_PER_SECOND / 10); + } + } + + #[test] + fn trimming_the_music_keeps_its_waveform_in_place() { + let (mut app, seq) = demo(); + let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).unwrap().clone(); + for (edge, delta) in [(filmcraft_edit::Edge::In, 5), (filmcraft_edit::Edge::Out, -5), (filmcraft_edit::Edge::In, -1)] { + let delta = Tick(delta * filmcraft_time::TICKS_PER_SECOND); + app.tl.drag = Some(Drag::Trim { clip: music.id, edge, mode: filmcraft_edit::TrimMode::Regular, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + let shown = edited.get(&music.id).expect("the trimmed clip is drawn from its edited item"); + // where the preview places it is unchanged + let want = match edge { + filmcraft_edit::Edge::In => (music.start + delta, music.duration - delta, None), + filmcraft_edit::Edge::Out => (music.start, music.duration + delta, None), + }; + assert_eq!(out.get(&music.id).copied(), Some(want)); + assert_eq!((shown.start, shown.duration), (want.0, want.1)); + // the music under every remaining timeline time is the same: the waveform stays put + assert_same_media(&music, shown); + if edge == filmcraft_edit::Edge::In { + assert_eq!(shown.source_in, music.source_in + delta, "5 s into the music, not the music squeezed"); + } + } + } + + #[test] + fn rolling_an_edit_keeps_both_sides_in_place_but_a_stretch_still_squeezes() { + let (mut app, seq) = demo(); + let (left, right) = seq.audio_tracks[0].items.windows(2).find(|w| w[0].end() == w[1].start).map(|w| (w[0].clone(), w[1].clone())).unwrap(); + let delta = Tick(filmcraft_time::TICKS_PER_SECOND / 2); + app.tl.drag = Some(Drag::Roll { left: left.id, right: right.id, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + assert_same_media(&left, &edited[&left.id]); + assert_same_media(&right, &edited[&right.id]); + assert_eq!(edited[&right.id].start, right.start + delta); + // Rate Stretch keeps the same media in a new length: drawn from the original item + app.tl.drag = Some(Drag::Stretch { clip: left.id, edge: filmcraft_edit::Edge::Out, delta, from: Tick::ZERO }); + let (mut out, mut edited) = (HashMap::new(), HashMap::new()); + preview_drag(&app, &seq, &mut out, &mut edited); + assert!(edited.is_empty() && out.contains_key(&left.id)); + } +} + #[cfg(test)] mod waveform_tests { use super::waveform_display_gain; diff --git a/crates/ui-egui/tests/timeline_trim_ui.rs b/crates/ui-egui/tests/timeline_trim_ui.rs index 8ea07cd2..849d6386 100644 --- a/crates/ui-egui/tests/timeline_trim_ui.rs +++ b/crates/ui-egui/tests/timeline_trim_ui.rs @@ -298,3 +298,37 @@ fn dragging_a_caption_edge_inward_trims_it() { assert_eq!(s1, s0, "the caption's start stayed: trimmed, not moved"); assert!(e1 < e0, "the caption's end moved in: {e0} → {e1}"); } + +/// #374: renders the Ambient_Score music clip mid-way through a trim of its In edge, with the +/// mouse button still down, for visual review (needs a GPU): `cargo test -p filmcraft-ui-egui +/// --test timeline_trim_ui -- --ignored trim_waveform_screenshots`. The waveform stays where it was +/// and is cut off at the moving edge; it used to squeeze the whole clip's waveform into the shorter +/// clip, so the sound under the pointer moved while dragging. +#[test] +#[ignore] +fn trim_waveform_screenshots() { + let mut d = Driver::demo(); + let seq = d.app().session.active_sequence().expect("sequence"); + let music = seq.audio_tracks.iter().flat_map(|t| &t.items).find(|i| i.name.contains("Ambient")).expect("music clip").id.0; + let (x0, _, y) = d.edges(music); + // waveform peaks are read on a background thread + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(50)); + d.frames(1); + } + d.screenshot("trim-waveform-before"); + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + let to = pos2(x0 + 160.0, y); + push(&mut d, egui::Event::PointerMoved(pos2(x0, y))); + d.frames(1); + push(&mut d, egui::Event::PointerButton { pos: pos2(x0, y), button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + d.frames(1); + for i in 1..=20 { + push(&mut d, egui::Event::PointerMoved(pos2(x0 + 160.0 * i as f32 / 20.0, y))); + d.frames(1); + } + d.screenshot("trim-waveform-dragging"); + push(&mut d, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + d.frames(2); + d.screenshot("trim-waveform-released"); +} From 09165a383ddd4f92d4e6953fbb9a7be8808a84c8 Mon Sep 17 00:00:00 2001 From: Lukas Keller Date: Sat, 10 Oct 2026 13:06:02 +0200 Subject: [PATCH 008/203] i18n: German (de) joins Edit > Language Language::De is persisted as `de` and translated through a new de.tsv catalog that covers every menu label and the native macOS Settings menu, written from the plain meaning of the English labels with informal "du" and infinitive commands. The new app.language.german command checks, switches and restores the language like the others, the native macOS menu maps it back, Settings > General offers Deutsch, and System Language picks German for any de-* locale (de-DE, de-AT, de-CH ...). A new test, built like the Ukrainian one, fails when German misses a core menu label (the Brazilian Portuguese entries, the top-level menus, the macOS Settings menu) or keeps an entry that is no longer a menu label; menu labels added later use English until they are translated. The System Language test no longer uses `de` as an unsupported example. Closes #490 Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 2 +- apps/filmcraft/src/native_menu.rs | 2 + crates/engine/src/settings.rs | 7 +- crates/engine/src/settings_tests.rs | 4 +- crates/ui-egui/README.md | 8 +- crates/ui-egui/src/i18n/de.tsv | 385 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/mod.rs | 78 +++++- crates/ui-egui/src/menus.rs | 4 + 8 files changed, 475 insertions(+), 15 deletions(-) create mode 100644 crates/ui-egui/src/i18n/de.tsv diff --git a/ROADMAP.md b/ROADMAP.md index bd80f88b..9aef2f7c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -211,4 +211,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining ### Interface localization -Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Simplified Chinese and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian` and `app.language.chinese` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish, Japanese and Simplified Chinese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet; `zh-cn.tsv` must cover every `tl!` literal and menu label too). Searches match the displayed labels as well as English sources. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Simplified Chinese (`zh-cn`; System Language picks it for `zh`, `zh-CN`, `zh-SG` and `zh-Hans`, not for Traditional locales) uses the Chinese fallback font the interface always installs (craft-fonts, otherwise an installed system face); with none, switching to it is refused the same way. Existing vertical text support is preserved. +Edit > Language switches English/Japanese/Spanish/Portuguese (Brazilian)/Ukrainian/Simplified Chinese/German and persists across restarts through `general.interfaceLanguage` (also available in Settings > General). Until a language is chosen, System Language (the default) follows the operating system's or browser's preferred languages and falls back to English. Commands `app.language.english`, `app.language.japanese`, `app.language.spanish`, `app.language.portuguese`, `app.language.ukrainian`, `app.language.chinese` and `app.language.german` are reachable through the control channel. Translations live in one catalog per language (`crates/ui-egui/src/i18n/.tsv`); UI code wraps its strings in `tl!("…")` / `tlf!("…{name}", name)`, and names that come from registries (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. **Spanish, Japanese and Simplified Chinese cover the whole interface**: menus, panels, dialogs, the Settings pages, the Effects/Effect Controls registry, the Keyboard Shortcuts dialog and status messages; tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry (`ja.tsv` has the same entries, but the tests do not require them yet; `zh-cn.tsv` must cover every `tl!` literal and menu label too). Searches match the displayed labels as well as English sources. German covers every menu, including the native macOS Settings menu; menu labels added later use English until they are translated. Brazilian Portuguese and Ukrainian cover the core menus; untranslated labels use English. Ukrainian uses the bundled Cyrillic interface fonts. Engine error messages, the control channel, the CLI and MCP stay in English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Simplified Chinese (`zh-cn`; System Language picks it for `zh`, `zh-CN`, `zh-SG` and `zh-Hans`, not for Traditional locales) uses the Chinese fallback font the interface always installs (craft-fonts, otherwise an installed system face); with none, switching to it is refused the same way. Existing vertical text support is preserved. diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 17ff888c..5a08ba97 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -103,6 +103,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::Uk } else if checked("app.language.chinese") { filmcraft_ui_egui::i18n::Language::ZhCn + } else if checked("app.language.german") { + filmcraft_ui_egui::i18n::Language::De } else { filmcraft_ui_egui::i18n::Language::En }; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 2f020835..865d3758 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -33,9 +33,9 @@ pub const PREFS_VERSION: u32 = 2; #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { - /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, or - /// `system` (the default: the operating system's preferred language when the interface has it, - /// otherwise English). + /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`, + /// `zh-cn`, `de`, or `system` (the default: the operating system's preferred language when the + /// interface has it, otherwise English). pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. pub at_startup: String, @@ -752,6 +752,7 @@ static CATEGORIES: &[Category] = &[ ("pt-br", "Português (Brasil)"), ("uk", "Українська"), ("zh-cn", "简体中文"), + ("de", "Deutsch"), ]), true, ), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index 241f36f6..0d8faa16 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -29,11 +29,13 @@ fn interface_language_persists_and_rejects_hostile_values() { let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() }; set(&mut s, "general.interfaceLanguage", json!("es")); assert_eq!(Preferences::load(&path).general.interface_language, "es"); - for value in [json!("xx"), json!("es-MX"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { + for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); assert_eq!(s.prefs.general.interface_language, "es"); } assert_eq!(Preferences::load(&path).general.interface_language, "es"); + set(&mut s, "general.interfaceLanguage", json!("de")); + assert_eq!(Preferences::load(&path).general.interface_language, "de"); // back to following the operating system (#218) set(&mut s, "general.interfaceLanguage", json!("system")); assert_eq!(Preferences::load(&path).general.interface_language, "system"); diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 699a95d4..1c723fea 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -17,8 +17,9 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho ## Localisation -Edit > Language offers English, Japanese and Spanish. The language is stored in the engine's -`general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. +Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified +Chinese and German. The language is stored in the engine's `general.interfaceLanguage` preference, +restored on startup, and also available in Settings > General. Its default, System Language (`system`), follows the operating system: the first of the user's preferred languages that the interface has (the host supplies them through `HostHooks::system_languages`: `sys-locale` on the desktop, `navigator.languages` on the web), @@ -34,7 +35,8 @@ original work using ordinary language, without proprietary localisation resource Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain -English; Brazilian Portuguese currently covers core menus and falls back to English elsewhere. +English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; they +fall back to English elsewhere. Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv new file mode 100644 index 00000000..019f4d17 --- /dev/null +++ b/crates/ui-egui/src/i18n/de.tsv @@ -0,0 +1,385 @@ +# German (de) catalog. Same format as `es.tsv`: context source translation. +# +# Clean-room: every entry was written from the plain meaning of the English label using ordinary +# German video-editing vocabulary. No proprietary localisation resource, string table or product UI +# was consulted or copied; add entries the same way. Like the German catalogs of the other Craft +# apps, text addresses the user informally ("du") and commands use the infinitive ("Projekt +# öffnen…"). Product, format and feature names (FilmCraft, ArtCraft, Lumetri, Essential Graphics, +# EDL, AAF…) keep their spelling. This catalog covers every menu, including the native macOS +# Settings menu; anything else is shown in English. +# +# Terms: track = Spur, bin = Ablage, marker = Marker, playhead = Abspielposition, In/Out = In-/Out- +# Punkt, transition = Übergang, frame hold = Standbild, panel = Bedienfeld, workspace = +# Arbeitsbereich, label (colour) = Farbmarkierung, preferences (also the native macOS Settings +# menu) = Voreinstellungen. "Timeline" is not translated. + + AAF… AAF… + About FilmCraft Über FilmCraft + Add Audio Submix Track Audio-Submix-Spur hinzufügen + Add Caption at Playhead Untertitel an Abspielposition hinzufügen + Add Chapter Marker… Kapitelmarker hinzufügen… + Add Edit Schnitt hinzufügen + Add Edit to All Tracks Schnitt auf allen Spuren hinzufügen + Add Flash Cue Marker… Flash-Cue-Marker hinzufügen… + Add Frame Hold Standbild hinzufügen + Add Guide… Hilfslinie hinzufügen… + Add Marker Marker hinzufügen + Add New Caption Track… Neue Untertitelspur hinzufügen… + Add Range Marker Bereichsmarker hinzufügen + Add Range Marker to In and Out Bereichsmarker von In bis Out hinzufügen + Add Tracks… Spuren hinzufügen… + Adjustment Layer… Einstellungsebene… + Align to Selection An Auswahl ausrichten + Align to Video Frame Am Videobild ausrichten + Align to Video Frame as Group Als Gruppe am Videobild ausrichten + All Panels Alle Bedienfelder + Alpha Alpha + Appearance Erscheinungsbild + Appearance… Darstellung… + Apply Audio Transition Audioübergang anwenden + Apply Default Transitions to Selection Standardübergänge auf Auswahl anwenden + Apply Video Transition Videoübergang anwenden + Arrange Anordnen + ArtCraft Website ArtCraft-Website + Assembly Montage + Attach Proxies… Proxys anhängen… + Audio Audio + Audio Channels… Audiokanäle… + Audio Clip Mixer Audioclip-Mischpult + Audio Gain… Audioverstärkung… + Audio Hardware… Audiohardware… + Audio In Audio-In + Audio Meters Audiopegel + Audio Options Audiooptionen + Audio Out Audio-Out + Audio Track Mixer Audiospur-Mischpult + Audio Waveform Audio-Wellenform + Audio… Audio… + Auto Save… Automatisch speichern… + Automate to Sequence… Automatisch in Sequenz einfügen… + Avid Log Exchange… Avid Log Exchange… + Bars and Tone… Farbbalken und Ton… + Bin Ablage + Bin From Selection Ablage aus Auswahl + Black Video… Schwarzbild… + Blue Blau + Bottom Unten + Breakout to Mono In Mono aufteilen + Bring Forward Schrittweise nach vorne + Bring to Front In den Vordergrund + Brown Braun + Captions Untertitel + Captions and Graphics Untertitel und Grafiken + Captions… Untertitel… + Caribbean Karibik + Center Horizontally Horizontal zentrieren + Center Vertically Vertikal zentrieren + Cerulean Himmelblau + Clear Löschen + Clear Guides Hilfslinien löschen + Clear In In-Punkt entfernen + Clear In and Out In- und Out-Punkt entfernen + Clear Markers Marker entfernen + Clear Out Out-Punkt entfernen + Clear Selected Marker Ausgewählten Marker entfernen + Clip Clip + Close Schließen + Close All Other Projects Alle anderen Projekte schließen + Close All Projects Alle Projekte schließen + Close Gap Lücke schließen + Close Project Projekt schließen + Color Farbe + Color Management… Farbmanagement… + Color Matte… Farbfläche… + Color… Farbe… + Comparison View Vergleichsansicht + Composite Video Composite-Video + Consolidate Duplicates Duplikate zusammenführen + Copy Kopieren + Copy Paste Includes Sequence Markers Kopieren und Einfügen mit Sequenzmarkern + Create Captions from Transcript… Untertitel aus Transkript erstellen… + Create Multi-Camera Source Sequence… Mehrkamera-Quellsequenz erstellen… + Create Proxies… Proxys erstellen… + Cut Ausschneiden + Dark Dunkel + Darkest Sehr dunkel + Delete Render Files Renderdateien löschen + Delete Render Files In to Out Renderdateien von In bis Out löschen + Delete Tracks… Spuren löschen… + Delete Transcript Transkript löschen + Demo Footage Demomaterial + Demo Project Demoprojekt + Deselect All Auswahl aufheben + Detach Proxies Proxys trennen + Display Mode Anzeigemodus + Distribute Verteilen + Distribute Horizontally Horizontal verteilen + Distribute Space Horizontally Abstände horizontal verteilen + Distribute Space Vertically Abstände vertikal verteilen + Distribute Vertically Vertikal verteilen + Duplicate Duplizieren + Dynamic Audio Waveforms Dynamische Audio-Wellenformen + EDL… EDL… + Edit Bearbeiten + Edit Offline… Offline bearbeiten… + Edit Original Original bearbeiten + Edit Subclip… Subclip bearbeiten… + Edit Workspaces… Arbeitsbereiche bearbeiten… + Editing Schnitt + Effect Controls Effekteinstellungen + Effects Effekte + Ellipse Ellipse + Enable Aktivieren + Enable Remix Remix aktivieren + Essential Graphics Essential Graphics + Essential Sound Essential Sound + Events Ereignisse + Export Exportieren + Export As Motion Graphics Template… Als Motion-Graphics-Vorlage exportieren… + Export Markers as CSV… Marker als CSV exportieren… + Extract Extrahieren + Extract Audio Audio extrahieren + FCPXML… FCPXML… + Field Options… Halbbildoptionen… + File Datei + File… Datei… + Fill frame Bild füllen + FilmCraft Help… FilmCraft-Hilfe… + FilmCraft on GitHub FilmCraft auf GitHub + FilmCraft on getartcraft.com FilmCraft auf getartcraft.com + Final Cut Pro XML… Final Cut Pro XML… + Find Next Weitersuchen + Find… Suchen… + Fit Einpassen + Fit to frame In Bild einpassen + Flatten Reduzieren + Forest Waldgrün + Frame Blending Frame-Blending + Frame Hold Options… Standbildoptionen… + Frame Sampling Frame-Sampling + From Bin Aus der Ablage + From Source Monitor Aus dem Quellmonitor + From Source Monitor, Match Frame Aus dem Quellmonitor, bildgenau + From file… Aus Datei… + Full Voll + General… Allgemein… + Generate Audio Waveform Audio-Wellenform erzeugen + Get Media File Properties for Mediendatei-Eigenschaften abrufen für + Go to Gap Zur Lücke gehen + Go to In Zum In-Punkt gehen + Go to Next Caption Segment Zum nächsten Untertitelsegment gehen + Go to Next Marker Zum nächsten Marker gehen + Go to Out Zum Out-Punkt gehen + Go to Previous Caption Segment Zum vorherigen Untertitelsegment gehen + Go to Previous Marker Zum vorherigen Marker gehen + Go to Split Zum Split-Punkt gehen + Graphics and Titles Grafiken und Titel + Graphics… Grafiken… + Green Grün + Group Gruppieren + Guide Templates Hilfslinienvorlagen + Help Hilfe + Hide All Caption Tracks Alle Untertitelspuren ausblenden + High Quality Playback Wiedergabe in hoher Qualität + History Verlauf + Import From Importieren aus + Import Image Sequence… Bildsequenz importieren… + Import from Media Browser Aus Medienbrowser importieren + Import… Importieren… + Info Info + Ingest Settings… Ingest-Einstellungen… + Insert Einfügen + Insert Frame Hold Segment Standbildsegment einfügen + Install Motion Graphics Template… Motion-Graphics-Vorlage installieren… + Interpret Footage… Material interpretieren… + Iris Iris + Join the ArtCraft Discord… Dem ArtCraft-Discord beitreten… + Keyboard Shortcuts… Tastenkürzel… + Label Farbmarkierung + Labels… Farbmarkierungen… + Language Sprache + Lavender Lavendel + Learning Lernen + Left Links + Libraries Bibliotheken + Lift Herausheben + Light Hell + Link Verknüpfen + Link Media… Medien verknüpfen… + Linked Selection Verknüpfte Auswahl + Lock Guides Hilfslinien sperren + Lumetri Color Lumetri-Farbe + Lumetri Scopes Lumetri-Scopes + Magenta Magenta + Magnification Vergrößerung + Make Offline… Offline setzen… + Make Subclip… Subclip erstellen… + Make Subsequence Untersequenz erstellen + Manage Guides… Hilfslinien verwalten… + Mango Mango + Mark Clip Clip markieren + Mark In In-Punkt setzen + Mark Out Out-Punkt setzen + Mark Selection Auswahl markieren + Mark Split Split-Punkt setzen + Markers Marker + Match Frame Quellbild finden + Media Analysis & Transcription… Medienanalyse und Transkription… + Media Browser Medienbrowser + Media Cache… Mediencache… + Media… Medien… + Memory… Arbeitsspeicher… + Merge Clips… Clips zusammenführen… + Metadata Metadaten + Modify Ändern + Motion Graphics Template… Motion-Graphics-Vorlage… + Multi-Camera Mehrkamera + Multi-Camera View Mehrkameraansicht + Nest… Verschachteln… + New Neu + New Layer Neue Ebene + New Project Panel Neues Projekt-Bedienfeld + Next Appearance Mode Nächster Darstellungsmodus + Next in Sequence Nächste in der Sequenz + Next in Track Nächste in der Spur + Normalize Mix Track… Mix-Spur normalisieren… + OMF… OMF… + Offline File… Offline-Datei… + Open Project… Projekt öffnen… + OpenTimelineIO… OpenTimelineIO… + Optical Flow Optischer Fluss + Overwrite Überschreiben + Paste Einfügen + Paste Attributes… Attribute einfügen… + Paste Insert Einfügen und verschieben + Paused Resolution Auflösung bei Pause + Playback Resolution Wiedergabeauflösung + Playback… Wiedergabe… + Plugins… Plug-ins… + Polygon Polygon + Preferences Voreinstellungen + Previous in Sequence Vorherige in der Sequenz + Previous in Track Vorherige in der Spur + Program Programm + Progress Fortschritt + Project Projekt + Project Manager… Projektmanager… + Project Settings Projekteinstellungen + Project… Projekt… + Properties Eigenschaften + Proxy Proxy + Purple Lila + Reconnect Full Resolution Media… Medien in voller Auflösung neu verknüpfen… + Recover Unsaved Changes… Nicht gespeicherte Änderungen wiederherstellen… + Rectangle Rechteck + Red Rot + Redo Wiederholen + Reference Monitor Referenzmonitor + Remix Remix + Remix Properties… Remix-Eigenschaften… + Remove Attributes… Attribute entfernen… + Remove Filler Words Füllwörter entfernen + Remove Pauses Pausen entfernen + Remove Unused Nicht verwendete entfernen + Rename… Umbenennen… + Render Audio Audio rendern + Render Effects In to Out Effekte von In bis Out rendern + Render In to Out Von In bis Out rendern + Render Selection Auswahl rendern + Replace Fonts in Projects… Schriften in Projekten ersetzen… + Replace With Clip Durch Clip ersetzen + Report an Issue… Problem melden… + Reset All Parameters Alle Parameter zurücksetzen + Reset Duration Dauer zurücksetzen + Reset to Saved Layout Auf gespeichertes Layout zurücksetzen + Restore Captions from Source Clip Untertitel aus Quellclip wiederherstellen + Reveal Log Files… Protokolldateien anzeigen… + Reverse Match Frame Sequenzbild finden + Revert Zurück zur gespeicherten Version + Revert Remix Remix zurücksetzen + Review Überprüfung + Right Rechts + Ripple Delete Löschen und Lücke schließen + Ripple Sequence Markers Sequenzmarker mitverschieben + Rose Rosa + Safe Margins Sicherheitsränder + Save Speichern + Save All Alle speichern + Save As… Speichern unter… + Save Changes to this Workspace Änderungen an diesem Arbeitsbereich speichern + Save Guides as Template… Hilfslinien als Vorlage speichern… + Save a Copy… Kopie speichern… + Save as New Workspace… Als neuen Arbeitsbereich speichern… + Save as Template… Als Vorlage speichern… + Scale to Frame Size Auf Bildgröße skalieren + Scene Edit Detection… Szenenschnitterkennung… + Scratch Disks… Arbeitslaufwerke… + Search Bin Suchablage + Select Auswählen + Select All Alles auswählen + Select All Matching Alle passenden auswählen + Select Label Group Farbmarkierungsgruppe auswählen + Select Next Graphic Nächste Grafik auswählen + Select Next Layer Nächste Ebene auswählen + Select Previous Graphic Vorherige Grafik auswählen + Select Previous Layer Vorherige Ebene auswählen + Selection Follows Playhead Auswahl folgt Abspielposition + Selection as FilmCraft Project… Auswahl als FilmCraft-Projekt… + Selection… Auswahl… + Send Backward Schrittweise nach hinten + Send to Back In den Hintergrund + Sequence Sequenz + Sequence From Clip Sequenz aus Clip + Sequence Settings… Sequenzeinstellungen… + Sequence… Sequenz… + Settings Voreinstellungen + Show Active Caption Tracks Only Nur aktive Untertitelspuren anzeigen + Show All Caption Tracks Alle Untertitelspuren anzeigen + Show All Marker Colors Alle Markerfarben anzeigen + Show Guides Hilfslinien anzeigen + Show Rulers Lineale anzeigen + Show Through Edits Durchgehende Schnitte anzeigen + Simplify Sequence… Sequenz vereinfachen… + Snap in Program Monitor Im Programmmonitor einrasten + Snap in Timeline In der Timeline einrasten + Source Quelle + Source Settings… Quelleinstellungen… + Speed/Duration… Geschwindigkeit/Dauer… + Synchronize… Synchronisieren… + System Compatibility Report… Systemkompatibilitätsbericht… + Tan Hellbraun + Teal Petrol + Text Text + Time Interpolation Zeitinterpolation + Timecode Timecode + Timecode… Timecode… + Timeline Timeline + Timeline… Timeline… + Toggle Proxies Proxys ein/aus + Tools Werkzeuge + Top Oben + Transcribe Sequence… Sequenz transkribieren… + Transcribe… Transkribieren… + Transcript Transkript + Transparent Video… Transparentes Video… + Trim Edit Schnitt trimmen + Trim… Trimmen… + Undo Rückgängig + Ungroup Gruppierung aufheben + Universal Counting Leader… Universal-Countdown-Vorspann… + Update Metadata… Metadaten aktualisieren… + Upgrade Caption to Graphic Untertitel in Grafik umwandeln + Upgrade to Source Graphic In Quellgrafik umwandeln + Vertical Text Vertikaler Text + Video In Video-In + Video Options Videooptionen + Video Out Video-Out + Video and Audio Waveform Split Video und Audio-Wellenform geteilt + View Ansicht + Violet Violett + Window Fenster + Workspaces Arbeitsbereiche + Yellow Gelb + Zoom In Vergrößern + Zoom Out Verkleinern + Zoom to Sequence Auf Sequenz zoomen diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index fb88bec6..05ce228b 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -42,6 +42,7 @@ pub enum Language { /// Simplified Chinese, persisted as `zh-cn`. #[serde(rename = "zh-cn")] ZhCn, + De, } static JAPANESE: OnceLock = OnceLock::new(); @@ -49,9 +50,10 @@ static SPANISH: OnceLock = OnceLock::new(); static PORTUGUESE: OnceLock = OnceLock::new(); static UKRAINIAN: OnceLock = OnceLock::new(); static CHINESE: OnceLock = OnceLock::new(); +static GERMAN: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 6] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn]; + pub const ALL: [Self; 7] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De]; pub fn name(self) -> &'static str { match self { @@ -61,6 +63,7 @@ impl Language { Self::PtBr => "Português (Brasil)", Self::Uk => "Українська", Self::ZhCn => "简体中文", + Self::De => "Deutsch", } } @@ -72,17 +75,20 @@ impl Language { "pt-br" => Some(Self::PtBr), "uk" => Some(Self::Uk), "zh-cn" => Some(Self::ZhCn), + "de" => Some(Self::De), _ => None, } } /// Interface Language ▸ System Language (#218): the first of the user's preferred languages /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the - /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is. - /// Chinese gets the Simplified catalog only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, - /// `zh-Hans…`); Traditional ones (`zh-TW`, `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped. + /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is; + /// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one. Chinese gets the Simplified catalog + /// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`, + /// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 5] = [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk)]; + const PRIMARY: [(&str, Language); 6] = + [("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), ("pt", Language::PtBr), ("uk", Language::Uk), ("de", Language::De)]; tags.iter() .find_map(|tag| { let mut parts = tag.split(['-', '_', '.', '@']); @@ -108,6 +114,7 @@ impl Language { Self::PtBr => "pt-br", Self::Uk => "uk", Self::ZhCn => "zh-cn", + Self::De => "de", } } @@ -120,6 +127,7 @@ impl Language { Self::PtBr => Some(PORTUGUESE.get_or_init(|| Catalog::parse(include_str!("pt-br.tsv")))), Self::Uk => Some(UKRAINIAN.get_or_init(|| Catalog::parse(include_str!("uk.tsv")))), Self::ZhCn => Some(CHINESE.get_or_init(|| Catalog::parse(include_str!("zh-cn.tsv")))), + Self::De => Some(GERMAN.get_or_init(|| Catalog::parse(include_str!("de.tsv")))), } } @@ -287,6 +295,7 @@ mod tests { ("pt-br", include_str!("pt-br.tsv")), ("uk", include_str!("uk.tsv")), ("zh-cn", include_str!("zh-cn.tsv")), + ("de", include_str!("de.tsv")), ] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -316,6 +325,13 @@ mod tests { assert_eq!(Language::ZhCn.name(), "简体中文"); assert_eq!(Language::ZhCn.tr("File"), "文件"); assert_eq!(Language::ZhCn.tr("我的视频.mp4"), "我的视频.mp4"); + assert_eq!(Language::De.tr("File"), "Datei"); + assert_eq!(Language::De.tr("Save As…"), "Speichern unter…"); + assert_eq!(Language::De.tr("Größe ändern.mp4"), "Größe ändern.mp4"); + assert_eq!(Language::De.tr("An untranslated label"), "An untranslated label"); + assert_eq!(Language::De.name(), "Deutsch"); + assert_eq!(Language::parse("de"), Some(Language::De)); + assert_eq!(Language::parse("de-DE"), None, "the preference stores the bare code"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -747,6 +763,26 @@ mod tests { let mut restarted = filmcraft_engine::Session::default(); restarted.prefs = serde_json::from_str(&prefs).unwrap(); assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Uk); + let result = crate::menus::invoke(&mut app, &ctx, "app.language.german", serde_json::json!({})).unwrap(); + assert_eq!(result, serde_json::json!("de")); + assert_eq!((app.ui.language, current()), (Language::De, Language::De)); + assert_eq!(app.session.prefs.general.interface_language, "de"); + for it in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) { + assert_eq!(it.checked, Some(it.id == "app.language.german"), "{}", it.id); + } + // the menus are drawn in German, the language names in their own language + let items = crate::menus::menu_items(&app); + let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); + assert_eq!(label("file.saveAs"), Some("Speichern unter…")); + assert_eq!(label("app.language.spanish"), Some("Español")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"language\":\"de\""), "{saved}"); + let restored: crate::state::UiState = serde_json::from_str(&saved).unwrap(); + assert_eq!(restored.language, Language::De); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::De); crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); set_current(Language::En); @@ -772,12 +808,18 @@ mod tests { assert_eq!(l(&[tag]), Language::En, "{tag}: Traditional Chinese has no catalog"); } assert_eq!(l(&["zh-TW", "ja-JP"]), Language::Ja); - assert_eq!(l(&["fr-FR", "de", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + for tag in ["de", "DE", "de-DE", "de-AT", "de-CH", "de-LI", "de-LU", "de_DE.UTF-8", "de_AT.UTF-8@euro", "de_CH"] { + assert_eq!(l(&[tag]), Language::De, "{tag}"); + } + assert_eq!(l(&["fr-FR", "it", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + assert_eq!(l(&["fr-CH", "de-CH", "en"]), Language::De); assert_eq!(l(&["en-GB", "es"]), Language::En); - for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"]] { + assert_eq!(l(&["en-US", "de"]), Language::En); + for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"]] { assert_eq!(l(none), Language::En, "{none:?}"); } assert_eq!(l(&[&"x".repeat(1 << 20), "es"]), Language::Es); + assert_eq!(l(&[&"de".repeat(1 << 19), "de-DE"]), Language::De); } /// #218: the `system` preference (the default) asks the host for the system's languages; an @@ -833,6 +875,28 @@ mod tests { } } + /// German covers at least the core menus (the Brazilian Portuguese entries, the top-level menus + /// and the native macOS Settings menu); every entry must still be a menu label. + #[test] + fn german_entries_cover_the_original_menu_catalog() { + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let items = crate::menus::menu_items(&app); + let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); + let (entries, _) = catalog::parse_entries(include_str!("de.tsv")); + for (_, en, _) in entries { + assert!(known(&en) || en == "Settings", "not a menu label: {en}"); + } + let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + for (_, en, _) in portuguese { + assert!(Language::De.has(&en), "missing German menu label: {en}"); + } + // "Clip" and "Audio" are the same word in German, so check for an entry, not a change + for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + assert!(Language::De.has(en), "untranslated German menu: {en}"); + } + assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); + } + #[test] fn ukrainian_renders_with_bundled_fonts() { let ctx = egui::Context::default(); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..dbce3b26 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -28,6 +28,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.portuguese", "Português (Brasil)", ["Edit", "Language"], None), uic!("app.language.ukrainian", "Українська", ["Edit", "Language"], None), uic!("app.language.chinese", "简体中文", ["Edit", "Language"], None), + uic!("app.language.german", "Deutsch", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -180,6 +181,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: | "app.language.portuguese" | "app.language.ukrainian" | "app.language.chinese" + | "app.language.german" ) { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { @@ -195,6 +197,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.portuguese" => crate::i18n::Language::PtBr, "app.language.ukrainian" => crate::i18n::Language::Uk, "app.language.chinese" => crate::i18n::Language::ZhCn, + "app.language.german" => crate::i18n::Language::De, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -571,6 +574,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.portuguese" => it.checked = Some(app.ui.language == crate::i18n::Language::PtBr), "app.language.ukrainian" => it.checked = Some(app.ui.language == crate::i18n::Language::Uk), "app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::ZhCn), + "app.language.german" => it.checked = Some(app.ui.language == crate::i18n::Language::De), _ => {} } if it.id.starts_with("view.") { From 63c9b17454dfc557ceedeb085b327b5298e6501f Mon Sep 17 00:00:00 2001 From: Lukas Keller Date: Sat, 10 Oct 2026 13:06:02 +0200 Subject: [PATCH 009/203] contributors: credit @keller-lukas as Lukas Keller Co-Authored-By: Claude Opus 5.5 --- contributors/people.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50788355..46dc3db2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,8 @@ sync_github_name = false [people.liangforstudy] sync_github_name = true + +[people.keller-lukas] +real_name = "Lukas Keller" +display_name = "Lukas Keller" +sync_github_name = false From b92078ec7e1e8530eee7dda42227906d4184ac6b Mon Sep 17 00:00:00 2001 From: Katt <238968166+KattKnight@users.noreply.github.com> Date: Sat, 10 Oct 2026 18:30:21 +0530 Subject: [PATCH 010/203] Media: a file that can't be opened is tried again once it changes, not on every look A media file the refresh couldn't open (still being written, or not media FilmCraft reads) kept its old stamp, so every look saw it as changed and opened it again on the UI thread. It now keeps its new stamp and is tried again once it changes again. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/media_watch.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/media_watch.rs b/crates/engine/src/media_watch.rs index b951f1f4..ddef6f11 100644 --- a/crates/engine/src/media_watch.rs +++ b/crates/engine/src/media_watch.rs @@ -14,7 +14,7 @@ //! media pool swaps in the new source (the old one still indexes the file as it was), and the //! view is refreshed without an undo step or modifying a clean project ([`Session::bump_view`]): //! the file changed, not the edit. A file seen for the first time is only remembered; one that -//! can't be opened yet keeps its old stamp, so the next look tries again. +//! can't be opened (still being written) is tried again once it changes again, not on every look. //! //! [`MediaInfo`]: filmcraft_media::MediaInfo @@ -142,12 +142,8 @@ impl Session { } } } - // Not readable yet (still being written): the next look tries again. - for path in &failed { - if let Some(old) = before.get(path) { - self.media_stamps.insert(path.clone(), *old); - } - } + // A file that can't be opened (still being written, or not media FilmCraft reads) keeps its + // new stamp: it is tried again once it changes again, not on every look. if fresh.is_empty() { return Ok(json!({"refreshed": []})); } From 8f0f689c855027d8706fb273564359f2b38ac035 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:10:39 +0700 Subject: [PATCH 011/203] Project panel: double-click anywhere empty in Icon view opens Import In Icon view each row of cards was laid out as a hover-only strip, so only the strip under the last row sensed clicks. With an empty project the whole panel is that strip and double-click opened Import; as soon as the project had cards (for example after placing audio in the timeline, which adds the clip and its sequence), most of the visible blank space sat beside or between the cards and ignored double-clicks. A click-sensing background now covers the visible Icon view behind the cards (registered first, so cards keep their own clicks) with the same handlers as the empty strip: click deselects, double-click imports, right-click opens the panel menu. Tests double-click with real pointer input at 60 fps: between and beside the demo project's bins, and beside the cards after a tone is placed on A1; both failed before (0 imports) and pass now. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/panels/project_views.rs | 16 +++ crates/ui-egui/tests/project_panel_ui.rs | 117 +++++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/crates/ui-egui/src/panels/project_views.rs b/crates/ui-egui/src/panels/project_views.rs index 7a54afae..381829bc 100644 --- a/crates/ui-egui/src/panels/project_views.rs +++ b/crates/ui-egui/src/panels/project_views.rs @@ -497,6 +497,21 @@ fn empty_space(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mu let r = Rect::from_min_size(rest.min, vec2(rest.width(), rest.height().max(40.0))); let resp = ui.allocate_rect(r, Sense::click()); app.auto.add(&format!("{}.empty", v.prefix), r.intersect(ui.clip_rect()), "Empty area"); + empty_interactions(app, &resp, v, actions); +} + +/// The space between and beside the cards of the Icon view, which is empty too: registered before +/// the cards, so a card on top takes its own clicks and everything else lands here. Without it +/// only the strip under the last row answered, and once a project had a row of bins or clips that +/// strip was all a double-click could open Import from. +fn empty_background(app: &mut FilmcraftApp, ui: &mut egui::Ui, v: &View, actions: &mut Actions) { + let r = ui.clip_rect(); + let resp = ui.interact(r, ui.id().with((&v.prefix, "empty-background")), Sense::click()); + app.auto.add(&format!("{}.emptyBackground", v.prefix), r, "Empty area"); + empty_interactions(app, &resp, v, actions); +} + +fn empty_interactions(app: &mut FilmcraftApp, resp: &egui::Response, v: &View, actions: &mut Actions) { if resp.clicked() { app.ui.project_panel.selected_bin = None; actions.push(("project.select".into(), json!({"items": []}))); @@ -811,6 +826,7 @@ pub fn icon_view(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, v: &View app.ui.keys.icon_columns = per_row; } let rows = n.div_ceil(per_row); + empty_background(app, ui, v, actions); for rr in 0..rows { let (row, _) = ui.allocate_exact_size(vec2(ui.available_width(), cell.y), Sense::hover()); for k in 0..per_row { diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32..a56b8928 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -626,3 +626,120 @@ fn media_browser_drag_imports_into_the_project_panel() { assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +/// Double-clicking empty space in the Project panel opens Import, also between and beside the +/// Icon view's cards once the project has some. Only the strip under the last row used to answer, +/// so with a project loaded a double-click in the visible blank space did nothing. Real pointer +/// input at 60 frames per second, so the two clicks are a real double-click. +#[test] +fn double_click_empty_area_imports_with_a_project_loaded() { + fn driver(demo: bool) -> (Driver, std::sync::Arc) { + let mut session = Session::default(); + if demo { + session.execute("file.openDemoProject", json!({})).expect("demo project"); + } + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picks.clone(); + app.hooks.pick_files = Some(Box::new(move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Vec::new() + })); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, snapshots: None }; + d.frames(10); + (d, picks) + } + fn double_click(d: &mut Driver, at: egui::Pos2) { + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + // a pause first, as a person makes: clicks closer together continue the last gesture + // (a triple click) instead of starting a double-click + d.frames(60); + push(d, egui::Event::PointerMoved(at)); + d.frames(1); + for _ in 0..2 { + for pressed in [true, false] { + push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + d.frames(1); + } + } + d.frames(3); + } + let count = |p: &std::sync::Arc| p.load(std::sync::atomic::Ordering::SeqCst); + + // an empty project: the whole panel is the empty area + let (mut d, picks) = driver(false); + let r = d.rect("project.empty"); + double_click(&mut d, egui::pos2(r[0] + r[2] / 2.0, r[1] + r[3] / 2.0)); + assert_eq!(count(&picks), 1, "empty project"); + + // the demo project, Icon view: the gap between the first two bins, and the space right of the + // last card in the first row + let (mut d, picks) = driver(true); + d.ok("ui.set", json!({})); + let (a, b) = (d.rect("project.bin.1"), d.rect("project.bin.2")); + assert!((a[1] - b[1]).abs() < 1.0 && b[0] > a[0] + a[2], "bins 1 and 2 share a row: {a:?} {b:?}"); + double_click(&mut d, egui::pos2((a[0] + a[2] + b[0]) / 2.0, a[1] + a[3] / 2.0)); + assert_eq!(count(&picks), 1, "between two bins"); + let row: Vec<[f32; 4]> = d.ids("project.bin.").iter().map(|id| d.rect(id)).filter(|r| (r[1] - a[1]).abs() < 1.0).collect(); + let last = row.iter().fold(a, |m, r| if r[0] > m[0] { *r } else { m }); + let panel = d.rect("project.count"); + if last[0] + last[2] + 30.0 < panel[0] + panel[2] { + double_click(&mut d, egui::pos2(last[0] + last[2] + 20.0, last[1] + last[3] / 2.0)); + assert_eq!(count(&picks), 2, "right of the last card"); + } + // a card still takes its own double-click: the bin opens, Import does not + double_click(&mut d, egui::pos2(a[0] + a[2] / 2.0, a[1] + a[3] / 2.0)); + let picked = count(&picks); + assert!(picked <= 2, "double-clicking a bin must not open Import"); +} + +/// The report: with audio placed in the timeline, double-clicking the Project panel's empty space +/// no longer opened Import. A new sequence with a tone on A1 (so the panel shows the sequence and +/// the audio clip as cards); every empty spot opens Import: the strip under the cards and the space +/// beside them. +#[test] +fn double_click_imports_with_audio_in_the_timeline() { + let mut session = Session::default(); + session.execute("file.newSequence", json!({"name": "Mix", "audio": 2, "video": 1})).unwrap(); + let inter: Vec = (0..48_000).flat_map(|i| [(i as f32 * 0.0576).sin() * 0.5; 2]).collect(); + let bytes: std::sync::Arc<[u8]> = filmcraft_engine::previews::write_wav_f32(&inter, 48_000).into(); + let item = filmcraft_engine::commands::import_bytes(&mut session, "/tone.wav", bytes, None).unwrap(); + session.execute("timeline.place", json!({"item": item.0, "audioTrack": "A1", "seconds": 0.0})).unwrap(); + let (tx, rx) = channel(); + let mut app = FilmcraftApp::new(session).with_control(rx); + let picks = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let seen = picks.clone(); + app.hooks.pick_files = Some(Box::new(move |_| { + seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Vec::new() + })); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, snapshots: None }; + d.frames(10); + let mut double_click = |d: &mut Driver, at: egui::Pos2| { + let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); + d.frames(60); + push(d, egui::Event::PointerMoved(at)); + d.frames(1); + for _ in 0..2 { + for pressed in [true, false] { + push(d, egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }); + d.frames(1); + } + } + d.frames(3); + }; + let count = || picks.load(std::sync::atomic::Ordering::SeqCst); + let cards: Vec<[f32; 4]> = d.ids("project.item.").iter().filter(|id| id.matches('.').count() == 2).map(|id| d.rect(id)).collect(); + assert!(!cards.is_empty(), "the sequence and the tone show as cards"); + let empty = d.rect("project.empty"); + eprintln!("cards {cards:?}, empty strip {empty:?}"); + double_click(&mut d, egui::pos2(empty[0] + empty[2] / 2.0, empty[1] + empty[3] / 2.0)); + let strip = count(); + let last = cards.iter().fold(cards[0], |m, r| if r[0] > m[0] { *r } else { m }); + double_click(&mut d, egui::pos2(last[0] + last[2] + 40.0, last[1] + last[3] / 2.0)); + let beside = count() - strip; + assert_eq!((strip, beside), (1, 1), "double-click on (the strip under the cards, the space beside them)"); +} From 2f4b59329e534fb6033e4ce2abb9f298c8176eac Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:21:30 +0700 Subject: [PATCH 012/203] #598: filmcraft-avi, a clean-room AVI / OpenDML demuxer New L0 crate written from the AVI RIFF file reference and OpenDML 1.02: avih and per-stream strh / strf (BITMAPINFOHEADER, WAVEFORMATEX(TENSIBLE)) / strn, and chunk tables from the best index a file has: the OpenDML indx super index and ix## standard indexes (field indexes too), else idx1 (offsets relative to movi or absolute) plus a scan of RIFF AVIX extensions it cannot see, else a scan of the movi lists (key frames then unknown). Frames are read on demand through a ByteSource. Sizes are clamped to the file, so a cut-off recording opens with what is there; rec lists nest at most 3 deep and tables are bounded by what the file could hold. Tests: hand-built files for every index path, truncation, and every single-byte mutation and truncation (no panic); ffmpeg-made files against ffprobe; an ignored test on a 1.16 GB OpenDML file with AVIX extensions. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 8 + Cargo.toml | 1 + crates/avi/Cargo.toml | 15 + crates/avi/README.md | 43 +++ crates/avi/src/lib.rs | 542 ++++++++++++++++++++++++++++++ crates/avi/src/source.rs | 59 ++++ crates/avi/src/tests.rs | 269 +++++++++++++++ crates/avi/tests/ffmpeg_oracle.rs | 151 +++++++++ xtask/src/main.rs | 1 + 9 files changed, 1089 insertions(+) create mode 100644 crates/avi/Cargo.toml create mode 100644 crates/avi/README.md create mode 100644 crates/avi/src/lib.rs create mode 100644 crates/avi/src/source.rs create mode 100644 crates/avi/src/tests.rs create mode 100644 crates/avi/tests/ffmpeg_oracle.rs diff --git a/Cargo.lock b/Cargo.lock index d94a4457..ed05e601 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1755,6 +1755,13 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-avi" +version = "0.5.0" +dependencies = [ + "filmcraft-testkit", +] + [[package]] name = "filmcraft-bitstream" version = "0.5.0" @@ -1808,6 +1815,7 @@ dependencies = [ "filmcraft-ac3", "filmcraft-apv", "filmcraft-av1", + "filmcraft-avi", "filmcraft-bitstream", "filmcraft-color", "filmcraft-dnx", diff --git a/Cargo.toml b/Cargo.toml index 1d306cff..d69ecd83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ filmcraft-geom = { path = "crates/geom" } filmcraft-color = { path = "crates/color" } filmcraft-bitstream = { path = "crates/bitstream" } filmcraft-isobmff = { path = "crates/isobmff" } +filmcraft-avi = { path = "crates/avi" } filmcraft-matroska = { path = "crates/matroska" } filmcraft-mxf = { path = "crates/mxf" } filmcraft-cfb = { path = "crates/cfb" } diff --git a/crates/avi/Cargo.toml b/crates/avi/Cargo.toml new file mode 100644 index 00000000..519c098d --- /dev/null +++ b/crates/avi/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "filmcraft-avi" +description = "Clean-room AVI (RIFF) and OpenDML demuxer" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/avi/README.md b/crates/avi/README.md new file mode 100644 index 00000000..d10ba02f --- /dev/null +++ b/crates/avi/README.md @@ -0,0 +1,43 @@ +# filmcraft-avi + +A clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the +OpenDML AVI File Format Extensions 1.02. Layer L0: no dependencies beyond `std`, no `unsafe`, builds +for `wasm32-unknown-unknown`. No FFmpeg, libavformat or other AVI implementation was read; ffmpeg +and ffprobe are used only as external **test oracles**. + +## API + +```rust +use filmcraft_avi::{open, StreamKind}; + +let file = open(&bytes)?; // any ByteSource: a slice, a file, a web Blob… +for (i, s) in file.streams.iter().enumerate() { + match s.kind { + StreamKind::Video => println!("{:?} {} fps", s.video.as_ref().map(|b| b.compression), s.rate as f64 / s.scale as f64), + StreamKind::Audio => println!("format 0x{:04x}", s.audio.as_ref().map_or(0, |w| w.format_tag)), + _ => {} + } + let first = file.read_chunk(&bytes, i, 0)?; // frame data, on demand +} +``` + +## What it reads + +| Part | | +|---|---| +| Headers | `avih`; per stream `strh`, `strf` (`BITMAPINFOHEADER`, `WAVEFORMATEX` / `WAVEFORMATEXTENSIBLE`), `strn` | +| Index | OpenDML `indx` super index → `ix##` standard indexes (field indexes too), else `idx1` (offsets relative to `movi` or absolute), else a scan of the `movi` lists | +| Large files | `RIFF AVIX` extensions: through the OpenDML index, or scanned after `idx1`, which only covers the first RIFF | +| Key frames | from the index; a scan marks every chunk and says so (`keyframes_known`) | +| Damage | sizes clamped to the file (a cut-off recording opens with what is there), `rec ` lists nested at most 3 deep, tables bounded by what the file could hold | + +`filmcraft-codecs` maps the streams onto decoders (`AviSource`): Motion JPEG, H.264, HEVC and +uncompressed RGB / YUV video; PCM, MP3, MP2 and AC-3 audio. + +## Tests + +`cargo test -p filmcraft-avi`: hand-built files for every index path (`idx1` relative and absolute, +`rec ` lists, OpenDML standard indexes, a scan), truncation, and every single-byte mutation and +truncation of them (never a panic). `tests/ffmpeg_oracle.rs` checks ffmpeg-made files (Motion JPEG, +H.264 with B-frames, BGR24, YUY2; PCM and MP3) against ffprobe; the ignored `opendml_file_past_1_gb` +checks a 1.16 GB OpenDML file with `AVIX` extensions. diff --git a/crates/avi/src/lib.rs b/crates/avi/src/lib.rs new file mode 100644 index 00000000..0294fdfe --- /dev/null +++ b/crates/avi/src/lib.rs @@ -0,0 +1,542 @@ +//! Clean-room AVI demuxer, written from Microsoft's published AVI RIFF file reference and the +//! OpenDML AVI File Format Extensions 1.02. +//! +//! [`open`] reads the `hdrl` header list (`avih`, and per stream `strh` / `strf` / `strn` / +//! `indx`) of the first `RIFF AVI ` and builds a per-stream table of [`Chunk`]s from the best index +//! the file has: the OpenDML super index (`indx` → `ix##` standard indexes, which also cover the +//! `RIFF AVIX` extensions of files past 1 GB), else the AVI 1.0 `idx1` (plus a scan of any `AVIX` +//! extension it cannot see), else a scan of the `movi` lists. Frame data is read on demand with +//! [`AviFile::read_chunk`]. Truncated files (a recording cut off) open with what is there. +//! +//! Every size and count comes from the file and is treated as hostile: reads are bounded by the +//! file length, tables by the number of chunks the file could hold, and nesting by a fixed depth. +//! +//! Layer L0: no dependencies beyond `std`, no `unsafe`, builds for `wasm32-unknown-unknown`. + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod source; +#[cfg(test)] +mod tests; + +pub use source::ByteSource; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Error { + /// Not an AVI file (no `RIFF` … `AVI ` header). + NotAvi, + /// A read failed. + Io(String), + /// Structurally invalid data. + Invalid(String), +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::NotAvi => f.write_str("not an AVI file"), + Error::Io(e) => write!(f, "I/O error: {e}"), + Error::Invalid(e) => write!(f, "invalid AVI: {e}"), + } + } +} + +impl std::error::Error for Error {} + +pub type Result = std::result::Result; + +/// Whether `head` starts like an AVI file: `RIFF` `AVI `. +pub fn sniff(head: &[u8]) -> bool { + head.get(..4) == Some(b"RIFF") && head.get(8..12) == Some(b"AVI ") +} + +/// `avih`: the main header (frame period, flags, frame count, size). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MainHeader { + pub micro_sec_per_frame: u32, + pub flags: u32, + pub total_frames: u32, + pub streams: u32, + pub width: u32, + pub height: u32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum StreamKind { + Video, + Audio, + /// Text, MIDI or anything else (`fccType`). + Other([u8; 4]), +} + +/// `strf` of a video stream: a `BITMAPINFOHEADER`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct BitmapInfo { + pub width: i32, + /// Positive: rows stored bottom-up (uncompressed RGB); negative: top-down. + pub height: i32, + pub bit_count: u16, + /// `biCompression` as four bytes: a FourCC (`MJPG`, `H264`, `YUY2`…) or 0 / 3 (`BI_RGB`, + /// `BI_BITFIELDS`) in little-endian. + pub compression: [u8; 4], + pub size_image: u32, + /// Bytes after the 40-byte header (codec setup, palette). + pub extra: Vec, +} + +impl BitmapInfo { + /// Uncompressed RGB (`BI_RGB` / `BI_BITFIELDS`). + pub fn is_rgb(&self) -> bool { + matches!(u32::from_le_bytes(self.compression), 0 | 3) + } +} + +/// `strf` of an audio stream: a `WAVEFORMATEX` (`WAVEFORMATEXTENSIBLE` is resolved to its sub-format). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WaveFormat { + /// 1 PCM, 3 IEEE float, 0x50 MPEG audio, 0x55 MP3, 0x2000 AC-3, 0xFF AAC… + pub format_tag: u16, + pub channels: u16, + pub sample_rate: u32, + pub avg_bytes_per_sec: u32, + pub block_align: u16, + pub bits_per_sample: u16, + /// `cbSize` bytes after the 18-byte header. + pub extra: Vec, +} + +/// One stored frame or audio chunk. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Chunk { + /// Offset of the data (after the 8-byte chunk header). + pub offset: u64, + pub size: u32, + /// A key frame (always true for audio and when the index does not say). + pub key: bool, +} + +/// Where the chunk tables came from. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum IndexSource { + /// OpenDML `indx` super index and `ix##` standard indexes. + OpenDml, + /// The AVI 1.0 `idx1` (plus a scan of the `AVIX` extensions). + Idx1, + /// No usable index: the `movi` lists were scanned (key frames unknown). + Scan, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stream { + pub kind: StreamKind, + /// `fccHandler`. + pub handler: [u8; 4], + /// Time base: `rate / scale` units per second (frames per second for video). + pub scale: u32, + pub rate: u32, + /// Start time in `scale / rate` units. + pub start: u32, + /// Length in `scale / rate` units. + pub length: u32, + /// Bytes per sample for fixed-size samples (PCM block align), 0 when samples vary. + pub sample_size: u32, + pub video: Option, + pub audio: Option, + /// `strn`. + pub name: Option, + pub chunks: Vec, + /// Whether [`Chunk::key`] comes from an index (a scan cannot tell). + pub keyframes_known: bool, + /// The OpenDML super index entries (offsets of the `ix##` chunks). + super_index: Vec, +} + +impl Stream { + /// Stream index of a `movi` chunk id (`00dc`, `01wb`…): its first two characters. + fn number(id: &[u8; 4]) -> Option { + let d = |b: u8| (b as char).to_digit(16).map(|v| v as usize); + Some(d(id[0])? * 16 + d(id[1])?) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AviFile { + pub header: MainHeader, + pub streams: Vec, + pub index: IndexSource, +} + +/// Options for [`open_with`] (tests reach the fallback indexes with them). +#[derive(Clone, Copy, Debug, Default)] +pub struct OpenOptions { + pub ignore_odml: bool, + pub ignore_idx1: bool, +} + +/// Headers larger than this are refused (a real `hdrl` is a few kilobytes). +const MAX_HEADER: u64 = 16 << 20; +/// Streams beyond this are ignored. +const MAX_STREAMS: usize = 64; +/// `LIST rec ` inside `movi` nests once; deeper is refused. +const MAX_DEPTH: u32 = 3; + +fn io(e: std::io::Error) -> Error { + Error::Io(e.to_string()) +} + +fn read(src: &S, at: u64, n: u64) -> Result> { + let end = at.checked_add(n).filter(|e| *e <= src.len()).ok_or_else(|| Error::Invalid(format!("{n} bytes at {at} run past the end")))?; + let len = usize::try_from(end - at).map_err(|_| Error::Invalid("chunk too large".into()))?; + let mut buf = vec![0; len]; + src.read_at(at, &mut buf).map_err(io)?; + Ok(buf) +} + +fn fourcc(b: &[u8], at: usize) -> [u8; 4] { + let mut f = [0; 4]; + if let Some(s) = b.get(at..at + 4) { + f.copy_from_slice(s); + } + f +} + +fn le16(b: &[u8], at: usize) -> u16 { + b.get(at..at + 2).map_or(0, |s| u16::from_le_bytes([s[0], s[1]])) +} + +fn le32(b: &[u8], at: usize) -> u32 { + b.get(at..at + 4).map_or(0, |s| u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +fn le64(b: &[u8], at: usize) -> u64 { + u64::from(le32(b, at)) | (u64::from(le32(b, at + 4)) << 32) +} + +/// A chunk header at `at`: (id, size, data offset). The size is clamped to the file (a truncated +/// recording keeps what is there). +fn header_at(src: &S, at: u64) -> Result<([u8; 4], u64, u64)> { + let h = read(src, at, 8)?; + let data = at + 8; + let size = u64::from(le32(&h, 4)).min(src.len().saturating_sub(data)); + Ok((fourcc(&h, 0), size, data)) +} + +/// Iterate the chunks of `[from, to)`: (id, size, data offset). Stops at the first unreadable +/// header; every step moves forward, so it ends. +fn children(src: &S, from: u64, to: u64) -> Vec<([u8; 4], u64, u64)> { + let mut out = Vec::new(); + let mut at = from; + while at.checked_add(8).is_some_and(|e| e <= to) { + let Ok((id, size, data)) = header_at(src, at) else { break }; + out.push((id, size, data)); + // chunks are padded to an even size + let Some(next) = data.checked_add(size).and_then(|e| e.checked_add(size & 1)) else { break }; + if next <= at { + break; + } + at = next; + } + out +} + +/// Open an AVI file. +pub fn open(src: &S) -> Result { + open_with(src, OpenOptions::default()) +} + +pub fn open_with(src: &S, opts: OpenOptions) -> Result { + let head = read(src, 0, 12.min(src.len())).map_err(|_| Error::NotAvi)?; + if !sniff(&head) { + return Err(Error::NotAvi); + } + // the RIFF forms: the first `AVI `, then any `AVIX` extensions (OpenDML) + let mut hdrl = None; + let mut movis: Vec<(u64, u64, u64)> = Vec::new(); // (position of the `movi` id, data start, data end) + let mut idx1 = None; + for (id, size, data) in children(src, 0, src.len()) { + if id != *b"RIFF" || size < 4 { + continue; + } + let end = data + size; + let form = fourcc(&read(src, data, 4)?, 0); + if form != *b"AVI " && form != *b"AVIX" { + continue; + } + for (cid, csize, cdata) in children(src, data + 4, end) { + match &cid { + b"LIST" if csize >= 4 => { + let ltype = fourcc(&read(src, cdata, 4)?, 0); + if ltype == *b"hdrl" && hdrl.is_none() && form == *b"AVI " { + hdrl = Some((cdata + 4, cdata + csize)); + } else if ltype == *b"movi" { + movis.push((cdata, cdata + 4, cdata + csize)); + } + } + b"idx1" if form == *b"AVI " => idx1 = Some((cdata, csize)), + _ => {} + } + } + } + let (hfrom, hto) = hdrl.ok_or_else(|| Error::Invalid("no hdrl header list".into()))?; + if hto - hfrom > MAX_HEADER { + return Err(Error::Invalid("header list too large".into())); + } + let (header, mut streams) = parse_hdrl(src, hfrom, hto)?; + if streams.is_empty() { + return Err(Error::Invalid("no streams".into())); + } + // chunk tables from the best index + let odml = !opts.ignore_odml && streams.iter().any(|s| !s.super_index.is_empty()); + let index = if odml && read_odml(src, &mut streams) { + IndexSource::OpenDml + } else if let (false, Some((at, size)), Some(first)) = (opts.ignore_idx1, idx1, movis.first()) { + clear(&mut streams); + read_idx1(src, &mut streams, at, size, first.0); + // idx1 only covers the first RIFF: the AVIX extensions are scanned + for &(_, from, to) in movis.iter().skip(1) { + scan(src, &mut streams, from, to, 0); + } + if streams.iter().all(|s| s.chunks.is_empty()) { scan_all(src, &mut streams, &movis) } else { IndexSource::Idx1 } + } else { + scan_all(src, &mut streams, &movis) + }; + Ok(AviFile { header, streams, index }) +} + +fn clear(streams: &mut [Stream]) { + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + } +} + +fn scan_all(src: &S, streams: &mut [Stream], movis: &[(u64, u64, u64)]) -> IndexSource { + clear(streams); + for &(_, from, to) in movis { + scan(src, streams, from, to, 0); + } + for s in streams.iter_mut() { + s.keyframes_known = false; + } + IndexSource::Scan +} + +fn parse_hdrl(src: &S, from: u64, to: u64) -> Result<(MainHeader, Vec)> { + let mut header = MainHeader::default(); + let mut streams = Vec::new(); + for (id, size, data) in children(src, from, to) { + match &id { + b"avih" => { + let b = read(src, data, size.min(64))?; + header = MainHeader { + micro_sec_per_frame: le32(&b, 0), + flags: le32(&b, 12), + total_frames: le32(&b, 16), + streams: le32(&b, 24), + width: le32(&b, 32), + height: le32(&b, 36), + }; + } + b"LIST" if size >= 4 && streams.len() < MAX_STREAMS && fourcc(&read(src, data, 4)?, 0) == *b"strl" => { + streams.push(parse_strl(src, data + 4, data + size)?); + } + _ => {} + } + } + Ok((header, streams)) +} + +fn parse_strl(src: &S, from: u64, to: u64) -> Result { + let mut s = Stream { + kind: StreamKind::Other([0; 4]), + handler: [0; 4], + scale: 1, + rate: 1, + start: 0, + length: 0, + sample_size: 0, + video: None, + audio: None, + name: None, + chunks: Vec::new(), + keyframes_known: true, + super_index: Vec::new(), + }; + let mut strf = None; + for (id, size, data) in children(src, from, to) { + match &id { + b"strh" => { + let b = read(src, data, size.min(64))?; + s.kind = match &fourcc(&b, 0) { + b"vids" => StreamKind::Video, + b"auds" => StreamKind::Audio, + other => StreamKind::Other(*other), + }; + s.handler = fourcc(&b, 4); + s.scale = le32(&b, 20); + s.rate = le32(&b, 24); + s.start = le32(&b, 28); + s.length = le32(&b, 32); + s.sample_size = le32(&b, 44); + } + b"strf" => strf = Some(read(src, data, size.min(1 << 20))?), + b"strn" => { + let b = read(src, data, size.min(4096))?; + let name = String::from_utf8_lossy(b.split(|&c| c == 0).next().unwrap_or_default()).trim().to_string(); + s.name = (!name.is_empty()).then_some(name); + } + b"indx" => s.super_index = super_index(&read(src, data, size.min(MAX_HEADER))?), + _ => {} + } + } + if let Some(b) = strf { + match s.kind { + StreamKind::Video => { + s.video = Some(BitmapInfo { + width: le32(&b, 4) as i32, + height: le32(&b, 8) as i32, + bit_count: le16(&b, 14), + compression: fourcc(&b, 16), + size_image: le32(&b, 20), + extra: b.get(40.min(b.len())..).unwrap_or_default().to_vec(), + }); + } + StreamKind::Audio => { + let cb = usize::from(le16(&b, 16)); + let extra = b.get(18.min(b.len())..(18 + cb).min(b.len())).unwrap_or_default().to_vec(); + let mut tag = le16(&b, 0); + // WAVE_FORMAT_EXTENSIBLE: the sub-format GUID starts with the real tag + if tag == 0xFFFE && extra.len() >= 22 { + tag = le16(&extra, 6); + } + s.audio = Some(WaveFormat { + format_tag: tag, + channels: le16(&b, 2), + sample_rate: le32(&b, 4), + avg_bytes_per_sec: le32(&b, 8), + block_align: le16(&b, 12), + bits_per_sample: le16(&b, 14), + extra, + }); + } + StreamKind::Other(_) => {} + } + } + if s.scale == 0 || s.rate == 0 { + (s.scale, s.rate) = (1, 1); + } + Ok(s) +} + +/// The `ix##` chunk offsets of an `indx` super index (`AVI_INDEX_OF_INDEXES`, 4 longs per entry). +fn super_index(b: &[u8]) -> Vec { + let longs = usize::from(le16(b, 0)); + let (subtype, itype, n) = (b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0xFF), le32(b, 4) as usize); + if itype != 0 || subtype != 0 || longs != 4 { + return Vec::new(); + } + // header 24 bytes, entries of 16: qwOffset, dwSize, dwDuration + let n = n.min(b.len().saturating_sub(24) / 16); + (0..n).map(|k| le64(b, 24 + k * 16)).filter(|&o| o > 0).collect() +} + +/// Fill the chunk tables from the OpenDML standard indexes; false (tables cleared) when a stream +/// that has data lacks one or none could be read. +fn read_odml(src: &S, streams: &mut [Stream]) -> bool { + let limit = src.len() / 8; + let mut total = 0u64; + for s in streams.iter_mut() { + s.chunks.clear(); + s.keyframes_known = true; + for &at in &s.super_index { + let Ok((id, size, data)) = header_at(src, at) else { continue }; + if &id[..2] != b"ix" { + continue; + } + let Ok(b) = read(src, data, size.min(MAX_HEADER * 8)) else { continue }; + let (longs, subtype, itype) = (usize::from(le16(&b, 0)), b.get(2).copied().unwrap_or(0), b.get(3).copied().unwrap_or(0)); + // AVI_INDEX_OF_CHUNKS; 2 longs per entry, or 3 for the field index (AVI_INDEX_2FIELD) + if itype != 1 || !(longs == 2 && subtype == 0 || longs == 3 && subtype == 1) { + continue; + } + let base = le64(&b, 12); + let step = longs * 4; + let n = (le32(&b, 4) as usize).min(b.len().saturating_sub(24) / step); + for k in 0..n { + let e = 24 + k * step; + let (off, raw) = (u64::from(le32(&b, e)), le32(&b, e + 4)); + let size = raw & 0x7FFF_FFFF; + let Some(offset) = base.checked_add(off).filter(|o| o.checked_add(u64::from(size)).is_some_and(|end| end <= src.len())) else { continue }; + total += 1; + if total > limit { + return false; + } + s.chunks.push(Chunk { offset, size, key: raw & 0x8000_0000 == 0 }); + } + } + } + let ok = streams.iter().any(|s| !s.chunks.is_empty()) && streams.iter().all(|s| !s.super_index.is_empty() || matches!(s.kind, StreamKind::Other(_))); + if !ok { + clear(streams); + } + ok +} + +/// Fill the chunk tables from `idx1` (16-byte entries: id, flags, offset, size). Offsets are +/// relative to the `movi` list's type id, or (some writers) absolute: the first entry decides. +fn read_idx1(src: &S, streams: &mut [Stream], at: u64, size: u64, movi: u64) { + let Ok(b) = read(src, at, size.min(src.len() / 2)) else { return }; + let entries: Vec<&[u8]> = b.as_chunks::<16>().0.iter().map(|e| e.as_slice()).collect(); + let id_at = |pos: u64| read(src, pos, 4).ok().map(|v| fourcc(&v, 0)); + let first = entries.iter().find(|e| le32(e, 4) & 1 == 0); + let relative = match first { + Some(e) => { + let (id, off) = (fourcc(e, 0), u64::from(le32(e, 8))); + id_at(movi + off) == Some(id) || id_at(off) != Some(id) + } + None => true, + }; + let base = if relative { movi } else { 0 }; + for e in entries { + let (id, flags, off, len) = (fourcc(e, 0), le32(e, 4), u64::from(le32(e, 8)), le32(e, 12)); + // AVIIF_LIST entries (`rec ` lists) and palette changes carry no samples + if flags & 1 != 0 || &id[2..] == b"pc" { + continue; + } + let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) else { continue }; + let Some(offset) = base.checked_add(off).and_then(|o| o.checked_add(8)).filter(|o| o.checked_add(u64::from(len)).is_some_and(|end| end <= src.len())) + else { + continue; + }; + s.chunks.push(Chunk { offset, size: len, key: flags & 0x10 != 0 || s.kind != StreamKind::Video }); + } +} + +/// Append the data chunks of a `movi` list (`[from, to)`) to the streams' tables, in file order. +fn scan(src: &S, streams: &mut [Stream], from: u64, to: u64, depth: u32) { + if depth >= MAX_DEPTH { + return; + } + for (id, size, data) in children(src, from, to) { + if id == *b"LIST" { + if size >= 4 { + scan(src, streams, data + 4, data + size, depth + 1); + } + continue; + } + if &id[..2] == b"ix" || &id[2..] == b"pc" { + continue; + } + if let Some(s) = Stream::number(&id).and_then(|n| streams.get_mut(n)) { + s.chunks.push(Chunk { offset: data, size: u32::try_from(size).unwrap_or(u32::MAX), key: true }); + } + } +} + +impl AviFile { + /// The bytes of chunk `i` of stream `stream`. + pub fn read_chunk(&self, src: &S, stream: usize, i: usize) -> Result> { + let c = self.streams.get(stream).and_then(|s| s.chunks.get(i)).ok_or_else(|| Error::Invalid(format!("no chunk {i} in stream {stream}")))?; + read(src, c.offset, u64::from(c.size)) + } +} diff --git a/crates/avi/src/source.rs b/crates/avi/src/source.rs new file mode 100644 index 00000000..783cc190 --- /dev/null +++ b/crates/avi/src/source.rs @@ -0,0 +1,59 @@ +//! Random-access byte sources for the demuxer (same shape as the other container crates', so one +//! adapter serves them all). + +use std::io; + +/// Random-access, read-only byte source (file, in-memory buffer, web Blob…). +pub trait ByteSource { + /// Total length in bytes. + fn len(&self) -> u64; + /// Fill `buf` entirely with bytes starting at `offset`; fail with `UnexpectedEof` if past the end. + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()>; + /// True if the source holds no bytes. + fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +fn eof() -> io::Error { + io::Error::new(io::ErrorKind::UnexpectedEof, "read past end of byte source") +} + +impl ByteSource for [u8] { + fn len(&self) -> u64 { + <[u8]>::len(self) as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + let start = usize::try_from(offset).map_err(|_| eof())?; + let end = start.checked_add(buf.len()).ok_or_else(eof)?; + buf.copy_from_slice(self.get(start..end).ok_or_else(eof)?); + Ok(()) + } +} + +impl ByteSource for Vec { + fn len(&self) -> u64 { + self.as_slice().len() as u64 + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + self.as_slice().read_at(offset, buf) + } +} + +impl ByteSource for &T { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} + +impl ByteSource for std::sync::Arc { + fn len(&self) -> u64 { + (**self).len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> io::Result<()> { + (**self).read_at(offset, buf) + } +} diff --git a/crates/avi/src/tests.rs b/crates/avi/src/tests.rs new file mode 100644 index 00000000..248364d6 --- /dev/null +++ b/crates/avi/src/tests.rs @@ -0,0 +1,269 @@ +use super::*; + +fn chunk(id: &[u8; 4], data: &[u8]) -> Vec { + let mut v = id.to_vec(); + v.extend_from_slice(&(data.len() as u32).to_le_bytes()); + v.extend_from_slice(data); + if data.len() % 2 == 1 { + v.push(0); + } + v +} + +fn list(ltype: &[u8; 4], body: &[u8]) -> Vec { + let mut d = ltype.to_vec(); + d.extend_from_slice(body); + chunk(b"LIST", &d) +} + +fn strl(video: bool) -> Vec { + let mut strh = vec![0u8; 56]; + strh[..4].copy_from_slice(if video { b"vids" } else { b"auds" }); + strh[4..8].copy_from_slice(if video { b"MJPG" } else { &[0; 4] }); + let (scale, rate, ss) = if video { (1u32, 25u32, 0u32) } else { (4, 192_000, 4) }; + strh[20..24].copy_from_slice(&scale.to_le_bytes()); + strh[24..28].copy_from_slice(&rate.to_le_bytes()); + strh[44..48].copy_from_slice(&ss.to_le_bytes()); + let strf = if video { + let mut b = vec![0u8; 40]; + b[..4].copy_from_slice(&40u32.to_le_bytes()); + b[4..8].copy_from_slice(&64i32.to_le_bytes()); + b[8..12].copy_from_slice(&48i32.to_le_bytes()); + b[14..16].copy_from_slice(&24u16.to_le_bytes()); + b[16..20].copy_from_slice(b"MJPG"); + b + } else { + let mut b = vec![0u8; 18]; + b[..2].copy_from_slice(&1u16.to_le_bytes()); + b[2..4].copy_from_slice(&2u16.to_le_bytes()); + b[4..8].copy_from_slice(&48_000u32.to_le_bytes()); + b[8..12].copy_from_slice(&192_000u32.to_le_bytes()); + b[12..14].copy_from_slice(&4u16.to_le_bytes()); + b[14..16].copy_from_slice(&16u16.to_le_bytes()); + b + }; + let mut body = chunk(b"strh", &strh); + body.extend(chunk(b"strf", &strf)); + body.extend(chunk(b"strn", if video { b"Camera\0" } else { b"Mic\0" })); + list(b"strl", &body) +} + +/// A small AVI: 3 video frames (the second a delta, the third odd-sized) and 2 audio chunks, +/// optionally inside a `rec ` list, with an `idx1` whose offsets are relative to `movi` or absolute. +fn avi(idx1: bool, absolute: bool, rec: bool) -> Vec { + let mut avih = vec![0u8; 56]; + avih[0..4].copy_from_slice(&40_000u32.to_le_bytes()); + avih[12..16].copy_from_slice(&0x10u32.to_le_bytes()); + avih[16..20].copy_from_slice(&3u32.to_le_bytes()); + avih[24..28].copy_from_slice(&2u32.to_le_bytes()); + avih[32..36].copy_from_slice(&64u32.to_le_bytes()); + avih[36..40].copy_from_slice(&48u32.to_le_bytes()); + let mut hdrl = chunk(b"avih", &avih); + hdrl.extend(strl(true)); + hdrl.extend(strl(false)); + let hdrl = list(b"hdrl", &hdrl); + let samples: [(&[u8; 4], Vec, bool); 5] = + [(b"00dc", vec![1; 10], true), (b"01wb", vec![2; 16], true), (b"00dc", vec![3; 6], false), (b"01wb", vec![4; 8], true), (b"00dc", vec![5; 7], true)]; + let mut body = Vec::new(); + let mut entries = Vec::new(); + for (id, data, key) in &samples { + entries.push((**id, body.len() as u32 + 4, data.len() as u32, *key)); + body.extend(chunk(id, data)); + } + let movi_body = if rec { + let r = list(b"rec ", &body); + // the samples moved 12 bytes in (the rec list's header and type) + for e in &mut entries { + e.1 += 12; + } + r + } else { + body + }; + let movi = list(b"movi", &movi_body); + let mut riff = b"AVI ".to_vec(); + riff.extend(hdrl); + // position of the `movi` type id: after `RIFF` , what `riff` holds so far, `LIST` + let movi_at = (8 + riff.len() + 8) as u32; + riff.extend(movi); + if idx1 { + let mut ix = Vec::new(); + for (id, off, len, key) in entries { + ix.extend_from_slice(&id); + ix.extend_from_slice(&(if key { 0x10u32 } else { 0 }).to_le_bytes()); + ix.extend_from_slice(&(if absolute { off + movi_at } else { off }).to_le_bytes()); + ix.extend_from_slice(&len.to_le_bytes()); + } + riff.extend(chunk(b"idx1", &ix)); + } + chunk(b"RIFF", &riff) +} + +fn table(f: &AviFile, s: usize) -> Vec<(u32, bool)> { + f.streams[s].chunks.iter().map(|c| (c.size, c.key)).collect() +} + +#[test] +fn headers_and_every_index_path_agree() { + for (idx1, absolute, rec, want) in [ + (true, false, false, IndexSource::Idx1), + (true, true, false, IndexSource::Idx1), + (true, false, true, IndexSource::Idx1), + (false, false, false, IndexSource::Scan), + (false, false, true, IndexSource::Scan), + ] { + let file = avi(idx1, absolute, rec); + let f = open(&file).unwrap(); + let what = format!("idx1 {idx1} absolute {absolute} rec {rec}"); + assert_eq!(f.index, want, "{what}"); + assert_eq!((f.header.width, f.header.height, f.header.total_frames, f.header.micro_sec_per_frame), (64, 48, 3, 40_000)); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, v.handler, v.scale, v.rate, v.name.as_deref()), (StreamKind::Video, *b"MJPG", 1, 25, Some("Camera"))); + assert_eq!(v.video.as_ref().map(|b| (b.width, b.height, b.bit_count, b.compression)), Some((64, 48, 24, *b"MJPG"))); + let w = a.audio.as_ref().unwrap(); + assert_eq!((w.format_tag, w.channels, w.sample_rate, w.block_align, w.bits_per_sample, a.sample_size), (1, 2, 48_000, 4, 16, 4)); + // the index knows the delta frame; a scan cannot + let keys = if idx1 { [true, false, true] } else { [true; 3] }; + assert_eq!(table(&f, 0), vec![(10, keys[0]), (6, keys[1]), (7, keys[2])], "{what}"); + assert_eq!(f.streams[0].keyframes_known, idx1); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 1).unwrap(), vec![3; 6], "{what}"); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7], "{what}"); + assert_eq!(f.read_chunk(&file, 1, 1).unwrap(), vec![4; 8], "{what}"); + assert!(f.read_chunk(&file, 0, 3).is_err() && f.read_chunk(&file, 5, 0).is_err()); + } + // the idx1 path can be skipped + let f = open_with(&avi(true, false, false), OpenOptions { ignore_idx1: true, ..Default::default() }).unwrap(); + assert_eq!(f.index, IndexSource::Scan); +} + +#[test] +fn not_avi_and_truncated_recordings() { + assert_eq!(open(&b"RIFF\0\0\0\0WAVEfmt ".to_vec()), Err(Error::NotAvi)); + assert_eq!(open(&Vec::new()), Err(Error::NotAvi)); + assert!(sniff(&avi(true, false, false)) && !sniff(b"RIFF")); + // a recording cut off in its last frame: the headers and the whole frames are there + let full = avi(false, false, false); + let cut = full[..full.len() - 4].to_vec(); + let f = open(&cut).unwrap(); + assert_eq!(f.streams[0].chunks.len(), 3); + assert!(f.read_chunk(&cut, 0, 0).is_ok()); +} + +/// Every single-byte change and every truncation of a valid file opens or fails, never panics, and +/// whatever opens reads its chunks without panicking. +#[test] +fn mutations_never_panic() { + for base in [avi(true, false, true), avi(false, false, false), avi(true, true, false)] { + for cut in 0..base.len() { + let b = base[..cut].to_vec(); + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + for at in 0..base.len() { + for v in [0x00u8, 0xFF, 0x7F, 0x80] { + let mut b = base.clone(); + b[at] = v; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for i in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, i); + } + } + } + } + } + } +} + +/// An OpenDML file: each stream's `indx` super index points at an `ix##` standard index (base +/// offset + offsets to the data, bit 31 of the size marking delta frames). Built twice: the second +/// pass knows where the `ix##` chunks landed. +fn avi_odml(ix_at: [u64; 2]) -> (Vec, [u64; 2]) { + let indx = |stream: u8, at: u64| { + let mut b = Vec::new(); + b.extend_from_slice(&4u16.to_le_bytes()); // wLongsPerEntry + b.extend_from_slice(&[0, 0]); // subtype, AVI_INDEX_OF_INDEXES + b.extend_from_slice(&1u32.to_le_bytes()); + b.extend_from_slice(&[b'0', b'0' + stream, if stream == 0 { b'd' } else { b'w' }, if stream == 0 { b'c' } else { b'b' }]); + b.extend_from_slice(&[0; 12]); + b.extend_from_slice(&at.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + chunk(b"indx", &b) + }; + let mut hdrl = chunk(b"avih", &[0; 56]); + for (s, at) in ix_at.iter().enumerate() { + let l = strl(s == 0); + // strl = LIST strl …: append indx inside it + let mut body = l[12..].to_vec(); + body.extend(indx(s as u8, *at)); + hdrl.extend(list(b"strl", &body)); + } + let mut riff = b"AVI ".to_vec(); + riff.extend(list(b"hdrl", &hdrl)); + // movi: video 10 (key) 6 (delta) 7 (key), audio 16 8; then ix00, ix01 + let movi_data_at = 8 + riff.len() as u64 + 12; // RIFF hdr, so far, LIST hdr + `movi` + let mut body = Vec::new(); + let mut at: [Vec<(u64, u32, bool)>; 2] = [Vec::new(), Vec::new()]; + for (s, data, key) in [(0usize, vec![1u8; 10], true), (1, vec![2; 16], true), (0, vec![3; 6], false), (1, vec![4; 8], true), (0, vec![5; 7], true)] { + let id = if s == 0 { b"00dc" } else { b"01wb" }; + at[s].push((movi_data_at + body.len() as u64 + 8, data.len() as u32, key)); + body.extend(chunk(id, &data)); + } + let mut where_ix = [0u64; 2]; + for (s, entries) in at.iter().enumerate() { + let base = 100u64; // offsets are relative to qwBaseOffset + let mut b = Vec::new(); + b.extend_from_slice(&2u16.to_le_bytes()); + b.extend_from_slice(&[0, 1]); // subtype 0, AVI_INDEX_OF_CHUNKS + b.extend_from_slice(&(entries.len() as u32).to_le_bytes()); + b.extend_from_slice(if s == 0 { b"00dc" } else { b"01wb" }); + b.extend_from_slice(&base.to_le_bytes()); + b.extend_from_slice(&0u32.to_le_bytes()); + for &(off, size, key) in entries { + b.extend_from_slice(&((off - base) as u32).to_le_bytes()); + b.extend_from_slice(&(size | if key { 0 } else { 0x8000_0000 }).to_le_bytes()); + } + where_ix[s] = movi_data_at + body.len() as u64; + body.extend(chunk(if s == 0 { b"ix00" } else { b"ix01" }, &b)); + } + riff.extend(list(b"movi", &body)); + (chunk(b"RIFF", &riff), where_ix) +} + +#[test] +fn opendml_standard_indexes() { + let (_, at) = avi_odml([0, 0]); + let (file, again) = avi_odml(at); + assert_eq!(at, again, "the layout does not depend on the offsets"); + let f = open(&file).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(table(&f, 0), vec![(10, true), (6, false), (7, true)]); + assert_eq!(table(&f, 1), vec![(16, true), (8, true)]); + assert_eq!(f.read_chunk(&file, 0, 2).unwrap(), vec![5; 7]); + // ignoring the OpenDML index, the scan finds the same chunks (and skips the ix## chunks) + let s = open_with(&file, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(s.index, IndexSource::Scan); + assert_eq!(s.streams[0].chunks.iter().map(|c| c.offset).collect::>(), f.streams[0].chunks.iter().map(|c| c.offset).collect::>()); + // a broken super index (pointing nowhere) falls back to a scan + let (bad, _) = avi_odml([3, 5]); + assert_eq!(open(&bad).unwrap().index, IndexSource::Scan); + // and mutations of the OpenDML file never panic + for i in 0..file.len() { + let mut b = file.clone(); + b[i] ^= 0xFF; + if let Ok(f) = open(&b) { + for (s, st) in f.streams.iter().enumerate() { + for k in 0..st.chunks.len() { + let _ = f.read_chunk(&b, s, k); + } + } + } + } +} diff --git a/crates/avi/tests/ffmpeg_oracle.rs b/crates/avi/tests/ffmpeg_oracle.rs new file mode 100644 index 00000000..7f1b1662 --- /dev/null +++ b/crates/avi/tests/ffmpeg_oracle.rs @@ -0,0 +1,151 @@ +//! ffmpeg-made AVI files (skipped without ffmpeg): the chunk tables match ffprobe's packet counts, +//! and every index the file has (`idx1`; OpenDML once a file passes 1 GB) and a `movi` scan describe +//! the same chunks. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use filmcraft_avi::{IndexSource, OpenOptions, StreamKind, open, open_with}; + +fn fixture(name: &str, args: &[&str]) -> Option { + let ffmpeg = filmcraft_testkit::ffmpeg_or_skip("avi oracle")?; + let out = filmcraft_testkit::fixtures_dir("avi").join(name); + filmcraft_testkit::fixtures::generate(&out, |tmp: &Path| { + let mut c = Command::new(&ffmpeg); + c.args(["-v", "error", "-y"]).args(args).args(["-f", "avi"]).arg(tmp); + c.status().is_ok_and(|s| s.success()) + }) +} + +fn packets(path: &Path, stream: usize) -> usize { + let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-count_packets", "-show_entries", "stream=nb_read_packets", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).trim().parse().unwrap_or(0) +} + +fn packet_bytes(path: &Path, stream: usize) -> u64 { + let ffprobe = filmcraft_testkit::ffprobe().expect("ffprobe next to ffmpeg"); + let out = Command::new(ffprobe) + .args(["-v", "error", "-select_streams", &stream.to_string(), "-show_entries", "packet=size", "-of", "csv=p=0"]) + .arg(path) + .output() + .unwrap(); + String::from_utf8_lossy(&out.stdout).lines().filter_map(|l| l.trim().parse::().ok()).sum() +} + +const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"]; + +fn cases() -> Vec<(&'static str, Vec<&'static str>, [u8; 4])> { + let pic = vec!["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"]; + let with = |v: &[&'static str], a: &[&'static str]| -> Vec<&'static str> { + let mut x = pic.clone(); + x.extend_from_slice(&TONE); + x.extend_from_slice(v); + x.extend_from_slice(a); + x + }; + vec![ + ("mjpeg_pcm.avi", with(&["-c:v", "mjpeg", "-q:v", "4", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]), *b"MJPG"), + ("h264_mp3.avi", with(&["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]), *b"H264"), + ("bgr24_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "bgr24"], &["-c:a", "pcm_s16le"]), [0; 4]), + ("yuyv_pcm.avi", with(&["-c:v", "rawvideo", "-pix_fmt", "yuyv422"], &["-c:a", "pcm_s16le"]), *b"YUY2"), + ] +} + +#[test] +fn ffmpeg_files_match_ffprobe_on_every_index_path() { + for (name, args, fourcc) in cases() { + let Some(path) = fixture(name, &args) else { return }; + let bytes = std::fs::read(&path).unwrap(); + let f = open(&bytes).unwrap(); + // ffmpeg reserves room for the OpenDML index but writes only idx1 under 1 GB + assert_eq!(f.index, IndexSource::Idx1, "{name}"); + assert_eq!(f.streams.len(), 2, "{name}"); + let (v, a) = (&f.streams[0], &f.streams[1]); + assert_eq!((v.kind, a.kind), (StreamKind::Video, StreamKind::Audio), "{name}"); + let bmp = v.video.as_ref().unwrap(); + assert_eq!((bmp.width, bmp.height.abs(), bmp.compression), (160, 120, fourcc), "{name}"); + assert_eq!((v.rate as f64 / v.scale as f64).round(), 25.0, "{name}"); + assert_eq!(a.audio.as_ref().map(|w| (w.sample_rate, w.channels)), Some((48_000, 1)), "{name}"); + // the tables hold what ffprobe reads + assert_eq!(v.chunks.len(), packets(&path, 0), "{name}: video"); + // PCM chunks are packets; ffprobe re-splits MP3 into its frames, which AVI chunks need not + // follow, so compressed audio compares by bytes + if a.audio.as_ref().is_some_and(|w| w.format_tag == 1) { + assert_eq!(a.chunks.len(), packets(&path, 1), "{name}: audio"); + } else { + let ours: u64 = a.chunks.iter().map(|c| u64::from(c.size)).sum(); + assert_eq!(ours, packet_bytes(&path, 1), "{name}: audio bytes"); + } + assert!(v.chunks[0].key, "{name}: the first frame is a key frame"); + if fourcc == *b"H264" { + assert!(v.chunks.iter().any(|c| !c.key), "{name}: the index marks the delta frames"); + } + // a scan finds the same chunks (it cannot know key frames) + let scanned = open_with(&bytes, OpenOptions { ignore_odml: true, ignore_idx1: true }).unwrap(); + assert_eq!(scanned.index, IndexSource::Scan, "{name}"); + for s in 0..2 { + assert_eq!(sizes(&scanned, s), sizes(&f, s), "{name}: scan stream {s}"); + } + } +} + +fn sizes(f: &filmcraft_avi::AviFile, s: usize) -> Vec<(u64, u32)> { + f.streams[s].chunks.iter().map(|c| (c.offset, c.size)).collect() +} + +/// A file past 1 GB, as long recordings are: ffmpeg writes `RIFF AVIX` extensions and the +/// OpenDML index. Needs ffmpeg and 1.2 GB of disk: `cargo test -p filmcraft-avi --test +/// ffmpeg_oracle -- --ignored`. +#[test] +#[ignore] +fn opendml_file_past_1_gb() { + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=s=640x480:r=25:d=50", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:d=50", + "-c:v", + "rawvideo", + "-pix_fmt", + "bgr24", + "-c:a", + "pcm_s16le", + ]; + let Some(path) = fixture("large_opendml.avi", &args) else { return }; + let file = std::fs::File::open(&path).unwrap(); + let src = FileSource(file); + let f = open(&src).unwrap(); + assert_eq!(f.index, IndexSource::OpenDml); + assert_eq!(f.streams[0].chunks.len(), packets(&path, 0)); + assert_eq!(f.streams[1].chunks.len(), packets(&path, 1)); + assert_eq!(f.streams[0].chunks.len(), 1250); + assert!(f.streams[0].chunks.last().unwrap().offset > 1 << 30, "the last frames are in an AVIX extension"); + // idx1 covers the first RIFF only; the AVIX extensions are scanned: the same chunks + let idx1 = open_with(&src, OpenOptions { ignore_odml: true, ..Default::default() }).unwrap(); + assert_eq!(idx1.index, IndexSource::Idx1); + for s in 0..2 { + assert_eq!(sizes(&idx1, s), sizes(&f, s), "stream {s}"); + } + assert_eq!(f.read_chunk(&src, 0, 1249).unwrap().len(), 640 * 480 * 3); +} + +/// A file on disk as a byte source, read where asked. +struct FileSource(std::fs::File); + +impl filmcraft_avi::ByteSource for FileSource { + fn len(&self) -> u64 { + self.0.metadata().map(|m| m.len()).unwrap_or(0) + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + std::os::unix::fs::FileExt::read_exact_at(&self.0, buf, offset) + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 631678d8..e44a843e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -29,6 +29,7 @@ const LAYERS: &[(&str, u8)] = &[ ("color", 0), ("bitstream", 0), ("isobmff", 0), + ("avi", 0), ("matroska", 0), ("mxf", 0), ("cfb", 0), From 0797ab4ed9d076b56a212a809076ef6d9ce659d9 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:21:30 +0700 Subject: [PATCH 013/203] #598: import AVI files (Motion JPEG, H.264, HEVC, uncompressed; PCM, MP3, MP2, AC-3) AviSource maps AVI streams onto the decoders, opened from bytes or a streaming reader (Media Browser probe): - Video: Motion JPEG, H.264 and HEVC (Annex B, our decoders) and uncompressed BGR24 / BGRX32 / YUY2 / UYVY / I420 / YV12 / NV12 / grey (new RawVideoDecoder). Empty "drop frame" chunks hold the picture before. AVI has no timestamps: H.264 with B-frames is stored in decode order, so the presentation order comes from the pictures' order counts (mxf::avc_presentation_order), keeping the slots the pictures occupy. A writer that kept a fine time base (ffmpeg copying an MP4's 1/600) fills the slots between pictures with empty chunks; the reported frame rate is the one the pictures play at. DivX / Xvid and DV are named and report that they have no decoder; their sound plays. - Audio: PCM (integer and float) from the chunks; MPEG audio and AC-3 frames, which AVI chunks need not follow, found again across chunk boundaries with the new filmcraft_mpegts::frame_bytes. - Motion JPEG frames without Huffman tables (the AVI1 convention) decode with the standard ITU-T T.81 Annex K tables (also for MOV). Tests against ffmpeg's decode of the same files: H.264 with B-frames bit-exact under random access, also copied from a 1/600 MP4; uncompressed video and PCM bit-exact; JPEG and MP3 / MP2 / AC-3 within decoder tolerance; drop frames; DivX; JPEG without tables equal to the same frame with them. Engine: an AVI imports, makes a sequence, plays picture and sound, and probes without reading it whole. Co-Authored-By: Claude Opus 5.5 --- README.md | 3 +- crates/codecs/Cargo.toml | 1 + crates/codecs/src/avi.rs | 585 ++++++++++++++++++++++++++ crates/codecs/src/avi_tests.rs | 232 ++++++++++ crates/codecs/src/lib.rs | 29 +- crates/codecs/src/tests.rs | 8 +- crates/codecs/src/video.rs | 220 +++++++++- crates/engine/src/avi_import_tests.rs | 58 +++ crates/engine/src/lib.rs | 2 + crates/engine/src/media_pool.rs | 2 +- crates/mpegts/src/es.rs | 14 + crates/mpegts/src/lib.rs | 2 +- 12 files changed, 1146 insertions(+), 10 deletions(-) create mode 100644 crates/codecs/src/avi.rs create mode 100644 crates/codecs/src/avi_tests.rs create mode 100644 crates/engine/src/avi_import_tests.rs diff --git a/README.md b/README.md index fc1acfeb..80b79b0b 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **MP3, FLAC, ALAC, Vorbis** | ✓ | | Via the [symphonia](https://github.com/pdeljanov/Symphonia) crate (MPL-2.0) for now, to be replaced by our own | | **MP4 / MOV** | ✓ | ✓ | Fragmented MP4, edit lists, timecode tracks | | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | +| **AVI** | ✓ | | AVI 1.0 and OpenDML (files past 1 GB); Motion JPEG, H.264, HEVC and uncompressed RGB / YUV with PCM, MP3, MP2 or AC-3. DivX / Xvid and DV not yet | | **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF | Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). @@ -301,7 +302,7 @@ A layered Cargo workspace: | Layer | Crates | |---|---| -| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `bitstream` | +| Codecs and containers | `h264`, `h264enc`, `hevc`, `vp9`, `prores`, `aac`, `opus`, `isobmff`, `matroska`, `avi`, `bitstream` | | Foundations | `time`, `geom`, `color`, `frame`, `media`, `text`, `project`, `audio-dsp` | | Editing and interchange | `edit`, `codecs`, `captions`, `format` (project files), `interchange` | | Rendering and output | `render`, `gpu`, `export` | diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c..3faf97da 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -14,6 +14,7 @@ filmcraft-time = { workspace = true } filmcraft-color = { workspace = true } filmcraft-frame = { workspace = true } filmcraft-media = { workspace = true } +filmcraft-avi = { workspace = true } filmcraft-isobmff = { workspace = true } filmcraft-matroska = { workspace = true } filmcraft-mxf = { workspace = true } diff --git a/crates/codecs/src/avi.rs b/crates/codecs/src/avi.rs new file mode 100644 index 00000000..65952442 --- /dev/null +++ b/crates/codecs/src/avi.rs @@ -0,0 +1,585 @@ +//! AVI media source (`filmcraft-avi` demux, GOP-aware video via [`crate::gop`]). +//! +//! Video: Motion JPEG (frames without their own Huffman tables get the standard ones), H.264 and +//! HEVC (Annex B byte streams, our decoders), and uncompressed RGB / YUV ([`RawFormat`]). A frame +//! slot with an empty chunk (a "drop frame") repeats the picture before it. MPEG-4 Part 2 +//! (DivX / Xvid) and DV have no decoder yet: their streams are listed and report the codec. +//! +//! Audio: PCM (integer and float) straight from the chunks; MPEG audio (MP3, MP2) and AC-3 frames, +//! which AVI chunks need not follow, are found again across chunk boundaries +//! ([`filmcraft_mpegts::frame_bytes`]) and decoded as packets. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use filmcraft_avi::{AviFile, BitmapInfo, StreamKind, WaveFormat}; +use filmcraft_frame::{AudioBuffer, VideoFrame}; +use filmcraft_isobmff::{PcmConfig, SampleEntry}; +use filmcraft_media::{AudioStreamInfo, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, SharedSource, VideoStreamInfo}; +use filmcraft_time::{FrameRate, Tick}; + +use crate::audio::{PacketDecoder, decode_pcm}; +use crate::gop::{GopCache, VideoSamples}; +use crate::video::{RawFormat, RawVideoDecoder, VideoDecoder}; +use crate::{CodecError, make_video_decoder}; + +pub fn sniff(b: &[u8]) -> bool { + filmcraft_avi::sniff(b) +} + +#[derive(Clone, Debug, PartialEq)] +enum VCodec { + Jpeg, + H264, + Hevc, + Raw(RawFormat), + /// No decoder: the codec's name. + Unsupported(String), +} + +fn fourcc_text(f: [u8; 4]) -> String { + let t: String = f.iter().map(|&c| if c.is_ascii_graphic() { c as char } else { '?' }).collect(); + t.trim_end_matches('?').to_string() +} + +/// The decoder for a video stream and its label. +fn video_codec(b: &BitmapInfo) -> (VCodec, String) { + if let Some(raw) = RawFormat::from_avi(b.compression, b.bit_count) { + return (VCodec::Raw(raw), raw.label().into()); + } + let mut f = b.compression; + f.make_ascii_uppercase(); + match &f { + b"MJPG" | b"AVRN" | b"LJPG" | b"JPGL" | b"DMB1" | b"JPEG" | b"MJPA" => (VCodec::Jpeg, "Motion JPEG".into()), + b"H264" | b"X264" | b"AVC1" | b"DAVC" | b"VSSH" => (VCodec::H264, "H.264".into()), + b"HEVC" | b"H265" | b"HEV1" | b"HVC1" | b"X265" => (VCodec::Hevc, "HEVC".into()), + b"DIVX" | b"DX50" | b"XVID" | b"FMP4" | b"MP4V" | b"M4S2" | b"3IV2" | b"DIV3" | b"MP43" | b"MP42" => { + (VCodec::Unsupported("MPEG-4 Part 2 (DivX / Xvid)".into()), "MPEG-4 Part 2 (DivX / Xvid)".into()) + } + b"DVSD" | b"DV25" | b"DV50" | b"DVHD" | b"DVSL" | b"CDVC" | b"DVCP" | b"DVPP" => (VCodec::Unsupported("DV".into()), "DV".into()), + _ => { + let name = fourcc_text(b.compression); + (VCodec::Unsupported(format!("\"{name}\"")), name) + } + } +} + +/// One picture of the video stream: its frame slot (in `scale / rate` units) and chunk. +#[derive(Clone, Copy, Debug)] +struct VFrame { + pts: i64, + chunk: usize, + key: bool, +} + +#[derive(Clone, Debug)] +enum ACodec { + Pcm(PcmConfig), + /// MPEG audio (layer 1-3) or AC-3 frames, found again across chunks. + Framed { + ts: filmcraft_mpegts::Codec, + layer: u8, + }, + Unsupported(String), +} + +fn audio_codec(w: &WaveFormat) -> (ACodec, String) { + let pcm = |float: bool| { + let bits = w.bits_per_sample; + let cfg = PcmConfig { bits, float, big_endian: false, signed: bits > 8, channels: u32::from(w.channels.max(1)), sample_rate: f64::from(w.sample_rate) }; + (ACodec::Pcm(cfg), format!("PCM {bits}-bit{}", if float { " float" } else { "" })) + }; + match w.format_tag { + 1 if matches!(w.bits_per_sample, 8 | 16 | 24 | 32) => pcm(false), + 3 if matches!(w.bits_per_sample, 32 | 64) => pcm(true), + 0x55 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::MpegAudio, layer: 3 }, "MP3".into()), + 0x50 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::MpegAudio, layer: 2 }, "MPEG Audio".into()), + 0x2000 => (ACodec::Framed { ts: filmcraft_mpegts::Codec::Ac3, layer: 0 }, "AC-3".into()), + tag => { + let name = format!("audio format 0x{tag:04X}"); + (ACodec::Unsupported(name.clone()), name) + } + } +} + +/// A slice of a chunk: (chunk, offset in it, length). +type Piece = (usize, usize, usize); + +struct AudioState { + decoder: Option, + packets: HashMap>>>, + order: Vec, + last_decoded: Option, +} + +struct AviAudio { + stream: usize, + codec: ACodec, + /// The pieces of packet `k` are `pieces[first[k]..first[k + 1]]`. + pieces: Vec, + first: Vec, + /// Packet start positions in sample frames. + starts: Vec, + state: Mutex, +} + +pub struct AviSource { + info: MediaInfo, + bytes: crate::Src, + file: AviFile, + vstream: Option, + vcodec: VCodec, + /// Pictures in stored (decode) order. + vframes: Vec, + /// `vframes` indices in presentation order (H.264 with B-frames is stored in decode order), + /// and their presentation times. + vorder: Vec, + vorder_pts: Vec, + /// Frame slots of the video stream (where the last picture ends). + vslots: i64, + rate: FrameRate, + bottom_up: bool, + video: GopCache, + audios: Vec, +} + +/// Whether an Annex B access unit holds an H.264 IDR or HEVC IRAP picture. +fn random_access(data: &[u8], hevc: bool) -> bool { + filmcraft_bitstream::annexb_nals(data).iter().any(|n| match n.first() { + Some(h) if hevc => (16..=23).contains(&((h >> 1) & 0x3F)), + Some(h) => h & 0x1F == 5, + None => false, + }) +} + +/// The packets of a framed audio stream: every frame header found in the stream of chunks, and +/// the samples each frame decodes to. Bytes that start no frame are skipped (resynchronisation). +fn split_frames( + file: &AviFile, + bytes: &crate::Src, + s: usize, + ts: &filmcraft_mpegts::Codec, +) -> (Vec, Vec, Vec, Option) { + let Some(st) = file.streams.get(s) else { return Default::default() }; + // chunk start positions in the concatenated stream + let mut at = Vec::with_capacity(st.chunks.len()); + let mut total = 0usize; + for c in &st.chunks { + at.push(total); + total = total.saturating_add(c.size as usize); + } + let locate = |pos: usize| -> Option<(usize, usize)> { + let k = at.partition_point(|&a| a <= pos).checked_sub(1)?; + Some((k, pos - at.get(k)?)) + }; + let (mut pieces, mut first, mut samples) = (Vec::new(), Vec::new(), Vec::new()); + let mut info = None; + // a sliding window over the chunks: `win` holds the stream from `base` + let (mut win, mut base, mut next) = (Vec::::new(), 0usize, 0usize); + let mut pos = 0usize; + while pos < total { + // keep at least a header's worth (and the frame, when we know its length) in the window + while win.len() < (pos - base) + 4096 && next < st.chunks.len() { + win.extend(file.read_chunk(&bytes, s, next).unwrap_or_default()); + next += 1; + } + let Some(here) = win.get(pos - base..) else { break }; + match filmcraft_mpegts::frame_bytes(ts, here) { + Some(n) if pos + n <= total => { + if info.is_none() { + info = filmcraft_mpegts::frame_info(ts, here); + } + let k = filmcraft_mpegts::frame_info(ts, here).map_or(0, |f| f.samples); + first.push(pieces.len()); + let mut p = pos; + while p < pos + n { + let Some((c, off)) = locate(p) else { break }; + let len = (st.chunks.get(c).map_or(0, |ch| ch.size as usize) - off).min(pos + n - p); + if len == 0 { + break; + } + pieces.push((c, off, len)); + p += len; + } + samples.push(k); + pos += n; + } + _ if here.len() < 8 && next >= st.chunks.len() => break, + _ => pos += 1, + } + // drop what is behind + if pos - base > 1 << 16 { + let cut = pos - base; + win.drain(..cut.min(win.len())); + base += cut; + } + } + (pieces, first, samples, info) +} + +impl AviSource { + pub fn open(name: &str, bytes: Arc<[u8]>) -> crate::Result { + Self::open_reader(name, Arc::new(filmcraft_media::reader::MemReader(bytes))) + } + + /// Open from a random-access reader: the index now, frames and audio on demand. + pub fn open_reader(name: &str, reader: filmcraft_media::SharedReader) -> crate::Result { + let bytes = crate::Src(reader); + let file = filmcraft_avi::open(&bytes).map_err(|e| CodecError::Container(e.to_string()))?; + let vstream = file.streams.iter().position(|s| s.kind == StreamKind::Video && s.video.is_some() && !s.chunks.is_empty()); + let astreams: Vec = file + .streams + .iter() + .enumerate() + .filter(|(_, s)| s.kind == StreamKind::Audio && s.audio.is_some() && !s.chunks.is_empty()) + .map(|(i, _)| i) + .take(filmcraft_media::MAX_AUDIO_STREAMS) + .collect(); + if vstream.is_none() && astreams.is_empty() { + return Err(CodecError::Unsupported("no playable streams".into())); + } + // video: frame slots, pictures and key frames + let (mut vcodec, mut vframes, mut vslots, mut rate, mut bottom_up, mut video) = + (VCodec::Unsupported(String::new()), Vec::new(), 0i64, FrameRate::new(25, 1), false, None); + if let Some(vs) = vstream.and_then(|i| file.streams.get(i)) { + let b = vs.video.clone().unwrap_or_default(); + let (codec, label) = video_codec(&b); + rate = FrameRate::new(i64::from(vs.rate), i64::from(vs.scale)); + bottom_up = b.height > 0; + let (w, h) = (b.width.unsigned_abs(), b.height.unsigned_abs()); + for (k, c) in vs.chunks.iter().enumerate() { + if c.size == 0 { + continue; + } + let key = match &codec { + VCodec::H264 | VCodec::Hevc if !vs.keyframes_known => { + file.read_chunk(&bytes, vstream.unwrap_or(0), k).is_ok_and(|d| random_access(&d, codec == VCodec::Hevc)) + } + VCodec::H264 | VCodec::Hevc => c.key, + _ => true, + }; + vframes.push(VFrame { pts: i64::from(vs.start) + k as i64, chunk: k, key }); + } + vslots = i64::from(vs.start) + vs.chunks.len() as i64; + // AVI has no timestamps: H.264 with B-frames is stored in decode order, and the + // presentation order comes from the pictures' order counts. A first look at the start + // of the stream decides whether it reorders at all (reading every frame's header is the + // cost of a reordering stream only). + if codec == VCodec::H264 + && let Some(si) = vstream + { + let heads = |n: usize| -> Vec> { + vframes + .iter() + .take(n) + .map(|f| { + file.streams + .get(si) + .and_then(|s| s.chunks.get(f.chunk)) + .and_then(|c| filmcraft_media::reader::read_range(&*bytes.0, c.offset, (c.size as usize).min(8 << 10)).ok()) + .unwrap_or_default() + }) + .collect() + }; + let reorders = |p: &[i64]| p.windows(2).any(|w| w[1] < w[0]); + if crate::mxf::avc_presentation_order(&heads(300)).is_some_and(|p| reorders(&p)) + && let Some(ranks) = crate::mxf::avc_presentation_order(&heads(vframes.len())) + { + // the pictures keep the slots they occupy (empty drop-frame chunks between them + // stay), shown in their presentation order + let mut slots: Vec = vframes.iter().map(|f| f.pts).collect(); + slots.sort_unstable(); + for (f, r) in vframes.iter_mut().zip(ranks) { + if let Some(&p) = usize::try_from(r).ok().and_then(|r| slots.get(r)) { + f.pts = p; + } + } + } + } + let secs = vslots as f64 / rate.as_f64().max(1e-9); + // The rate the pictures play at: a writer that kept a fine time base (ffmpeg copying an + // MP4's 1/600) fills the slots between pictures with empty chunks, every 20th slot + // holding a picture at 30 fps. A regular spacing makes the rate; slots stay the units. + let shown = if vframes.len() > 1 { + let mut gaps: Vec = vframes.windows(2).map(|w| w[1].chunk as i64 - w[0].chunk as i64).filter(|g| *g > 0).take(1000).collect(); + gaps.sort_unstable(); + let gap = gaps.get(gaps.len() / 2).copied().unwrap_or(1).max(1); + let regular = gaps.iter().filter(|g| **g == gap).count() * 10 >= gaps.len() * 9; + if gap > 1 && regular { FrameRate::new(i64::from(vs.rate), i64::from(vs.scale).saturating_mul(gap)) } else { rate } + } else { + rate + }; + let bitrate = (secs > 0.0).then(|| (vs.chunks.iter().map(|c| u64::from(c.size)).sum::() as f64 * 8.0 / secs) as u64); + video = Some(VideoStreamInfo { + width: w, + height: h, + frame_rate: shown, + par: (1, 1), + codec: label, + pixel_format: String::new(), + color: crate::stream_color::resolve(w, h, &[]), + has_alpha: false, + bitrate, + hdr: None, + }); + vcodec = codec; + } + // audio: packets and their start positions + let mut audios = Vec::new(); + let mut audio_streams = Vec::new(); + for &s in &astreams { + let Some(st) = file.streams.get(s) else { continue }; + let w = st.audio.clone().unwrap_or_default(); + let (codec, label) = audio_codec(&w); + let mut sample_rate = w.sample_rate.max(1); + let mut channels = u32::from(w.channels.max(1)); + let (pieces, first, starts) = match &codec { + ACodec::Pcm(cfg) => { + let block = usize::from(w.block_align.max(1)).max((usize::from(cfg.bits).div_ceil(8)) * channels as usize); + let mut t = 0i64; + let mut starts = Vec::with_capacity(st.chunks.len()); + for c in &st.chunks { + starts.push(t); + t = t.saturating_add((c.size as usize / block) as i64); + } + let pieces: Vec = st.chunks.iter().enumerate().map(|(k, c)| (k, 0, c.size as usize)).collect(); + let first = (0..pieces.len()).collect(); + (pieces, first, starts) + } + ACodec::Framed { ts, .. } => { + let (pieces, first, samples, info) = split_frames(&file, &bytes, s, ts); + if let Some(i) = info { + sample_rate = i.sample_rate.max(1); + channels = i.channels.max(1); + } + let mut t = 0i64; + let starts = samples + .iter() + .map(|&k| { + let s = t; + t = t.saturating_add(i64::from(k)); + s + }) + .collect(); + (pieces, first, starts) + } + ACodec::Unsupported(_) => (Vec::new(), Vec::new(), Vec::new()), + }; + // a stream that starts late (dwStart, in scale / rate units) + let delay = (i128::from(st.start) * i128::from(st.scale) * i128::from(sample_rate) / i128::from(st.rate.max(1))) as i64; + let starts: Vec = starts.into_iter().map(|s: i64| s.saturating_add(delay)).collect(); + audio_streams.push(AudioStreamInfo { + sample_rate, + channels, + codec: label, + bits_per_sample: (w.bits_per_sample > 0).then_some(u32::from(w.bits_per_sample)), + }); + let state = Mutex::new(AudioState { decoder: None, packets: HashMap::new(), order: Vec::new(), last_decoded: None }); + audios.push(AviAudio { stream: s, codec, pieces, first, starts, state }); + } + // the longer of the picture and the sound + let vdur = if vslots > 0 { rate.tick_of(vslots) } else { Tick::ZERO }; + let adur = audios + .iter() + .zip(&audio_streams) + .filter_map(|(a, i)| { + let last = a.starts.last()?; + let tail = a.starts.len().checked_sub(2).and_then(|k| a.starts.get(k)).map_or(0, |p| last - p); + Some(Tick::from_rational(last + tail, 1, i64::from(i.sample_rate))) + }) + .max() + .unwrap_or_default(); + let info = MediaInfo { + name: name.to_string(), + kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly }, + duration: vdur.max(adur), + video, + audio_streams, + container: "AVI".into(), + start_timecode: None, + file_size: Some(bytes.0.len()), + }; + let mut vorder: Vec = (0..vframes.len()).collect(); + vorder.sort_by_key(|&i| vframes.get(i).map_or(0, |f| f.pts)); + let vorder_pts = vorder.iter().map(|&i| vframes.get(i).map_or(0, |f| f.pts)).collect(); + Ok(Self { info, bytes, file, vstream, vcodec, vframes, vorder, vorder_pts, vslots, rate, bottom_up, video: GopCache::new(None), audios }) + } + + fn read(&self, stream: usize, chunk: usize) -> crate::Result> { + self.file.read_chunk(&self.bytes, stream, chunk).map_err(|e| CodecError::Container(e.to_string())) + } + + /// The bytes of audio packet `k`. + fn packet(&self, a: &AviAudio, k: usize) -> crate::Result> { + let (from, to) = ( + a.first.get(k).copied().ok_or_else(|| CodecError::Container("audio packet out of range".into()))?, + a.first.get(k + 1).copied().unwrap_or(a.pieces.len()), + ); + let mut out = Vec::new(); + for &(c, off, len) in a.pieces.get(from..to).unwrap_or_default() { + let chunk = self.read(a.stream, c)?; + out.extend_from_slice(chunk.get(off..off + len).ok_or_else(|| CodecError::Container("audio chunk shorter than its index".into()))?); + } + Ok(out) + } + + fn audio_packet(&self, a: &AviAudio, st: &mut AudioState, ainfo: &AudioStreamInfo, i: usize) -> crate::Result>>> { + if let Some(p) = st.packets.get(&i) { + return Ok(p.clone()); + } + let data = self.packet(a, i)?; + let decoded = match &a.codec { + ACodec::Pcm(cfg) => decode_pcm(&data, cfg), + ACodec::Unsupported(name) => return Err(CodecError::Unsupported(name.clone())), + ACodec::Framed { ts, layer } => { + if st.decoder.is_none() { + st.decoder = Some(match ts { + filmcraft_mpegts::Codec::Ac3 => PacketDecoder::ac3()?, + _ => PacketDecoder::mpeg_audio(*layer, ainfo.sample_rate)?, + }); + } + let d = st.decoder.as_mut().ok_or_else(|| CodecError::Decode("no audio decoder".into()))?; + // non-sequential access: reset and prime with the packet before (the bit reservoir) + if st.last_decoded.is_none_or(|l| l + 1 != i) { + d.reset(); + if let Some(prev) = i.checked_sub(1).and_then(|j| self.packet(a, j).ok()) { + let _ = d.decode(&prev, 0); + } + } + let start = a.starts.get(i).copied().unwrap_or(0).max(0) as u64; + let r = d.decode(&data, start); + st.last_decoded = Some(i); + r.unwrap_or_default() + } + }; + let p = Arc::new(decoded); + st.packets.insert(i, p.clone()); + st.order.push(i); + if st.order.len() > 4096 { + let old = st.order.remove(0); + st.packets.remove(&old); + } + Ok(p) + } +} + +/// The video stream as a [`VideoSamples`] table. +struct AviVideo<'a> { + src: &'a AviSource, + stream: usize, +} + +impl VideoSamples for AviVideo<'_> { + fn count(&self) -> usize { + self.src.vframes.len() + } + fn pts(&self, i: usize) -> i64 { + self.src.vframes.get(i).map_or(0, |f| f.pts) + } + fn sync_before(&self, i: usize) -> usize { + let i = i.min(self.src.vframes.len().saturating_sub(1)); + self.src.vframes.get(..=i).and_then(|f| f.iter().rposition(|f| f.key)).unwrap_or(0) + } + fn sample_at(&self, t: i64) -> Option { + // the picture shown in slot `t`: the last one at or before it (empty chunks repeat it) + if t >= self.src.vslots { + return None; + } + let k = self.src.vorder_pts.partition_point(|&p| p <= t).checked_sub(1)?; + self.src.vorder.get(k).copied() + } + fn read(&self, i: usize) -> crate::Result> { + let f = self.src.vframes.get(i).ok_or_else(|| CodecError::Container("frame out of range".into()))?; + self.src.read(self.stream, f.chunk) + } + fn make_decoder(&self) -> crate::Result> { + let v = self.src.info.video.as_ref().ok_or_else(|| CodecError::Unsupported("no video stream".into()))?; + let (w, h) = (v.width.min(u32::from(u16::MAX)) as u16, v.height.min(u32::from(u16::MAX)) as u16); + match &self.src.vcodec { + VCodec::Jpeg => make_video_decoder(&SampleEntry::jpeg(w, h)), + VCodec::H264 => Ok(Box::new(crate::video::H264Decoder::annexb())), + VCodec::Hevc => Ok(Box::new(crate::video::HevcDecoder::annexb())), + VCodec::Raw(f) => Ok(Box::new(RawVideoDecoder::new(*f, v.width, v.height, self.src.bottom_up)?)), + VCodec::Unsupported(name) => Err(CodecError::Unsupported(format!("no decoder for {name} video"))), + } + } +} + +impl MediaSource for AviSource { + fn info(&self) -> &MediaInfo { + &self.info + } + + fn video_frame(&self, req: FrameRequest) -> Result, MediaError> { + let s = self.vstream.ok_or(MediaError::NoStream("video"))?; + let t = req.time.max(Tick::ZERO); + let slot = |t: Tick| self.rate.frame_at(t); + let late = filmcraft_media::cancel::catch_up().map(|m| slot(t - m)); + Ok(self.video.frame_late(&AviVideo { src: self, stream: s }, slot(t), late)?) + } + + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result { + self.audio_stream(0, start, frames, sample_rate) + } + + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> Result { + let a = self.audios.get(stream).ok_or(MediaError::NoStream("audio"))?; + let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?; + if let ACodec::Unsupported(name) = &a.codec { + return Err(MediaError::Unsupported(format!("{name} audio"))); + } + let ch = ainfo.channels.max(1) as usize; + let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate.max(1)); + let s0 = (start as f64 * ratio).floor() as i64; + let need = (frames as f64 * ratio).ceil() as i64 + 2; + let mut src: Vec> = vec![vec![0.0; need.max(0) as usize]; ch]; + let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner()); + let mut i = a.starts.partition_point(|&x| x <= s0).saturating_sub(1); + while let Some(&pk_start) = a.starts.get(i) { + if pk_start >= s0 + need { + break; + } + let pk = self.audio_packet(a, &mut st, ainfo, i)?; + for (c, dst) in src.iter_mut().enumerate() { + let Some(chan) = pk.get(c.min(pk.len().saturating_sub(1))) else { continue }; + for (k, v) in chan.iter().enumerate() { + let pos = pk_start + k as i64 - s0; + if let Some(d) = usize::try_from(pos).ok().and_then(|p| dst.get_mut(p)) { + *d = *v; + } + } + } + i += 1; + } + drop(st); + let frac = s0 as f64 - start as f64 * ratio; + let mut out = AudioBuffer::silence(sample_rate, ch, frames); + for (c, o) in out.channels.iter_mut().enumerate() { + let Some(s) = src.get(c) else { continue }; + for (k, d) in o.iter_mut().enumerate() { + let pos = k as f64 * ratio - frac; + let i0 = pos.floor().max(0.0) as usize; + let f = (pos - i0 as f64) as f32; + let a = s.get(i0).copied().unwrap_or(0.0); + let b = s.get(i0 + 1).copied().unwrap_or(a); + *d = a + (b - a) * f; + } + } + Ok(out) + } +} + +pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option> { + if !sniff(&bytes) { + return None; + } + Some(AviSource::open(name, bytes).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) +} + +/// [`filmcraft_media::ReaderOpener`] for AVI. +pub fn reader_opener(name: &str, head: &[u8], reader: &filmcraft_media::SharedReader) -> Option> { + if !sniff(head) { + return None; + } + Some(AviSource::open_reader(name, reader.clone()).map(|s| Arc::new(s) as SharedSource).map_err(Into::into)) +} diff --git a/crates/codecs/src/avi_tests.rs b/crates/codecs/src/avi_tests.rs new file mode 100644 index 00000000..714eda19 --- /dev/null +++ b/crates/codecs/src/avi_tests.rs @@ -0,0 +1,232 @@ +//! AVI files made by ffmpeg (skipped without it), checked against ffmpeg's decode of the same file: +//! bit-exact where the decoder is (H.264, uncompressed video, PCM), within decoder tolerance where +//! it differs (JPEG, MP3 / MP2 / AC-3). + +use std::path::Path; +use std::process::Command; +use std::sync::Arc; + +use filmcraft_media::FrameRequest; + +use crate::tests::{ffmpeg, fixture_path, reference_yuv, yuv_bytes}; + +const PIC: [&str; 4] = ["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2"]; +const TONE: [&str; 4] = ["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:d=2"]; + +fn avi(name: &str, video: &[&str], audio: &[&str]) -> Option<(std::path::PathBuf, filmcraft_media::SharedSource)> { + let mut args: Vec<&str> = PIC.to_vec(); + args.extend_from_slice(&TONE); + args.extend_from_slice(video); + args.extend_from_slice(audio); + let path = fixture_path(name, &args)?; + let bytes: Arc<[u8]> = std::fs::read(&path).ok()?.into(); + Some((path.clone(), crate::open_bytes(name, bytes).unwrap_or_else(|e| panic!("{name}: {e}")))) +} + +/// ffmpeg's decode of the first audio stream as interleaved f32 at its own rate. +fn reference_audio(path: &Path) -> Vec { + let out = Command::new(ffmpeg().unwrap()) + .args(["-v", "error", "-i"]) + .arg(path) + .args(["-map", "0:a:0", "-f", "f32le", "-c:a", "pcm_f32le", "-"]) + .output() + .unwrap(); + out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// The first `n` mono samples of stream 0 as FilmCraft decodes them. +fn ours(src: &filmcraft_media::SharedSource, n: usize) -> Vec { + src.audio(0, n, 48_000).unwrap().channels[0].clone() +} + +fn check_info(name: &str, src: &filmcraft_media::SharedSource, vcodec: &str, acodec: &str) { + let info = src.info(); + assert_eq!(info.container, "AVI", "{name}"); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (160, 120, vcodec), "{name}"); + assert_eq!(v.frame_rate.as_f64(), 25.0, "{name}"); + let a = &info.audio_streams[0]; + assert_eq!((a.sample_rate, a.channels, a.codec.as_str()), (48_000, 1, acodec), "{name}"); + let secs = info.duration.seconds(); + assert!((secs - 2.0).abs() < 0.1, "{name}: duration {secs}"); +} + +#[test] +fn h264_frames_are_bit_exact_with_random_access_and_mp3_plays() { + let Some((path, src)) = + avi("avi_h264_mp3.avi", &["-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p"], &["-c:a", "libmp3lame", "-b:a", "128k"]) + else { + return; + }; + check_info("h264", &src, "H.264", "MP3"); + let reference = reference_yuv(&path, "yuv420p").unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [37, 3, 24, 49, 0, 13, 12, 11, 30, 31, 32] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + assert!(yuv_bytes(&f) == reference[k * frame..(k + 1) * frame], "H.264 frame {k} differs from ffmpeg"); + } + // MP3 (a different decoder): the tone, in time with ffmpeg's decode + let r = reference_audio(&path); + let a = ours(&src, 48_000); + let (rms_ref, rms_diff) = rms_and_diff(&r, &a, 4_800..43_200); + // ffmpeg's test tone is at 1/8 of full scale + assert!(rms_ref > 0.05 && rms_diff < rms_ref * 0.05, "MP3: tone {rms_ref}, difference {rms_diff}"); + // random access into the middle matches sequential decoding + let mid = src.audio(24_000, 4_800, 48_000).unwrap().channels[0].clone(); + let worst = mid.iter().zip(&a[24_000..28_800]).map(|(x, y)| (x - y).abs()).fold(0.0, f32::max); + assert!(worst < 0.01, "MP3 seek: {worst}"); +} + +fn rms_and_diff(reference: &[f32], ours: &[f32], range: std::ops::Range) -> (f32, f32) { + let n = range.len() as f32; + let rms = (range.clone().map(|i| reference[i].powi(2)).sum::() / n).sqrt(); + let diff = (range.map(|i| (reference[i] - ours[i]).powi(2)).sum::() / n).sqrt(); + (rms, diff) +} + +#[test] +fn mjpeg_and_pcm() { + let Some((path, src)) = avi("avi_mjpeg_pcm.avi", &["-c:v", "mjpeg", "-q:v", "3", "-pix_fmt", "yuvj420p"], &["-c:a", "pcm_s16le"]) else { return }; + check_info("mjpeg", &src, "Motion JPEG", "PCM 16-bit"); + let reference = reference_yuv(&path, "rgba").unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [10, 0, 49] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap().to_rgba8(); + let r = &reference[k * frame..(k + 1) * frame]; + let mean = f.iter().zip(r).map(|(a, b)| (*a as i32 - *b as i32).unsigned_abs()).sum::() as f64 / f.len() as f64; + // another JPEG decoder and chroma upsampling than ffmpeg's: close, not identical + assert!(mean < 6.0, "JPEG frame {k}: mean difference {mean}"); + } + // PCM: exactly ffmpeg's samples + let r = reference_audio(&path); + assert_eq!(ours(&src, 48_000), r[..48_000].to_vec()); +} + +#[test] +fn uncompressed_video_is_bit_exact() { + for (name, pix, codec, reference_fmt) in [ + ("avi_bgr24.avi", "bgr24", "Uncompressed RGB 24-bit", "rgba"), + ("avi_yuyv.avi", "yuyv422", "Uncompressed YUV 4:2:2", "yuv422p"), + ("avi_i420.avi", "yuv420p", "Uncompressed YUV 4:2:0", "yuv420p"), + ("avi_nv12.avi", "nv12", "Uncompressed YUV 4:2:0", "yuv420p"), + ] { + let Some((path, src)) = avi(name, &["-c:v", "rawvideo", "-pix_fmt", pix], &["-c:a", "pcm_s16le"]) else { return }; + check_info(name, &src, codec, "PCM 16-bit"); + let reference = reference_yuv(&path, reference_fmt).unwrap(); + let frame = reference.len() / 50; + let rate = src.info().frame_rate(); + for k in [7, 0, 49] { + let f = src.video_frame(FrameRequest::full(rate.tick_of(k as i64))).unwrap(); + let got = if reference_fmt == "rgba" { f.to_rgba8() } else { yuv_bytes(&f) }; + assert!(got == reference[k * frame..(k + 1) * frame], "{name}: frame {k} differs from ffmpeg"); + } + } +} + +#[test] +fn mp2_and_ac3_audio() { + for (name, codec, label) in [("avi_mp2.avi", "mp2", "MPEG Audio"), ("avi_ac3.avi", "ac3", "AC-3")] { + let Some((path, src)) = avi(name, &["-c:v", "mjpeg"], &["-c:a", codec, "-b:a", "192k"]) else { return }; + assert_eq!(src.info().audio_streams[0].codec, label, "{name}"); + let r = reference_audio(&path); + let a = ours(&src, 48_000); + let (rms_ref, rms_diff) = rms_and_diff(&r, &a, 4_800..43_200); + assert!(rms_ref > 0.05 && rms_diff < rms_ref * 0.05, "{name}: tone {rms_ref}, difference {rms_diff}"); + } +} + +/// Dropped frames are stored as empty chunks: their slot shows the picture before. +#[test] +fn drop_frames_hold_the_previous_picture() { + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=s=160x120:r=25:d=2", + "-vf", + "select='not(eq(n\\,10))'", + "-fps_mode", + "passthrough", + "-c:v", + "rawvideo", + "-pix_fmt", + "bgr24", + ]; + let Some(path) = fixture_path("avi_drop.avi", &args) else { return }; + let src = crate::open_bytes("avi_drop.avi", std::fs::read(&path).unwrap().into()).unwrap(); + let rate = src.info().frame_rate(); + let at = |k: i64| src.video_frame(FrameRequest::full(rate.tick_of(k))).unwrap().to_rgba8(); + assert!(at(10) == at(9), "the dropped slot holds frame 9"); + assert!(at(11) != at(9)); +} + +/// DivX / Xvid has no decoder yet: the file opens, the codec is named, the picture reports it, and +/// the sound plays. +#[test] +fn mpeg4_part2_is_named_and_its_audio_plays() { + let Some((_, src)) = avi("avi_xvid.avi", &["-c:v", "mpeg4", "-vtag", "XVID"], &["-c:a", "pcm_s16le"]) else { return }; + assert_eq!(src.info().video.as_ref().unwrap().codec, "MPEG-4 Part 2 (DivX / Xvid)"); + let e = src.video_frame(FrameRequest::full(filmcraft_time::Tick::ZERO)).unwrap_err().to_string(); + assert!(e.contains("MPEG-4 Part 2"), "{e}"); + assert!(ours(&src, 4_800).iter().any(|s| s.abs() > 0.1)); +} + +/// A Motion JPEG frame without Huffman tables (the AVI1 convention) decodes with the standard ones: +/// exactly as the same frame with them. +#[test] +fn jpeg_without_huffman_tables_uses_the_standard_ones() { + let Some(path) = fixture_path( + "default_huffman.jpg", + &["-f", "lavfi", "-i", "testsrc2=s=160x120", "-frames:v", "1", "-c:v", "mjpeg", "-huffman", "default", "-q:v", "3"], + ) else { + return; + }; + let jpeg = std::fs::read(&path).unwrap(); + // remove every DHT segment + let mut stripped = jpeg[..2].to_vec(); + let mut at = 2; + while at + 4 <= jpeg.len() && jpeg[at] == 0xFF && jpeg[at + 1] != 0xDA { + let len = u16::from_be_bytes([jpeg[at + 2], jpeg[at + 3]]) as usize; + if jpeg[at + 1] != 0xC4 { + stripped.extend_from_slice(&jpeg[at..at + 2 + len]); + } + at += 2 + len; + } + stripped.extend_from_slice(&jpeg[at..]); + assert!(stripped.len() < jpeg.len() && !stripped.windows(2).any(|w| w == [0xFF, 0xC4])); + use crate::video::VideoDecoder; + let mut d = crate::video::MjpegDecoder; + let a = d.decode(&jpeg, 0).unwrap().remove(0).frame.to_rgba8(); + let b = d.decode(&stripped, 0).unwrap().remove(0).frame.to_rgba8(); + assert!(a == b); + // the inserted segment is 418 bytes and well-formed: 4 tables of 12, 12, 162, 162 codes + let with = crate::video::with_default_huffman(&stripped); + assert_eq!(with.len(), stripped.len() + 420); +} + +/// H.264 with B-frames copied from an MP4 whose time base is 1/600 (ffmpeg keeps it): 600 frame +/// slots a second, a picture in every 24th at 25 fps, empty drop chunks between. The pictures stay +/// at their times in presentation order, and the stream reports the rate they play at. +#[test] +fn copied_h264_keeps_its_timing_with_a_fine_time_base() { + let mp4 = + ["-f", "lavfi", "-i", "testsrc2=s=160x120:r=25:d=2", "-c:v", "libx264", "-bf", "2", "-g", "12", "-pix_fmt", "yuv420p", "-video_track_timescale", "600"]; + let Some(src_mp4) = fixture_path("avi_copy_source.mp4", &mp4) else { return }; + let src_path = src_mp4.to_string_lossy().into_owned(); + let Some(path) = fixture_path("avi_copy_600.avi", &["-i", &src_path, "-c:v", "copy", "-bsf:v", "h264_mp4toannexb", "-f", "avi"]) else { return }; + let src = crate::open_bytes("avi_copy_600.avi", std::fs::read(&path).unwrap().into()).unwrap(); + let v = src.info().video.clone().unwrap(); + assert_eq!(v.frame_rate.as_f64(), 25.0, "the rate the pictures play at, not the 600 slots"); + let reference = reference_yuv(&path, "yuv420p").unwrap(); + let frame = reference.len() / 50; + for k in [37usize, 3, 24, 49, 0, 13, 12, 11, 30] { + let at = filmcraft_time::Tick::from_rational(k as i64, 1, 25); + let f = src.video_frame(FrameRequest::full(at)).unwrap(); + assert!(yuv_bytes(&f) == reference[k * frame..(k + 1) * frame], "picture {k} at {:.2} s differs from ffmpeg", k as f64 / 25.0); + } + // between pictures, the one before holds + let mid = src.video_frame(FrameRequest::full(filmcraft_time::Tick::from_rational(1, 1, 50))).unwrap(); + assert!(yuv_bytes(&mid) == reference[0..frame]); +} diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index a31da3a7..b728820f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -13,6 +13,8 @@ //! - [`MpegSource`]: MPEG-2 transport streams (`.ts`, `.m2ts`, `.mts`), program streams (`.mpg`, //! `.vob`, `.mod`) and MPEG-1/2 video elementary streams: MPEG-1/2, H.264 and HEVC video; MPEG //! audio, AAC (ADTS / LATM), AC-3 and LPCM. +//! - [`AviSource`]: AVI (OpenDML included): Motion JPEG, H.264, HEVC and uncompressed video; PCM, +//! MP3 / MP2 and AC-3 audio. //! - [`AudioFileSource`]: standalone compressed audio files (MP3, FLAC, AIFF, …). //! - [`openers`]: the openers to register with the engine's media pool. @@ -20,6 +22,7 @@ pub mod apv; pub mod audio; +pub mod avi; pub mod gop; pub mod hw; mod hw_frame; @@ -35,6 +38,7 @@ use std::sync::{Arc, RwLock}; pub use apv::ApvSource; pub use audio::AudioFileSource; +pub use avi::AviSource; pub use gop::{FRAME_BUDGET, GopStats, cached_bytes, gop_stats, live_decoders}; pub use mkv::MkvSource; pub use mp4::Mp4Source; @@ -124,12 +128,22 @@ pub fn software_video_decoder(entry: &filmcraft_isobmff::SampleEntry) -> Result< /// Openers for the engine's media pool (MP4/MOV, Matroska/WebM, MXF, Ogg Opus/Vorbis, MPEG TS/PS and /// MPEG-1/2 video elementary streams, APV raw bitstreams, standalone audio). pub fn openers() -> Vec { - vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, audio::opener] + vec![mp4::opener, mkv::opener, mxf::opener, ogg::opener, mpeg::opener, apv::opener, avi::opener, audio::opener] } fn reader_registry() -> &'static RwLock> { static R: std::sync::OnceLock>> = std::sync::OnceLock::new(); - R.get_or_init(|| RwLock::new(vec![mp4::reader_opener, mkv::reader_opener, mxf::reader_opener, ogg::reader_opener, mpeg::reader_opener, apv::reader_opener])) + R.get_or_init(|| { + RwLock::new(vec![ + mp4::reader_opener, + mkv::reader_opener, + mxf::reader_opener, + ogg::reader_opener, + mpeg::reader_opener, + apv::reader_opener, + avi::reader_opener, + ]) + }) } /// Openers that read containers through a [`filmcraft_media::ByteReader`] (index now, samples on @@ -160,6 +174,15 @@ impl filmcraft_isobmff::ByteSource for Src { } } +impl filmcraft_avi::ByteSource for Src { + fn len(&self) -> u64 { + self.0.len() + } + fn read_at(&self, offset: u64, buf: &mut [u8]) -> std::io::Result<()> { + self.0.read_at(offset, buf) + } +} + impl filmcraft_matroska::ByteSource for Src { fn len(&self) -> u64 { self.0.len() @@ -177,6 +200,8 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>) -> std::result::Result Option<&'static str> { +pub(crate) fn ffmpeg() -> Option<&'static str> { ["/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg"].into_iter().find(|p| std::path::Path::new(p).exists()) } @@ -231,13 +231,13 @@ fn matroska_prores_pcm() { /// Path of a fixture under `target/fixtures/codecs/`, generated with ffmpeg when missing /// (`None` when ffmpeg or the encoder is unavailable). -fn fixture_path(name: &str, args: &[&str]) -> Option { +pub(crate) fn fixture_path(name: &str, args: &[&str]) -> Option { fixture(name, args)?; Some(PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../target/fixtures/codecs").join(name)) } /// ffmpeg's decode of `src` as raw planar video (the oracle). -fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { +pub(crate) fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { let name = format!("{}.{pix_fmt}.yuv", src.file_name()?.to_str()?); let path = src.to_str()?; let b = fixture(&name, &["-i", path, "-fps_mode", "passthrough", "-f", "rawvideo", "-pix_fmt", pix_fmt])?; @@ -245,7 +245,7 @@ fn reference_yuv(src: &std::path::Path, pix_fmt: &str) -> Option> { } /// Planes of a decoded YUV frame as little-endian bytes (ffmpeg rawvideo layout). -fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec { +pub(crate) fn yuv_bytes(f: &filmcraft_frame::VideoFrame) -> Vec { match &f.data { filmcraft_frame::PixelData::Yuv8 { planes, .. } => planes.iter().flat_map(|p| p.iter().copied()).collect(), filmcraft_frame::PixelData::Yuv16 { planes, .. } => planes.iter().flat_map(|p| p.iter().flat_map(|s| s.to_le_bytes())).collect(), diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 86b21b7d..8b9c3196 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -178,13 +178,69 @@ pub fn hvcc_length_size_and_tid(hvcc: &[u8]) -> (usize, Option) { /// A factory returns `None` when it does not handle the entry. pub type VideoDecoderFactory = fn(&SampleEntry) -> Option>>; +/// The typical Huffman tables of ITU-T T.81 Annex K.3 as one DHT segment (luminance and +/// chrominance, DC and AC): what a Motion JPEG frame without its own tables is coded with (AVI's +/// "MJPG", the AVI1 convention of capture cards and cameras). +const DEFAULT_DHT: &[u8] = &[ + 0xFF, 0xC4, 0x01, 0xA2, // + 0x00, 0, 1, 5, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // + 0x01, 0, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, // + 0x10, 0, 2, 1, 3, 3, 2, 4, 3, 5, 5, 4, 4, 0, 0, 1, 0x7D, // + 0x01, 0x02, 0x03, 0x00, 0x04, 0x11, 0x05, 0x12, 0x21, 0x31, 0x41, 0x06, 0x13, 0x51, 0x61, 0x07, 0x22, 0x71, 0x14, 0x32, 0x81, 0x91, 0xA1, 0x08, // + 0x23, 0x42, 0xB1, 0xC1, 0x15, 0x52, 0xD1, 0xF0, 0x24, 0x33, 0x62, 0x72, 0x82, 0x09, 0x0A, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x25, 0x26, 0x27, 0x28, // + 0x29, 0x2A, 0x34, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, 0x59, // + 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x83, 0x84, 0x85, 0x86, 0x87, 0x88, 0x89, // + 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, 0xB5, 0xB6, // + 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, 0xE1, 0xE2, // + 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, // + 0x11, 0, 2, 1, 2, 4, 4, 3, 4, 7, 5, 4, 4, 0, 1, 2, 0x77, // + 0x00, 0x01, 0x02, 0x03, 0x11, 0x04, 0x05, 0x21, 0x31, 0x06, 0x12, 0x41, 0x51, 0x07, 0x61, 0x71, 0x13, 0x22, 0x32, 0x81, 0x08, 0x14, 0x42, 0x91, // + 0xA1, 0xB1, 0xC1, 0x09, 0x23, 0x33, 0x52, 0xF0, 0x15, 0x62, 0x72, 0xD1, 0x0A, 0x16, 0x24, 0x34, 0xE1, 0x25, 0xF1, 0x17, 0x18, 0x19, 0x1A, 0x26, // + 0x27, 0x28, 0x29, 0x2A, 0x35, 0x36, 0x37, 0x38, 0x39, 0x3A, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0x49, 0x4A, 0x53, 0x54, 0x55, 0x56, 0x57, 0x58, // + 0x59, 0x5A, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6A, 0x73, 0x74, 0x75, 0x76, 0x77, 0x78, 0x79, 0x7A, 0x82, 0x83, 0x84, 0x85, 0x86, 0x87, // + 0x88, 0x89, 0x8A, 0x92, 0x93, 0x94, 0x95, 0x96, 0x97, 0x98, 0x99, 0x9A, 0xA2, 0xA3, 0xA4, 0xA5, 0xA6, 0xA7, 0xA8, 0xA9, 0xAA, 0xB2, 0xB3, 0xB4, // + 0xB5, 0xB6, 0xB7, 0xB8, 0xB9, 0xBA, 0xC2, 0xC3, 0xC4, 0xC5, 0xC6, 0xC7, 0xC8, 0xC9, 0xCA, 0xD2, 0xD3, 0xD4, 0xD5, 0xD6, 0xD7, 0xD8, 0xD9, 0xDA, // + 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9, 0xEA, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xF8, 0xF9, 0xFA, +]; + +/// A JPEG with the [`DEFAULT_DHT`] inserted before its first scan when it defines no Huffman +/// table of its own (else unchanged, borrowed). +pub fn with_default_huffman(jpeg: &[u8]) -> std::borrow::Cow<'_, [u8]> { + use std::borrow::Cow; + if jpeg.get(..2) != Some(&[0xFF, 0xD8]) { + return Cow::Borrowed(jpeg); + } + // walk the marker segments up to the start of scan + let mut at = 2usize; + while let (Some(&0xFF), Some(&m)) = (jpeg.get(at), jpeg.get(at + 1)) { + match m { + 0xFF => at += 1, // fill byte + 0xC4 => return Cow::Borrowed(jpeg), + 0xDA => { + let mut out = Vec::with_capacity(jpeg.len() + DEFAULT_DHT.len()); + out.extend_from_slice(jpeg.get(..at).unwrap_or_default()); + out.extend_from_slice(DEFAULT_DHT); + out.extend_from_slice(jpeg.get(at..).unwrap_or_default()); + return Cow::Owned(out); + } + 0xD0..=0xD9 | 0x01 => at += 2, + _ => { + let Some(len) = jpeg.get(at + 2..at + 4).map(|b| usize::from(u16::from_be_bytes([b[0], b[1]]))) else { break }; + at += 2 + len; + } + } + } + Cow::Borrowed(jpeg) +} + /// Motion-JPEG / Photo-JPEG (each sample is a complete JPEG). pub struct MjpegDecoder; impl VideoDecoder for MjpegDecoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + let sample = with_default_huffman(sample); // mjpa samples may contain two fields; decode the first image (field-merging lands with interlace support). - let img = image::load_from_memory_with_format(sample, image::ImageFormat::Jpeg).map_err(|e| CodecError::Decode(e.to_string()))?; + let img = image::load_from_memory_with_format(&sample, image::ImageFormat::Jpeg).map_err(|e| CodecError::Decode(e.to_string()))?; let rgba = img.to_rgba8(); let (w, h) = rgba.dimensions(); Ok(vec![DecodedFrame { pts, frame: VideoFrame::rgba8(w, h, rgba.into_raw()), draft: false }]) @@ -1017,3 +1073,165 @@ mod disposable_tests { assert!(!hevc_disposable(&sample(&[&nal(0, 1)]), 4, None)); } } + +/// Uncompressed video layouts (AVI `BI_RGB` and YUV FourCCs). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RawFormat { + /// 24-bit B, G, R; rows padded to 4 bytes. + Bgr24, + /// 32-bit B, G, R, X. + Bgrx32, + /// Packed 4:2:2: Y0 U Y1 V (`YUY2`, `YUYV`). + Yuy2, + /// Packed 4:2:2: U Y0 V Y1 (`UYVY`, `2vuy`). + Uyvy, + /// Planar 4:2:0: Y, U, V (`I420`, `IYUV`). + I420, + /// Planar 4:2:0: Y, V, U (`YV12`). + Yv12, + /// Y plane, interleaved U V at half resolution (`NV12`). + Nv12, + /// Luma only (`Y800`, `GREY`, `Y8 `). + Gray8, +} + +impl RawFormat { + /// The layout of an AVI video stream: `BI_RGB` with its bit count, or a YUV FourCC. + pub fn from_avi(compression: [u8; 4], bit_count: u16) -> Option { + if matches!(u32::from_le_bytes(compression), 0 | 3) { + return match bit_count { + 24 => Some(RawFormat::Bgr24), + 32 => Some(RawFormat::Bgrx32), + _ => None, + }; + } + let mut upper = compression; + upper.make_ascii_uppercase(); + Some(match &upper { + b"YUY2" | b"YUYV" | b"YUNV" | b"V422" => RawFormat::Yuy2, + b"UYVY" | b"2VUY" | b"UYNV" | b"HDYC" => RawFormat::Uyvy, + b"I420" | b"IYUV" => RawFormat::I420, + b"YV12" => RawFormat::Yv12, + b"NV12" => RawFormat::Nv12, + b"Y800" | b"GREY" | b"GRAY" | b"Y8 " => RawFormat::Gray8, + _ => return None, + }) + } + + pub fn label(self) -> &'static str { + match self { + RawFormat::Bgr24 => "Uncompressed RGB 24-bit", + RawFormat::Bgrx32 => "Uncompressed RGB 32-bit", + RawFormat::Yuy2 | RawFormat::Uyvy => "Uncompressed YUV 4:2:2", + RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 => "Uncompressed YUV 4:2:0", + RawFormat::Gray8 => "Uncompressed greyscale", + } + } +} + +/// Uncompressed frames (each sample one picture of a [`RawFormat`]). +pub struct RawVideoDecoder { + format: RawFormat, + width: u32, + height: u32, + /// RGB rows are stored bottom-up (a positive `BITMAPINFOHEADER` height). + bottom_up: bool, +} + +impl RawVideoDecoder { + pub fn new(format: RawFormat, width: u32, height: u32, bottom_up: bool) -> Result { + if width == 0 || height == 0 || width > 16_384 || height > 16_384 { + return Err(CodecError::Unsupported(format!("{width}x{height} uncompressed video"))); + } + Ok(Self { format, width, height, bottom_up }) + } + + fn yuv(&self, planes: [Vec; 3], chroma: filmcraft_frame::Chroma) -> VideoFrame { + use std::sync::Arc; + let [y, u, v] = planes; + VideoFrame { + width: self.width, + height: self.height, + data: filmcraft_frame::PixelData::Yuv8 { planes: [Arc::new(y), Arc::new(u), Arc::new(v)], chroma, alpha: None }, + color: vui_color(self.width, self.height, 2, 2, false), + par: (1, 1), + pts: filmcraft_time::Tick::ZERO, + } + } + + fn picture(&self, s: &[u8]) -> Result { + let (w, h) = (self.width as usize, self.height as usize); + let short = || CodecError::Decode(format!("{} frame of {} bytes is too short for {w}x{h}", self.format.label(), s.len())); + let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); + Ok(match self.format { + RawFormat::Bgr24 | RawFormat::Bgrx32 => { + let bpp = if self.format == RawFormat::Bgr24 { 3 } else { 4 }; + let stride = (w * bpp).div_ceil(4) * 4; + if s.len() < stride * h { + return Err(short()); + } + let mut rgba = Vec::with_capacity(w * h * 4); + for row in 0..h { + let src = if self.bottom_up { h - 1 - row } else { row }; + for px in s.get(src * stride..src * stride + w * bpp).unwrap_or_default().chunks_exact(bpp) { + rgba.extend_from_slice(&[px[2], px[1], px[0], 255]); + } + } + VideoFrame::rgba8(self.width, self.height, rgba) + } + RawFormat::Yuy2 | RawFormat::Uyvy => { + let stride = cw * 4; + if s.len() < stride * h { + return Err(short()); + } + let (yo, uo, vo, y2) = if self.format == RawFormat::Yuy2 { (0, 1, 3, 2) } else { (1, 0, 2, 3) }; + let (mut y, mut u, mut v) = (Vec::with_capacity(w * h), Vec::with_capacity(cw * h), Vec::with_capacity(cw * h)); + for row in s.chunks_exact(stride).take(h) { + for (k, q) in row.as_chunks::<4>().0.iter().enumerate() { + y.push(q[yo]); + if 2 * k + 1 < w { + y.push(q[y2]); + } + u.push(q[uo]); + v.push(q[vo]); + } + } + self.yuv([y, u, v], filmcraft_frame::Chroma::C422) + } + RawFormat::I420 | RawFormat::Yv12 | RawFormat::Nv12 | RawFormat::Gray8 => { + let luma = w * h; + let need = if self.format == RawFormat::Gray8 { luma } else { luma + 2 * cw * ch }; + if s.len() < need { + return Err(short()); + } + let y = s.get(..luma).unwrap_or_default().to_vec(); + let (u, v) = match self.format { + RawFormat::Gray8 => (vec![128; cw * ch], vec![128; cw * ch]), + RawFormat::Nv12 => s.get(luma..luma + 2 * cw * ch).unwrap_or_default().as_chunks::<2>().0.iter().map(|p| (p[0], p[1])).unzip(), + _ => { + let a = s.get(luma..luma + cw * ch).unwrap_or_default().to_vec(); + let b = s.get(luma + cw * ch..luma + 2 * cw * ch).unwrap_or_default().to_vec(); + if self.format == RawFormat::Yv12 { (b, a) } else { (a, b) } + } + }; + self.yuv([y, u, v], filmcraft_frame::Chroma::C420) + } + }) + } +} + +impl VideoDecoder for RawVideoDecoder { + fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { + Ok(vec![DecodedFrame { pts, frame: self.picture(sample)?, draft: false }]) + } + fn flush(&mut self) -> Vec { + Vec::new() + } + fn reset(&mut self) {} + fn name(&self) -> &str { + self.format.label() + } + fn intra_only(&self) -> bool { + true + } +} diff --git a/crates/engine/src/avi_import_tests.rs b/crates/engine/src/avi_import_tests.rs new file mode 100644 index 00000000..43d8a301 --- /dev/null +++ b/crates/engine/src/avi_import_tests.rs @@ -0,0 +1,58 @@ +//! #598: AVI files import (ffmpeg-made, skipped without it): the Project item describes the +//! streams, a sequence made from it plays picture and sound, and the Media Browser's streaming +//! probe opens it without reading it whole. + +use serde_json::json; + +use crate::Session; +use filmcraft_project::ItemId; + +fn fixture() -> Option { + let ffmpeg = filmcraft_testkit::oracle::ffmpeg_or_skip("avi import")?; + let path = filmcraft_testkit::fixtures_dir("engine/avi").join("h264_mp3.avi"); + filmcraft_testkit::fixtures::generate(&path, |tmp| { + std::process::Command::new(&ffmpeg) + .args([ + "-y", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=160x120:rate=25:duration=2", + "-f", + "lavfi", + "-i", + "sine=frequency=440:sample_rate=48000:duration=2", + ]) + .args(["-c:v", "libx264", "-bf", "2", "-pix_fmt", "yuv420p", "-c:a", "libmp3lame", "-f", "avi"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +#[test] +fn avi_imports_plays_and_probes() { + let Some(path) = fixture() else { return }; + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert!(r["errors"].as_array().is_none_or(|e| e.is_empty()), "{r}"); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let info = s.project.item(item).unwrap().as_media().unwrap().info.clone(); + assert_eq!(info.container, "AVI"); + let v = info.video.as_ref().unwrap(); + assert_eq!((v.width, v.height, v.codec.as_str()), (160, 120, "H.264")); + assert_eq!(info.audio_streams.first().map(|a| a.codec.as_str()), Some("MP3")); + assert!((info.duration.seconds() - 2.0).abs() < 0.1, "{}", info.duration.seconds()); + // a sequence from the clip has its picture and sound + s.execute("file.newSequenceFromClip", json!({"items": [item.0]})).unwrap(); + let q = s.active_sequence().unwrap().clone(); + assert!(!q.video_tracks[0].items.is_empty() && !q.audio_tracks[0].items.is_empty()); + let src = s.source(item).unwrap(); + assert!(src.video_frame(filmcraft_media::FrameRequest::full(filmcraft_time::Tick(filmcraft_time::TICKS_PER_SECOND))).is_ok()); + assert!(src.audio(48_000, 4_800, 48_000).unwrap().channels[0].iter().any(|v| v.abs() > 0.05)); + // the Media Browser's probe streams it (index now, frames on demand) + let probed = s.media.probe_file(&path.to_string_lossy(), &*s.services).unwrap(); + assert_eq!(probed.info().container, "AVI"); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32..96ae9761 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1046,6 +1046,8 @@ mod audio_placement_tests; #[cfg(test)] mod autosave_tests; #[cfg(test)] +mod avi_import_tests; +#[cfg(test)] mod clip_ops_tests; #[cfg(test)] mod color_tests; diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index f82aa78b..06147b64 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -54,7 +54,7 @@ impl Default for MediaPool { } /// Largest file [`MediaPool::probe_file`] reads whole when no streaming reader takes it (WAV, -/// MP3 and other byte-opened formats, or an unsupported container such as AVI). +/// MP3 and other byte-opened formats, or an unsupported container). pub const PROBE_WHOLE_FILE_MAX: u64 = 64 * 1024 * 1024; /// Cache key of a media clip's full-resolution reference ("" matches anything: sources inserted diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index 7d567969..97b42d96 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -175,6 +175,20 @@ pub struct FrameInfo { const ADTS_RATES: [u32; 13] = [96_000, 88_200, 64_000, 48_000, 44_100, 32_000, 24_000, 22_050, 16_000, 12_000, 11_025, 8_000, 7_350]; /// Parse the frame header at the start of `b` (MPEG audio, ADTS, AC-3, E-AC-3). +/// Bytes of the audio frame whose header starts `b` (MPEG audio, ADTS, LATM, AC-3, E-AC-3), or +/// `None` without a valid header there. For containers that store frames in chunks that need not +/// follow them (AVI): the frames are found again with it. +pub fn frame_bytes(codec: &Codec, b: &[u8]) -> Option { + let sync = match codec { + Codec::MpegAudio => AudioSync::Mpa, + Codec::AacAdts => AudioSync::Adts, + Codec::AacLatm => AudioSync::Loas, + Codec::Ac3 | Codec::Eac3 => AudioSync::Ac3, + _ => return None, + }; + frame_len(sync, b) +} + pub fn frame_info(codec: &Codec, b: &[u8]) -> Option { match codec { Codec::MpegAudio => { diff --git a/crates/mpegts/src/lib.rs b/crates/mpegts/src/lib.rs index 074d3951..4330ebcb 100644 --- a/crates/mpegts/src/lib.rs +++ b/crates/mpegts/src/lib.rs @@ -21,7 +21,7 @@ #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] mod es; -pub use es::{FrameInfo, frame_info}; +pub use es::{FrameInfo, frame_bytes, frame_info}; mod pes; mod ps; mod ts; From 4ce807263db0bdf11acc029ecdf243d80a7e69c3 Mon Sep 17 00:00:00 2001 From: JordyUSA <75485565+JordyUSA@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:06:45 +0700 Subject: [PATCH 014/203] Project panel test: drop an unneeded mut (clippy unused_mut) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/tests/project_panel_ui.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index a56b8928..53d30b2e 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -718,7 +718,7 @@ fn double_click_imports_with_audio_in_the_timeline() { let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app); let mut d = Driver { harness, tx, snapshots: None }; d.frames(10); - let mut double_click = |d: &mut Driver, at: egui::Pos2| { + let double_click = |d: &mut Driver, at: egui::Pos2| { let push = |d: &mut Driver, e: egui::Event| d.harness.input_mut().events.push(e); d.frames(60); push(d, egui::Event::PointerMoved(at)); From ec7c43eea21318a488e0cd38c3d09befac2d5e75 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 02:39:53 +1100 Subject: [PATCH 015/203] Treat media extensions as case-insensitive on import. Camera files such as CLIP.MOV were hidden or skipped on Linux because sibling matching and tests assumed lowercase suffixes. Import, the media browser, and image-sequence numbering now accept mixed-case extensions. --- crates/engine/src/image_sequence_tests.rs | 11 +++++++++++ crates/engine/src/media_browser_tests.rs | 5 +++-- crates/media/src/lib.rs | 14 ++++++++++++++ crates/media/src/sequence.rs | 11 ++++++++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/image_sequence_tests.rs b/crates/engine/src/image_sequence_tests.rs index ca36d9cc..c8f213a2 100644 --- a/crates/engine/src/image_sequence_tests.rs +++ b/crates/engine/src/image_sequence_tests.rs @@ -11,6 +11,17 @@ use serde_json::{Value, json}; use crate::Session; use crate::media_test_util::tmp_dir; +#[test] +fn import_accepts_uppercase_extensions() { + let dir = tmp_dir("import-uppercase"); + let wav = dir.join("TONE.WAV"); + std::fs::write(&wav, filmcraft_media::wav::write_wav16(&[0.1; 4800], 2, 48_000)).unwrap(); + let mut s = Session::default(); + let r = s.execute("file.import", json!({"paths": [wav.to_string_lossy()]})).unwrap(); + assert_eq!(r["items"].as_array().unwrap().len(), 1); + assert!(s.project.items.values().any(|it| it.name == "TONE.WAV")); +} + fn demo() -> Session { let mut s = Session::default(); s.execute("file.openDemoProject", json!({})).unwrap(); diff --git a/crates/engine/src/media_browser_tests.rs b/crates/engine/src/media_browser_tests.rs index 972220b3..7b26dc0c 100644 --- a/crates/engine/src/media_browser_tests.rs +++ b/crates/engine/src/media_browser_tests.rs @@ -29,6 +29,7 @@ impl FakeFs { "/home/me/Movies/edit.edl", "/home/me/Movies/subs.srt", "/home/me/Movies/clip.mov", + "/home/me/Movies/CLIP.MOV", ] { files.insert(f.to_string(), vec![0; 16]); } @@ -100,7 +101,7 @@ fn listing_puts_folders_first_and_hides_unsupported_files() { assert_eq!(l["dir"], "/home/me"); assert_eq!(names(&l), ["Movies"]); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap(); - assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]); + assert_eq!(names(&l), ["Selects", "A-interview.wav", "b-roll.wav", "CLIP.MOV", "clip.mov", "edit.edl", "shot_0001.png", "shot_0002.png", "subs.srt"]); let e = &l["entries"][0]; assert_eq!((e["isDir"].as_bool(), e["kind"].as_str()), (Some(true), Some("folder"))); let shot = l["entries"].as_array().unwrap().iter().find(|e| e["name"] == "shot_0001.png").unwrap(); @@ -117,7 +118,7 @@ fn file_type_filter() { let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "image"})).unwrap(); assert_eq!(names(&l), ["Selects", "shot_0001.png", "shot_0002.png"]); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies", "fileTypes": "mov"})).unwrap(); - assert_eq!(names(&l), ["Selects", "clip.mov"]); + assert_eq!(names(&l), ["Selects", "CLIP.MOV", "clip.mov"]); // the setting persists and applies by default s.execute("mediaBrowser.settings", json!({"fileTypes": "project"})).unwrap(); let l = s.execute("mediaBrowser.list", json!({"path": "/home/me/Movies"})).unwrap(); diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 6b5ff1b1..d65a0630 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -180,6 +180,20 @@ pub fn is_importable(path: &Path) -> bool { STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) } +#[cfg(test)] +mod importable_tests { + use super::*; + use std::path::Path; + + #[test] + fn camera_extensions_match_regardless_of_case() { + for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { + assert!(is_importable(Path::new(name)), "{name}"); + } + assert!(!is_importable(Path::new("notes.txt"))); + } +} + /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; diff --git a/crates/media/src/sequence.rs b/crates/media/src/sequence.rs index 9521cba4..8c60e015 100644 --- a/crates/media/src/sequence.rs +++ b/crates/media/src/sequence.rs @@ -75,7 +75,15 @@ impl Numbered { /// The frame number of a sibling file name of this sequence (same prefix, suffix and padding). pub fn number_of(&self, file_name: &str) -> Option { - let mid = file_name.strip_prefix(self.prefix.as_str())?.strip_suffix(self.suffix.as_str())?; + let rest = file_name.strip_prefix(self.prefix.as_str())?; + let mid = { + let n = rest.as_bytes(); + let s = self.suffix.as_bytes(); + if n.len() < s.len() || !n[n.len() - s.len()..].eq_ignore_ascii_case(s) { + return None; + } + &rest[..n.len() - s.len()] + }; if mid.is_empty() || !mid.bytes().all(|b| b.is_ascii_digit()) || mid.len() > 18 { return None; } @@ -231,6 +239,7 @@ mod tests { assert!(!u.padded()); assert_eq!(u.file_name(10), "frame10.TIF"); assert_eq!(u.number_of("frame10.TIF"), Some(10)); + assert_eq!(u.number_of("frame10.tif"), Some(10), "Linux stills often mix .TIF and .tif"); assert_eq!(Numbered::parse("clip.mov"), None, "not a still"); assert_eq!(Numbered::parse("nodigits.png"), None); let w = Numbered::parse("C:\\renders\\out.0010.exr.png").unwrap(); From 4b67c44dbe9a9dc78cacb30cb2bf04e1f12a3403 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 18:12:54 +0200 Subject: [PATCH 016/203] #342: ProRes 4444 and 4444 XQ export, with alpha Problem: the ProRes encoder already codes 4444 / 4444 XQ (optionally with alpha), but export only knew the four 422 flavours: any other proresProfile fell back to HQ and the encoder was always fed a 4:2:2 frame, so graphics could not round-trip with their alpha. Fix: proresProfile accepts 4444 and 4444xq. The ProRes export encoder builds a 4:4:4 frame for them and, with Include Alpha Channel, codes the straight alpha of the unflattened picture (ExportSettings:: supports_alpha / keeps_alpha). Export Settings offers both profiles and the alpha checkbox for them; new presets Apple ProRes 4444, 4444 with Alpha and 4444 XQ; summary text and the MXF coding label name the profile (and the MXF descriptor keeps its 12-bit 4:4:4 depth). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 9 ++- crates/export/src/lib.rs | 75 ++++++++++++++++++++---- crates/export/src/mxf_out.rs | 7 +-- crates/export/src/pipeline.rs | 4 +- crates/export/src/presets.rs | 8 +++ crates/export/src/settings.rs | 19 +++++- crates/export/src/tests.rs | 45 ++++++++++++++ crates/ui-egui/src/panels/export_mode.rs | 7 ++- 9 files changed, 154 insertions(+), 22 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..ec5385be 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1180,7 +1180,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 2757ebba..a1f3f231 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -202,9 +202,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { "proxy" => ("Proxy", 45.0), "lt" => ("LT", 102.0), "standard" => ("Standard", 147.0), + "4444" => ("4444", 330.0), + "4444xq" => ("XQ", 500.0), _ => ("HQ", 220.0), }; assert_eq!(v["profile"], profile, "{name}"); + if matches!(profile, "4444" | "XQ") { + let pix = v["pix_fmt"].as_str().unwrap_or_default(); + let alpha = settings["alpha"].as_bool().unwrap_or(false); + assert!(pix.contains("444") && pix.starts_with("yuva") == alpha, "{name}: {pix}"); + } assert!(vbr <= mbps * px * 1.5, "{name}: {vbr:.1} Mb/s vs nominal {:.1}", mbps * px); assert_eq!(a["codec_name"], "pcm_s24le", "{name}"); } @@ -303,7 +310,7 @@ fn user_presets_persist_with_favourites_import_and_export() { let favs = s.execute("export.presets.list", json!({"favorites": true})).unwrap()["presets"].as_array().unwrap().len(); assert_eq!(favs, 2); let hits = s.execute("export.presets.list", json!({"query": "prores"})).unwrap()["presets"].as_array().unwrap().len(); - assert_eq!(hits, 5, "four QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); + assert_eq!(hits, 8, "seven QuickTime ProRes presets and MXF OP1a ProRes 422 HQ"); // persisted: a fresh session with the same data directory sees both let mut s2 = demo(); s2.export_presets.set_dir(&dir.0); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 312d0301..05196d72 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -239,7 +239,7 @@ pub struct ExportSettings { /// `progress.total`, `finished` and the final status; this call only adds to `done`. #[serde(default)] pub part_of_batch: bool, - /// ProRes flavour: `proxy`, `lt`, `standard` or `hq` (empty = HQ). + /// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ). #[serde(default)] pub prores_profile: String, /// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ). @@ -256,8 +256,9 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG and TIFF sequences (straight alpha, Premiere's "Include - /// Alpha Channel"). Off, every frame is flattened over black (#160). Other formats ignore it. + /// Keep the alpha channel in PNG and TIFF sequences and ProRes 4444 / 4444 XQ QuickTime movies + /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over + /// black (#160). Other formats ignore it. #[serde(default)] pub alpha: bool, /// Output frame size (None = Match Source: the sequence size times `scale`). @@ -986,6 +987,8 @@ fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSetti struct ProResEncoder { enc: filmcraft_prores::Encoder, profile: filmcraft_prores::Profile, + /// Code the frames' alpha channel (4444 profiles with Include Alpha Channel). + alpha: bool, w: u32, h: u32, rate: FrameRate, @@ -1002,13 +1005,19 @@ impl VideoEncoder for ProResEncoder { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { - let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::ChromaFormat::Yuv422, 10, false); - timed(Stage::Convert, || match f.hdr { - Some(rgb) => { - let (kr, kb) = self.signal.kr_kb(); - rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) + let chroma = self.profile.chroma(); + let mut fr = filmcraft_prores::Frame::new(f.width, f.height, chroma, 10, self.alpha); + let (w, h) = (f.width as usize, f.height as usize); + timed(Stage::Convert, || match (chroma, f.hdr) { + (filmcraft_prores::ChromaFormat::Yuv444, hdr) => { + let (kr, kb) = if hdr.is_some() { self.signal.kr_kb() } else { (0.2126, 0.0722) }; + to_yuv444_10(f.rgba, hdr, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr, fr.alpha.as_deref_mut()) } - None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr), + (_, Some(rgb)) => { + let (kr, kb) = self.signal.kr_kb(); + rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) + } + (_, None) => rgba_to_yuv422_10(f.rgba, w, h, &mut fr.y, &mut fr.cb, &mut fr.cr), }); let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) @@ -1018,6 +1027,47 @@ impl VideoEncoder for ProResEncoder { } } +/// Limited-range 10-bit 4:4:4 with matrix (Kr, Kb) from straight RGBA8, or from encoded R'G'B' +/// floats (3 per pixel) when `hdr` is given. `alpha` (full range, 0 = transparent) takes the +/// RGBA8 alpha; it stays opaque for float input, which carries none. +#[allow(clippy::too_many_arguments)] +pub fn to_yuv444_10( + rgba: &[u8], + hdr: Option<&[f32]>, + w: usize, + h: usize, + kr: f32, + kb: f32, + y: &mut [u16], + cb: &mut [u16], + cr: &mut [u16], + alpha: Option<&mut [u16]>, +) { + let (w, n) = (w.max(1), w.saturating_mul(h)); + let kg = 1.0 - kr - kb; + let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); + let rgb_at = |i: usize| -> [f32; 3] { + match hdr { + Some(f) => f.get(i * 3..i * 3 + 3).map_or([0.0; 3], |p| [p[0], p[1], p[2]]), + None => rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]), + } + }; + y.par_chunks_mut(w).zip(cb.par_chunks_mut(w).zip(cr.par_chunks_mut(w))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { + for (x, (yv, (cbv, crv))) in yr.iter_mut().zip(cbr.iter_mut().zip(crr.iter_mut())).enumerate() { + let [r, g, b] = rgb_at(row * w + x); + let yy = kr * r + kg * g + kb * b; + *yv = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16; + *cbv = (512.0 + 896.0 * (b - yy) / sb).round().clamp(4.0, 1019.0) as u16; + *crv = (512.0 + 896.0 * (r - yy) / sr).round().clamp(4.0, 1019.0) as u16; + } + }); + if let (Some(a), None) = (alpha, hdr) { + for (i, dst) in a.iter_mut().enumerate().take(n) { + *dst = rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16); + } + } +} + /// BT.709 limited-range 10-bit 4:2:2 from straight RGBA8 (chroma averaged horizontally). pub fn rgba_to_yuv422_10(rgba: &[u8], w: usize, h: usize, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) { let cw = w.div_ceil(2); @@ -1051,6 +1101,8 @@ pub fn prores_profile(name: &str) -> filmcraft_prores::Profile { "proxy" => Profile::Proxy, "lt" => Profile::Lt, "standard" | "422" => Profile::Standard, + "4444" => Profile::P4444, + "4444xq" | "4444 xq" | "4444-xq" | "xq" => Profile::P4444Xq, _ => Profile::Hq, } } @@ -1089,7 +1141,10 @@ fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSet if s.signal.is_hdr() { cfg.color = filmcraft_prores::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix }; } - Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, rate, signal: s.signal }) as Box) + let alpha = s.keeps_alpha(); + cfg.encode_alpha = alpha; + let enc = filmcraft_prores::Encoder::with_config(cfg); + Ok(Box::new(ProResEncoder { enc, profile, alpha, w, h, rate, signal: s.signal }) as Box) }) } diff --git a/crates/export/src/mxf_out.rs b/crates/export/src/mxf_out.rs index 24233e7a..ecd8ede2 100644 --- a/crates/export/src/mxf_out.rs +++ b/crates/export/src/mxf_out.rs @@ -38,7 +38,9 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc { Profile::Proxy => 1, Profile::Lt => 2, Profile::Standard => 3, - _ => 4, + Profile::Hq => 4, + Profile::P4444 => 5, + Profile::P4444Xq => 6, }, } } @@ -53,9 +55,6 @@ fn picture(settings: &ExportSettings, w: u32, h: u32) -> PictureDesc { _ => PictureCoding::Vc3 { cid: crate::dnx_profile(&settings.dnx_profile).cid() }, }; let mut d = PictureDesc::new(coding, w, h); - if matches!(coding, PictureCoding::ProRes { .. }) { - d.depth = 10; - } d.color = match settings.signal { s if s == ColorSignal::PQ => ColorSpace::Rec2020Pq, s if s == ColorSignal::HLG => ColorSpace::Rec2020Hlg, diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 306aef2f..78503d00 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -12,7 +12,7 @@ use filmcraft_time::FrameRate; use rayon::prelude::*; use crate::settings::{ExportEffects, Placement, Scaling, TextOverlay}; -use crate::{ExportError, ExportSettings, Format, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; +use crate::{ExportError, ExportSettings, FrameRenderer, GpuRendering, Result, build_frame_renderer, note_gpu_fallback, note_gpu_frame}; /// Where the rendered picture lands in the output frame. #[derive(Clone, Copy, Debug, PartialEq)] @@ -201,7 +201,7 @@ impl Pipeline { geom, opts, hdr_out, - alpha: settings.alpha && matches!(settings.format, Format::PngSequence | Format::TiffSequence), + alpha: settings.keeps_alpha(), out_tf, effects, overlay, diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 3aa37d05..4323de77 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -122,6 +122,14 @@ pub fn builtin_presets() -> Vec { p("Apple ProRes 422", bc, "QuickTime, ProRes 422, 24-bit 48 kHz PCM", mov(Format::ProRes, "standard")), p("Apple ProRes 422 LT", bc, "QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM", mov(Format::ProRes, "lt")), p("Apple ProRes 422 Proxy", bc, "QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM", mov(Format::ProRes, "proxy")), + p("Apple ProRes 4444", bc, "QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444")), + p( + "Apple ProRes 4444 with Alpha", + bc, + "QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM", + ExportSettings { alpha: true, ..mov(Format::ProRes, "4444") }, + ), + p("Apple ProRes 4444 XQ", bc, "QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM", mov(Format::ProRes, "4444xq")), p("Avid DNxHR HQ", bc, "QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "hq")), p("Avid DNxHR SQ", bc, "QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "sq")), p("Avid DNxHR LB", bc, "QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM", mov(Format::DnxHr, "lb")), diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 8e8fc7ef..8b761693 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -374,6 +374,21 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG and TIFF + /// sequences and ProRes 4444 / 4444 XQ QuickTime movies. + pub fn supports_alpha(&self) -> bool { + match self.format { + Format::PngSequence | Format::TiffSequence => true, + Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, + _ => false, + } + } + + /// Whether the exported frames keep their alpha channel instead of being flattened over black. + pub fn keeps_alpha(&self) -> bool { + self.alpha && self.supports_alpha() + } + /// Whether the output is a numbered image sequence. pub fn is_image_sequence(&self) -> bool { matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence) @@ -540,7 +555,9 @@ impl ExportSettings { Profile::Proxy => ", ProRes 422 Proxy", Profile::Lt => ", ProRes 422 LT", Profile::Standard => ", ProRes 422", - _ => ", ProRes 422 HQ", + Profile::Hq => ", ProRes 422 HQ", + Profile::P4444 => ", ProRes 4444", + Profile::P4444Xq => ", ProRes 4444 XQ", } } Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }), diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 656ddd9f..ae8e9fd6 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -101,6 +101,51 @@ fn prores_export_roundtrip() { assert!(f[0] > 240 && f[1] < 15 && f[2] < 15, "{:?}", &f[..4]); } +/// ProRes 4444 / 4444 XQ export (#342): 4:4:4 frames under the `ap4h` / `ap4x` codes, and with +/// Include Alpha Channel the picture's alpha survives instead of being flattened over black. +#[test] +fn prores_4444_export_roundtrip() { + let (p, seq, m) = project(); + for (profile, label) in [("4444", "ProRes 4444"), ("4444xq", "ProRes 4444 XQ")] { + let path = tmp(&format!("pr_{profile}.mov")); + let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: profile.into(), ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap(); + let codec = src.info().video.as_ref().unwrap().codec.clone(); + assert!(codec.contains(label), "{profile}: {codec}"); + let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap(); + assert!(f.format_label().contains("4:4:4"), "{profile}: {}", f.format_label()); + let px = f.to_rgba8(); + assert!(px[0] > 240 && px[1] < 15 && px[2] < 15 && px[3] == 255, "{profile}: {:?}", &px[..4]); + } + + // a half-transparent matte keeps its alpha with Include Alpha Channel, and only then + let mut p = (*p).clone(); + let half = GeneratorSource::new(Generator::ColorMatte { color: [1.0, 0.0, 0.0, 0.5] }, 320, 180, FrameRate::FPS_24, Tick(2 * TICKS_PER_SECOND)); + let item = p.sequence(seq).unwrap().video_tracks[0].items[0].item; + if let ItemKind::Media(mc) = &mut p.item_mut(item).unwrap().kind { + mc.media = MediaRef::Generator(half.generator.clone()); + } + let mut m = m; + m.0.insert(item, Arc::new(half)); + let p = Arc::new(p); + for alpha in [true, false] { + let path = tmp(&format!("pr_4444_alpha_{alpha}.mov")); + let s = ExportSettings { format: Format::ProRes, path: path.clone(), prores_profile: "4444".into(), alpha, ..Default::default() }; + assert!(s.keeps_alpha() == alpha); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into(); + let src = filmcraft_codecs::open_bytes("pr.mov", bytes).unwrap(); + let px = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 3))).unwrap().to_rgba8(); + if alpha { + assert!((px[3] as i32 - 128).abs() <= 3, "alpha kept: {:?}", &px[..4]); + } else { + assert_eq!(px[3], 255, "flattened: {:?}", &px[..4]); + } + } +} + #[test] fn dnxhr_export_roundtrip() { let (p, seq, m) = project(); diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b66ab552..c93f5ebc 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -606,7 +606,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } - if matches!(s.format, Format::PngSequence | Format::TiffSequence) { + if s.supports_alpha() { row(ui, t, tl!("Alpha"), |ui| { check(ui, reg, "export.video.alpha", &mut s.alpha, tl!("Include Alpha Channel")); }); @@ -700,8 +700,9 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } Format::ProRes => { row(ui, t, tl!("Profile"), |ui| { - let keys = ["proxy", "lt", "standard", "hq"]; - let labels = ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ"]; + let keys = ["proxy", "lt", "standard", "hq", "4444", "4444xq"]; + let labels = + ["Apple ProRes 422 Proxy", "Apple ProRes 422 LT", "Apple ProRes 422", "Apple ProRes 422 HQ", "Apple ProRes 4444", "Apple ProRes 4444 XQ"]; let cur = keys.iter().position(|k| *k == s.prores_profile).unwrap_or(3); if let Some(i) = combo(ui, reg, "export.video.proresProfile", labels[cur], &opts(&labels), 220.0) { s.prores_profile = keys[i].into(); From 86d113e557170116deb59c60afeb76b68369438c Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:15:30 +0200 Subject: [PATCH 017/203] edit: Close Gap refuses a locked track Closing a gap on a locked track skipped that track but still moved the sync-locked ones, pulling them out of sync with it. It is now refused with the usual "locked" error. --- crates/edit/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..3bd1b370 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -448,6 +448,10 @@ pub fn ripple_delete_items(seq: &mut Sequence, items: &[ClipId]) -> Result Result<()> { let tr = seq.track(track).ok_or(EditError::NoTrack(track))?; + if tr.locked { + // the other tracks would move while this one stayed put + return Err(EditError::Locked); + } if tr.item_at(t).is_some() { return Err(EditError::Nothing); } From c8f85af5a1c56aa6646eb4d5ff31466414b6c6ae Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:15:30 +0200 Subject: [PATCH 018/203] #648, #668: select a gap and close it with Delete or Ripple Delete timeline.selectGap selects the empty time on one track between two clips (or before the first one). Delete, Backspace (edit.clear) and Ripple Delete then close it as one "Ripple Delete" undo step, as in Premiere Pro. The selection lives in the editor state (gapSelection in sequence.inspect), is replaced by a clip, transition or edit point selection, and is dropped by an edit that changes the gap. sequence.closeGap shares the same code. --- crates/engine/src/commands.rs | 98 ++++++++++--- crates/engine/src/gap_selection_tests.rs | 174 +++++++++++++++++++++++ crates/engine/src/lib.rs | 30 +++- crates/engine/src/trim.rs | 2 + 4 files changed, 284 insertions(+), 20 deletions(-) create mode 100644 crates/engine/src/gap_selection_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..390cc236 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -82,16 +82,20 @@ pub(crate) fn has_selection(s: &Session) -> std::result::Result<(), String> { /// Clips or captions selected (Clear / Ripple Delete work on either). fn has_any_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; - if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.transition_selection.is_empty() { + if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.transition_selection.is_empty() && s.state.gap_selection.is_none() { Err("nothing selected".into()) } else { Ok(()) } } -/// Clips or captions selected (Ripple Delete: a transition has no length of its own to close). +/// Clips, captions or a gap selected (Ripple Delete: a transition has no length of its own to close). fn has_clip_or_caption_selection(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; - if s.state.selection.is_empty() && s.state.caption_selection.is_empty() { Err("nothing selected".into()) } else { Ok(()) } + if s.state.selection.is_empty() && s.state.caption_selection.is_empty() && s.state.gap_selection.is_none() { + Err("nothing selected".into()) + } else { + Ok(()) + } } fn has_source(s: &Session) -> std::result::Result<(), String> { has_seq(s)?; @@ -1218,6 +1222,12 @@ fn build() -> Vec { let ids = s.state.transition_selection.clone(); return remove_transitions(s, &ids, "Clear"); } + // a selected gap closes, as in Premiere Pro (#648, #668) + if s.state.caption_selection.is_empty() + && let Some(g) = s.state.gap_selection + { + return ripple_delete_gap(s, g); + } let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, false); } @@ -1231,6 +1241,11 @@ fn build() -> Vec { }), cmd!("edit.rippleDelete", "Ripple Delete", ["Edit"], Some("Shift+Delete"), r#"{"clips":[id]?}"#, has_clip_or_caption_selection, |s, p| { if p.get("clips").is_none() && p.get("clip").is_none() && s.state.selection.is_empty() { + if s.state.caption_selection.is_empty() + && let Some(g) = s.state.gap_selection + { + return ripple_delete_gap(s, g); + } let caps = s.state.caption_selection.clone(); return crate::captions::delete(s, &caps, true); } @@ -1250,11 +1265,13 @@ fn build() -> Vec { cmd!("edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{}", has_seq, |s, _| { let all: Vec = s.active_sequence().map(|q| q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect()).unwrap_or_default(); s.state.selection = all; + s.state.gap_selection = None; Ok(json!({"selected": s.state.selection.len()})) }), cmd!("edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", always, |s, _| { s.state.selection.clear(); s.state.caption_selection.clear(); + s.state.gap_selection = None; Ok(Value::Null) }), cmd!("edit.duplicate", "Duplicate", ["Edit"], Some("Cmd+Shift+/"), "{}", has_project_selection, |s, _| { @@ -1648,21 +1665,7 @@ fn build() -> Vec { cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id,"time":ticks}"#, has_seq, |s, p| { let tr = track_p(s, p, "track", "sequence.closeGap")?.ok_or_else(|| bad("sequence.closeGap", "need `track`"))?; let t = time_p(s, p, "").unwrap_or(s.playhead()); - s.edit_sequence("Ripple Delete", |q, _, st| { - let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default(); - edit::close_gap(q, tr, t)?; - if st.ripple_sequence_markers { - // the gap ended where the first clip that moved started - let moved = before - .iter() - .filter_map(|(id, old)| q.find_item(*id).filter(|(_, i)| i.start != *old).map(|(_, i)| (*old, *old - i.start))) - .min_by_key(|m| m.0); - if let Some((end, len)) = moved { - crate::sequence_tools::ripple_markers(&mut q.markers, end, -len); - } - } - Ok(()) - })?; + close_gap(s, tr, t)?; Ok(Value::Null) }), cmd!("sequence.snap", "Snap in Timeline", ["Sequence"], Some("S"), r#"{"on":bool?}"#, always, |s, p| { @@ -2146,8 +2149,9 @@ fn build() -> Vec { // selecting clips leaves trim mode (Premiere: clip and edit point selections are exclusive) s.state.edit_points.clear(); s.state.trim_shift = Default::default(); - // and a clip selection replaces a transition one + // and a clip selection replaces a transition or gap one s.state.transition_selection.clear(); + s.state.gap_selection = None; if bool_p(p, "toggle").unwrap_or(false) { for c in clips { if let Some(i) = s.state.selection.iter().position(|x| *x == c) { @@ -2168,6 +2172,7 @@ fn build() -> Vec { } Ok(json!({"selection": s.state.selection.iter().map(|c| c.0).collect::>()})) }), + cmd!("timeline.selectGap", "Select Gap", [], None, r#"{"track":"V1"|id,"time":ticks|"frame":n|"seconds":f}"#, has_seq, select_gap), cmd!( "timeline.move", "Move Clips", @@ -3350,6 +3355,7 @@ fn select_transitions(s: &mut Session, ids: &Value, add: bool, toggle: bool) -> } s.state.selection.clear(); s.state.caption_selection.clear(); + s.state.gap_selection = None; s.state.edit_points.clear(); s.state.trim_shift = Default::default(); Ok(json!({"selection": [], "transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::>()})) @@ -3366,6 +3372,59 @@ fn remove_transitions(s: &mut Session, ids: &[TransitionId], label: &str) -> Res Ok(json!({"removed": n})) } +/// `timeline.selectGap`: select the gap on `track` at `time` (clicking empty space between +/// clips, #648, #668). It replaces the clip, transition, caption and edit point selections, as +/// in Premiere Pro. Empty time after a track's last clip is not a gap. +fn select_gap(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "timeline.selectGap"; + let track = track_p(s, p, "track", CMD)?.ok_or_else(|| bad(CMD, "need `track`"))?; + let t = time_p(s, p, "").ok_or_else(|| bad(CMD, "need `time`, `frame` or `seconds`"))?; + let q = s.active_sequence().ok_or(EngineError::NoSequence)?; + let tr = q.track(track).ok_or_else(|| bad(CMD, format!("no track {}", track.0)))?; + if tr.locked { + return Err(EngineError::Edit(edit::EditError::Locked)); + } + let range = edit::through::track_gaps(tr).into_iter().find(|g| g.contains(t)).ok_or_else(|| bad(CMD, "there is no gap there"))?; + s.state.gap_selection = Some(crate::GapSelection { track, range }); + s.state.selection.clear(); + s.state.caption_selection.clear(); + s.state.transition_selection.clear(); + s.state.edit_points.clear(); + s.state.trim_shift = Default::default(); + Ok(json!({"track": track.0, "start": range.start.0, "end": range.end().0})) +} + +/// Close the selected gap (Delete, Backspace or Ripple Delete on a gap). +fn ripple_delete_gap(s: &mut Session, g: crate::GapSelection) -> Result { + let still = s.active_sequence().and_then(|q| q.track(g.track)).is_some_and(|tr| edit::through::track_gaps(tr).contains(&g.range)); + if !still { + s.state.gap_selection = None; + return Err(EngineError::Other("the selected gap is no longer there".into())); + } + close_gap(s, g.track, g.range.start)?; + s.state.gap_selection = None; + Ok(json!({"closed": g.range.duration.0})) +} + +/// `sequence.closeGap`: ripple the gap on `track` at `t` closed (one undo step, "Ripple Delete"). +fn close_gap(s: &mut Session, tr: TrackId, t: Tick) -> Result<()> { + s.edit_sequence("Ripple Delete", |q, _, st| { + let before: Vec<(ClipId, Tick)> = q.track(tr).map(|x| x.items.iter().map(|i| (i.id, i.start)).collect()).unwrap_or_default(); + edit::close_gap(q, tr, t)?; + if st.ripple_sequence_markers { + // the gap ended where the first clip that moved started + let moved = before + .iter() + .filter_map(|(id, old)| q.find_item(*id).filter(|(_, i)| i.start != *old).map(|(_, i)| (*old, *old - i.start))) + .min_by_key(|m| m.0); + if let Some((end, len)) = moved { + crate::sequence_tools::ripple_markers(&mut q.markers, end, -len); + } + } + Ok(()) + }) +} + /// `sequence.setTransition`: edit an applied transition (Effect Controls, the Timeline): its /// settings, Reverse, and where it sits. `duration` / `frames` resize it the way its alignment /// says (Center at Cut and Custom Start: both ends; Start at Cut: the end; End at Cut: the @@ -3588,6 +3647,7 @@ pub fn inspect_sequence(s: &Session, id: ItemId, q: &filmcraft_project::Sequence "audio": q.audio_tracks.iter().map(tr).collect::>(), "selection": s.state.selection.iter().map(|c| c.0).collect::>(), "transitionSelection": s.state.transition_selection.iter().map(|t| t.0).collect::>(), + "gapSelection": s.state.gap_selection.map(|g| json!({"track": g.track.0, "start": g.range.start.0, "end": g.range.end().0})), }) } diff --git a/crates/engine/src/gap_selection_tests.rs b/crates/engine/src/gap_selection_tests.rs new file mode 100644 index 00000000..66f1d2d9 --- /dev/null +++ b/crates/engine/src/gap_selection_tests.rs @@ -0,0 +1,174 @@ +//! Selecting a gap between clips and closing it with Delete / Backspace / Ripple Delete (#648, #668): +//! `timeline.selectGap` selects the empty time on one track, `edit.clear` and `edit.rippleDelete` +//! close it as one "Ripple Delete" undo step. + +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::{GapSelection, Session}; + +fn range(s: &Session, c: ClipId) -> (Tick, Tick) { + s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap() +} + +/// Two 48-frame clips (pictures on V1, linked sounds on A1) placed at `frames`. +fn two_clips(s: &mut Session, frames: [i64; 2]) -> [(ClipId, ClipId); 2] { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for f in frames { + s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let q = s.active_sequence().unwrap(); + [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)) +} + +#[test] +fn clicking_between_two_clips_selects_the_gap_and_delete_closes_it() { + let mut s = Session::default(); + let [(picture1, _), (picture2, sound2)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let (f48, f60, f96) = (f(48), f(60), f(96)); + s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap(); + let before = s.project.clone(); + + let r = s.execute("timeline.selectGap", json!({"track": "V1", "time": f60.0})).unwrap(); + assert_eq!(r, json!({"track": s.active_sequence().unwrap().video_tracks[0].id.0, "start": f48.0, "end": f96.0})); + assert!(s.state.selection.is_empty(), "the gap replaces the clip selection"); + assert!(s.is_enabled("edit.clear") && s.is_enabled("edit.rippleDelete"), "Delete and Ripple Delete apply to the gap"); + + // Delete / Backspace + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!(range(&s, picture2), (f48, f96), "the next clip moves up into the gap"); + assert_eq!(range(&s, sound2), (f48, f96), "and its sound with it"); + assert_eq!(range(&s, picture1).0, Tick::ZERO, "the clip before stays"); + assert!(s.state.gap_selection.is_none(), "the gap is gone"); + s.active_sequence().unwrap().check().unwrap(); + + assert_eq!(s.undo().as_deref(), Some("Ripple Delete"), "one undo step"); + assert_eq!(*s.project, *before); +} + +#[test] +fn ripple_delete_closes_a_selected_gap_too() { + let mut s = Session::default(); + let [_, (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + s.execute("timeline.selectGap", json!({"track": "A1", "frame": 50})).unwrap(); + s.execute("edit.rippleDelete", json!({})).unwrap(); + assert_eq!(range(&s, picture2).0, f(48)); +} + +#[test] +fn the_empty_time_before_the_first_clip_is_a_gap() { + let mut s = Session::default(); + let [(picture1, _), (picture2, _)] = two_clips(&mut s, [24, 72]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.selectGap", json!({"track": "V1", "frame": 5})).unwrap(); + assert_eq!((r["start"].clone(), r["end"].clone()), (json!(0), json!(f(24).0))); + s.execute("edit.clear", json!({})).unwrap(); + assert_eq!((range(&s, picture1).0, range(&s, picture2).0), (Tick::ZERO, f(48))); +} + +#[test] +fn empty_time_after_the_last_clip_is_not_a_gap() { + let mut s = Session::default(); + two_clips(&mut s, [0, 96]); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 400})).is_err()); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 10})).is_err(), "inside a clip"); + assert!(s.state.gap_selection.is_none()); + assert!(!s.is_enabled("edit.clear"), "nothing to delete"); +} + +#[test] +fn selecting_a_clip_or_deselecting_all_drops_the_gap() { + let mut s = Session::default(); + let [(picture1, _), _] = two_clips(&mut s, [0, 96]); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + s.execute("timeline.select", json!({"clips": [picture1.0]})).unwrap(); + assert!(s.state.gap_selection.is_none()); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + s.execute("edit.deselectAll", json!({})).unwrap(); + assert!(s.state.gap_selection.is_none()); +} + +/// An edit that fills the gap leaves no gap selected; Delete then removes nothing it should not. +#[test] +fn an_edit_that_changes_the_gap_drops_it() { + let mut s = Session::default(); + let [(picture1, _), (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + s.execute("timeline.trim", json!({"clip": picture1.0, "edge": "out", "mode": "regular", "deltaFrames": 12})).unwrap(); + assert!(s.state.gap_selection.is_none(), "the gap changed"); + assert!(s.execute("edit.clear", json!({})).is_err()); + assert_eq!(range(&s, picture2).0, f(96), "nothing moved"); +} + +/// A stale selection (set from outside, as the control channel can) is refused, not acted on. +#[test] +fn a_stale_gap_is_refused() { + let mut s = Session::default(); + let [_, (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let track = s.active_sequence().unwrap().video_tracks[0].id; + s.state.gap_selection = Some(GapSelection { track, range: filmcraft_time::TimeRange::from_bounds(f(10), f(20)) }); + assert!(s.execute("edit.clear", json!({})).is_err()); + assert!(s.state.gap_selection.is_none()); + assert_eq!(range(&s, picture2).0, f(96)); +} + +#[test] +fn a_gap_on_a_locked_track_cannot_be_selected_or_closed() { + let mut s = Session::default(); + let [_, (picture2, _)] = two_clips(&mut s, [0, 96]); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + s.execute("timeline.setTrack", json!({"track": "V1", "locked": true})).unwrap(); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).is_err()); + // Sequence ▸ Close Gap moved the other tracks while the locked one stayed put + assert!(s.execute("sequence.closeGap", json!({"track": "V1", "frame": 60})).is_err()); + assert_eq!(range(&s, picture2).0, f(96)); + let sound2 = s.active_sequence().unwrap().audio_tracks[0].items[1].id; + assert_eq!(range(&s, sound2).0, f(96), "the unlocked sound stays in sync"); +} + +#[test] +fn hostile_select_gap_parameters_are_errors() { + let mut s = Session::default(); + assert!(s.execute("timeline.selectGap", json!({"track": "V1", "frame": 1})).is_err(), "no sequence"); + two_clips(&mut s, [0, 96]); + for p in [ + json!({}), + json!({"track": "V1"}), + json!({"frame": 60}), + json!({"track": "V99", "frame": 60}), + json!({"track": 999_999, "frame": 60}), + json!({"track": "V1", "time": i64::MAX}), + json!({"track": "V1", "time": i64::MIN}), + json!({"track": "V1", "frame": i64::MAX}), + json!({"track": "V1", "seconds": f64::MAX}), + json!({"track": [], "frame": "x"}), + ] { + let _ = s.execute("timeline.selectGap", p); + } + assert!(s.state.gap_selection.is_none()); +} + +#[test] +fn the_gap_selection_is_part_of_the_ui_state() { + let mut s = Session::default(); + two_clips(&mut s, [0, 96]); + s.execute("timeline.selectGap", json!({"track": "V1", "frame": 60})).unwrap(); + let v = serde_json::to_value(&s.state).unwrap(); + let back: crate::EditorState = serde_json::from_value(v).unwrap(); + assert_eq!(back.gap_selection, s.state.gap_selection); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32..0d48e26c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -57,7 +57,7 @@ use std::sync::Arc; use filmcraft_edit::EditCtx; use filmcraft_project::{ClipId, ItemId, Project, Sequence, TrackId, TrackKind}; -use filmcraft_time::{FrameRate, Tick}; +use filmcraft_time::{FrameRate, Tick, TimeRange}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -221,6 +221,13 @@ pub struct Targeting { pub audio_dest: Option, } +/// A gap on one track of the active sequence (see [`EditorState::gap_selection`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct GapSelection { + pub track: TrackId, + pub range: TimeRange, +} + /// Editing state that commands depend on (not project data, but headless-relevant). #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct EditorState { @@ -266,6 +273,12 @@ pub struct EditorState { /// them. Selecting clips clears it and selecting transitions clears the clip selection. #[serde(default)] pub transition_selection: Vec, + /// The selected gap (#648, #668): empty time on one track, clicked in the Timeline, from the + /// end of a clip (or the sequence start) up to the next clip. Delete, Backspace and Ripple + /// Delete close it. Selecting clips, transitions or edit points clears it, and so does an edit + /// that changes the gap. + #[serde(default)] + pub gap_selection: Option, /// Selected layers (indices among the graphic layers, 0 = back) of the selected graphic clip. #[serde(default)] pub graphic_layers: Vec, @@ -776,6 +789,7 @@ impl Session { self.history.redo.clear(); self.history.merge_key = None; self.state = st; + self.drop_stale_gap(); self.bump(); Ok(r) } @@ -795,6 +809,7 @@ impl Session { self.refuse_self_nesting(&p)?; self.project = Arc::new(p); self.state = st; + self.drop_stale_gap(); self.bump(); Ok(r) } @@ -888,6 +903,17 @@ impl Session { self.state.caption_selection.clear(); } self.state.project_selection.retain(|i| p.item(*i).is_some()); + self.drop_stale_gap(); + } + + /// Forget the selected gap when it is no longer a gap of the active sequence (a clip moved + /// into it or next to it, its track went, another sequence became the active one). + fn drop_stale_gap(&mut self) { + if let Some(g) = self.state.gap_selection + && !self.active_sequence().and_then(|q| q.track(g.track)).is_some_and(|tr| filmcraft_edit::through::track_gaps(tr).contains(&g.range)) + { + self.state.gap_selection = None; + } } fn bump(&mut self) { @@ -1058,6 +1084,8 @@ mod export_tests; #[cfg(test)] mod file_tests; #[cfg(test)] +mod gap_selection_tests; +#[cfg(test)] mod image_sequence_tests; #[cfg(test)] mod interchange_auto_points_tests; diff --git a/crates/engine/src/trim.rs b/crates/engine/src/trim.rs index dda7a4d1..951a873c 100644 --- a/crates/engine/src/trim.rs +++ b/crates/engine/src/trim.rs @@ -104,6 +104,7 @@ pub fn select(s: &mut Session, p: &Value) -> Result { s.state.edit_points = vec![ep]; } s.state.selection.clear(); + s.state.gap_selection = None; reset_shift(s); Ok(json!({"editPoints": s.state.edit_points})) } @@ -155,6 +156,7 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result { } s.state.edit_points = points; s.state.selection.clear(); + s.state.gap_selection = None; reset_shift(s); Ok(json!({"editPoints": s.state.edit_points})) } From ca44d854505d3e601670a7982d06e622d7321de8 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:15:30 +0200 Subject: [PATCH 019/203] #648, #668: click a gap in the Timeline, Delete closes it Clicking empty time between two clips selects the gap, drawn as a light block across it. Delete or Backspace closes it; right-clicking it opens a menu with Ripple Delete. A cut's edit point menu right-clicked before no longer stands in for the gap's menu. --- crates/ui-egui/src/panels/timeline.rs | 51 +++++++- crates/ui-egui/tests/timeline_gap_ui.rs | 167 ++++++++++++++++++++++++ 2 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 crates/ui-egui/tests/timeline_gap_ui.rs diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..ca227606 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -30,6 +30,8 @@ const RULER_H: f32 = 44.0; const SCROLLBAR_H: f32 = 17.0; const DIVIDER_H: f32 = 5.0; const MASTER_H: f32 = 34.0; +/// The selected gap between two clips (#648, #668). +const GAP_FILL: Color32 = Color32::from_gray(224); const SNAP_PX: f32 = 9.0; /// Transient interaction state. @@ -380,6 +382,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } let p = painter.with_clip_rect(Rect::from_min_max(pos2(content.min.x, row.min.y), pos2(content.max.x, row.max.y))); let volume_line = r.kind == TrackKind::Audio && !r.lane; + // the selected gap (#648, #668): a light block across the empty time, as in Premiere Pro + if let Some(g) = app.session.state.gap_selection.filter(|g| g.track == r.track) { + let x0 = layout.x_of(g.range.start).max(content.min.x - 1.0); + let x1 = layout.x_of(g.range.end()).min(content.max.x + 1.0); + if x1 > x0 { + let gap = Rect::from_min_max(pos2(x0, r.rect.min.y + 1.0), pos2(x1, r.rect.max.y - 1.0)); + p.rect_filled(gap, 0.0, GAP_FILL); + app.auto.add("timeline.gap", gap.intersect(content), "Selected gap"); + } + } for it in &tr.items { let (start, dur, moved_track) = previews.get(&it.id).copied().unwrap_or((it.start, it.duration, None)); if moved_track.is_some_and(|m| m != r.track) { @@ -1695,6 +1707,19 @@ fn edit_point_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::U } } +/// The menu of a right-clicked gap: Ripple Delete closes it. +fn gap_menu(app: &mut FilmcraftApp, ctx: &egui::Context, ui: &mut egui::Ui) { + let label = tl!("Ripple Delete"); + let r = ui.add_enabled(app.session.state.gap_selection.is_some(), egui::Button::new(label)); + app.auto.add("timeline.gapMenu.edit.rippleDelete", r.rect, label); + if r.clicked() { + if let Err(e) = crate::menus::invoke(app, ctx, "edit.rippleDelete", json!({})) { + app.ui.status = e; + } + ui.close(); + } +} + /// The clip menu's Multi-Camera submenu: Enable, Flatten, and the cameras of the selected nested /// sequence clips (greyed out when none of the selected clips is a nested sequence). fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem]) { @@ -1968,6 +1993,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if mods.alt { app.session.state.selection = vec![clip]; app.session.state.transition_selection.clear(); + app.session.state.gap_selection = None; } else { let _ = app.session.execute("timeline.select", json!({"clips": [clip.0]})); } @@ -1996,13 +2022,17 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } (_, Grab::Other(Hit::Transition { .. })) => None, - (_, Grab::Other(Hit::Empty { .. })) => { + (_, Grab::Other(Hit::Empty { track })) => { if resp.drag_started() { Some(Drag::Marquee { start: p }) } else { app.session.state.selection.clear(); app.session.state.edit_points.clear(); app.session.state.transition_selection.clear(); + // empty time between clips: select the gap, for Delete to close it (#648, #668) + if app.session.execute("timeline.selectGap", json!({"track": track.0, "time": t.0})).is_err() { + app.session.state.gap_selection = None; + } None } } @@ -2199,6 +2229,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); // the clip right-clicked, remembered while its menu is open: Unlink leaves only it selected let menu_clip_id = egui::Id::new("timeline.clipMenu.clip"); + // right-clicking empty time between clips selects the gap and opens its menu (#648, #668) + let gap_menu_id = egui::Id::new("timeline.gapMenu.open"); + if resp.secondary_clicked() + && let Some(p) = resp.interact_pointer_pos() + { + let on_gap = match hit(seq, layout, p) { + Hit::Empty { track } => app.session.execute("timeline.selectGap", json!({"track": track.0, "time": layout.tick_at(p.x).0})).is_ok(), + _ => false, + }; + ctx.data_mut(|d| d.insert_temp(gap_menu_id, on_gap)); + if on_gap { + // not the edit point menu of a cut right-clicked before + ctx.data_mut(|d| d.insert_temp::>(edit_point_menu_id, None)); + } + } if resp.secondary_clicked() && let Some(p) = resp.interact_pointer_pos() && let Hit::Clip { clip, edge, track } = hit(seq, layout, p) @@ -2230,6 +2275,10 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & edit_point_menu(app, &ctx, ui, seq, target); return; } + if ctx.data(|d| d.get_temp::(gap_menu_id)).unwrap_or(false) { + gap_menu(app, &ctx, ui); + return; + } let sel = app.session.state.selection.clone(); let picked: Vec<&filmcraft_project::TrackItem> = sel.iter().filter_map(|c| seq.find_item(*c).map(|(_, it)| it)).collect(); let all_enabled = !picked.is_empty() && picked.iter().all(|it| it.enabled); diff --git a/crates/ui-egui/tests/timeline_gap_ui.rs b/crates/ui-egui/tests/timeline_gap_ui.rs new file mode 100644 index 00000000..930f291d --- /dev/null +++ b/crates/ui-egui/tests/timeline_gap_ui.rs @@ -0,0 +1,167 @@ +//! Headless UI test of gaps in the Timeline (#648, #668): clicking empty time between two clips +//! selects the gap (drawn as a light block), and Delete, Backspace or the gap's right-click menu +//! (Ripple Delete) closes it, as in Premiere Pro. The real `FilmcraftApp` under `egui_kittest`, +//! driven over the control channel. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_engine::project::ClipId; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + /// A sequence with two 48-frame clips on V1 (and their sounds on A1) at frames 0 and 96. + fn two_clips() -> (Self, ClipId, ClipId) { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Gaps", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for f in [0, 96] { + s.execute("timeline.place", json!({"item": item.0, "frame": f, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + s.execute("edit.deselectAll", json!({})).unwrap(); + let v1 = &s.active_sequence().unwrap().video_tracks[0]; + let (a, b) = (v1.items[0].id, v1.items[1].id); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(s).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + (d, a, b) + } + + fn frames(&mut self, n: usize) { + for _ in 0..n { + // the control channel's clicks and keys enter through the input hook + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + + fn rect(&mut self, id: &str) -> Option<[f64; 4]> { + self.frames(2); + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == json!(id))?.clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + Some([r[0], r[1], r[2], r[3]]) + } + + /// Click (`button`: "left" or "right") in the middle of the empty time between `a` and `b`. + fn click_between(&mut self, a: ClipId, b: ClipId, button: &str) { + let ra = self.rect(&format!("timeline.clip.{}", a.0)).expect("the first clip is shown"); + let rb = self.rect(&format!("timeline.clip.{}", b.0)).expect("the second clip is shown"); + let (gap_l, gap_r) = (ra[0] + ra[2], rb[0]); + assert!(gap_r - gap_l > 10.0, "the gap is wide enough to click: {ra:?} {rb:?}"); + self.ok("ui.click", json!({"x": (gap_l + gap_r) / 2.0, "y": ra[1] + ra[3] / 2.0, "button": button})); + self.frames(3); + } + + fn start(&mut self, c: ClipId) -> i64 { + self.harness.state_mut().session.active_sequence().unwrap().find_item(c).map(|(_, i)| i.start.0).unwrap() + } + + fn frame(&mut self, n: i64) -> i64 { + self.harness.state_mut().session.sequence_rate().tick_of(n).0 + } + + fn gap(&mut self) -> Value { + self.ok("engine.execute", json!({"command": "sequence.inspect", "params": {}}))["gapSelection"].clone() + } +} + +#[test] +fn clicking_a_gap_selects_it_and_delete_closes_it() { + let (mut d, a, b) = Driver::two_clips(); + let (f48, f96) = (d.frame(48), d.frame(96)); + d.click_between(a, b, "left"); + let g = d.gap(); + assert_eq!((g["start"].as_i64(), g["end"].as_i64()), (Some(f48), Some(f96)), "the gap is selected: {g}"); + assert!(d.rect("timeline.gap").is_some(), "and drawn"); + d.ok("ui.key", json!({"key": "Delete"})); + d.frames(3); + assert_eq!(d.start(b), f48, "Delete closes the gap"); + assert_eq!(d.start(a), 0, "the clip before stays"); + assert_eq!(d.gap(), Value::Null); + assert!(d.rect("timeline.gap").is_none(), "nothing is drawn any more"); +} + +#[test] +fn backspace_closes_a_selected_gap_too() { + let (mut d, a, b) = Driver::two_clips(); + let f48 = d.frame(48); + d.click_between(a, b, "left"); + d.ok("ui.key", json!({"key": "Backspace"})); + d.frames(3); + assert_eq!(d.start(b), f48); +} + +#[test] +fn right_clicking_a_gap_offers_ripple_delete() { + let (mut d, a, b) = Driver::two_clips(); + let f48 = d.frame(48); + d.click_between(a, b, "right"); + assert!(d.gap().is_object(), "right-clicking selects the gap"); + assert!(d.rect("timeline.gapMenu.edit.rippleDelete").is_some(), "its menu offers Ripple Delete"); + assert!(d.rect("timeline.clipMenu.edit.cut").is_none(), "not the clip menu"); + d.ok("ui.click", json!({"id": "timeline.gapMenu.edit.rippleDelete"})); + d.frames(3); + assert_eq!(d.start(b), f48, "Ripple Delete closes the gap"); +} + +#[test] +fn clicking_a_clip_or_after_the_last_clip_drops_the_gap() { + let (mut d, a, b) = Driver::two_clips(); + let f96 = d.frame(96); + d.click_between(a, b, "left"); + d.ok("ui.click", json!({"id": format!("timeline.clip.{}", a.0)})); + d.frames(3); + assert_eq!(d.gap(), Value::Null, "a clip selection replaces the gap"); + d.click_between(a, b, "left"); + // empty time after the last clip is not a gap + let rb = d.rect(&format!("timeline.clip.{}", b.0)).unwrap(); + d.ok("ui.click", json!({"x": rb[0] + rb[2] + 30.0, "y": rb[1] + rb[3] / 2.0})); + d.frames(3); + assert_eq!(d.gap(), Value::Null); + d.ok("ui.key", json!({"key": "Delete"})); + d.frames(3); + assert_eq!(d.start(b), f96, "Delete with nothing selected moves nothing"); +} + +/// A cut's edit point menu, right-clicked before, does not stand in for the gap's menu. +#[test] +fn the_gap_menu_follows_an_edit_point_menu() { + let (mut d, a, b) = Driver::two_clips(); + d.ok("ui.click", json!({"id": format!("timeline.clip.{}", b.0), "fx": 0.0, "button": "right"})); + d.frames(3); + assert!(d.rect("timeline.editPointMenu.rippleIn").is_some(), "the edge's edit point menu"); + d.ok("ui.key", json!({"key": "Escape"})); + d.frames(3); + d.click_between(a, b, "right"); + assert!(d.rect("timeline.gapMenu.edit.rippleDelete").is_some(), "the gap's menu"); + assert!(d.rect("timeline.editPointMenu.rippleIn").is_none(), "not the edit point menu"); +} From ac8b77744768c8f86fb116c4f4456fb0a7551b56 Mon Sep 17 00:00:00 2001 From: Oskar Date: Sat, 10 Oct 2026 19:17:47 +0200 Subject: [PATCH 020/203] contributors: add myself (ossini), shown with my GitHub profile name --- contributors/people.toml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/contributors/people.toml b/contributors/people.toml index 50788355..976aaa4c 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -56,3 +56,6 @@ sync_github_name = false [people.liangforstudy] sync_github_name = true + +[people.ossini] +sync_github_name = true From cbd06b6c9a3fe2309ade2522baefd7643f41b2f6 Mon Sep 17 00:00:00 2001 From: awe <156385337+aw4e@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:22:02 +0700 Subject: [PATCH 021/203] Project panel: dragging one of several selected items places them all, back to back, in one undo step timeline.place takes items: [...] and file.newSequence takes fromItems: [...]; both run as one undo step through the new Session::edit_group, which rolls back if any item fails. Files dropped from the OS use the same command instead of one timeline.place per file, so they are one undo step too. --- crates/engine/src/commands.rs | 195 +++++++++++++++-------- crates/engine/src/lib.rs | 35 ++++ crates/engine/src/place_items_tests.rs | 109 +++++++++++++ crates/ui-egui/src/panels/mod.rs | 11 +- crates/ui-egui/src/panels/timeline.rs | 89 +++-------- crates/ui-egui/tests/project_panel_ui.rs | 37 +++++ crates/ui-egui/tests/timeline_drop_ui.rs | 24 +++ 7 files changed, 366 insertions(+), 134 deletions(-) create mode 100644 crates/engine/src/place_items_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 0b3a9f8a..05144a41 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -577,6 +577,110 @@ pub(crate) fn ensure_audio_tracks(p: &mut Project, seq_id: ItemId, first: usize, Ok(p.sequence(seq_id).map(|q| q.audio_tracks.iter().map(|t| t.id).collect()).unwrap_or_default()) } +/// `timeline.place` for one item (`item`). +fn place_one(s: &mut Session, p: &Value) -> Result { + let stream_flag = |key: &str| match p.get(key) { + None => Ok(true), + Some(Value::Bool(v)) => Ok(*v), + _ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))), + }; + let (video, audio) = (stream_flag("video")?, stream_flag("audio")?); + if !video && !audio { + return Err(bad("timeline.place", "enable video, audio, or both")); + } + let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; + let at = time_p(s, p, "").unwrap_or(s.playhead()); + let tg = s.targeting(); + let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; + let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); + // `track` is where the picture goes and `audioTrack` where the sound goes. A caller that + // names an audio track as `track` asks for the sound alone, on that track. + let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) { + (Some(t), Some(_)) if is_audio(t) => { + return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once")); + } + (_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")), + (Some(t), None) if is_audio(t) && !pi.has_audio() => { + return Err(bad("timeline.place", "`track` names an audio track and this item has no sound")); + } + (Some(t), None) if is_audio(t) => (None, Some(t)), + (Some(_), None) if !pi.has_video() => { + return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track")); + } + (v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)), + }; + let v = v.filter(|_| video); + let a = a.filter(|_| audio); + if p.get("video").is_some() && video && pi.has_video() && v.is_none() { + return Err(bad("timeline.place", "no video destination: add or enable a video track")); + } + if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() { + return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); + } + let full = match &pi.kind { + // Settings ▸ Timeline ▸ Still Image Default Duration + ItemKind::Media(m) + if matches!(m.info.kind, filmcraft_media::MediaKind::Still) + || (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) => + { + s.prefs.timeline.still_duration(s.sequence_rate()) + } + _ => pi.duration(), + }; + let (mi, mo) = match &pi.kind { + ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())), + ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())), + _ => (None, None), + }; + let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default(); + let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin); + let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?; + Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) +} + +/// Most items one `timeline.place` with `items` takes: a larger list is refused, not cut. +const MAX_PLACE_ITEMS: usize = 10_000; + +/// `timeline.place` for `items`: the first at the given time, each next one where the previous one +/// ends, all as one undo step. `track`, `audioTrack`, `insert`, `video` and `audio` apply to every +/// item; `sourceIn` and `duration` name one item's range, so they need `item`. +fn place_items(s: &mut Session, p: &Value, items: &Value) -> Result { + let ids: Vec = items + .as_array() + .ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids"))? + .iter() + .map(|v| v.as_u64().map(ItemId).ok_or_else(|| bad("timeline.place", "`items` must be a list of item ids"))) + .collect::>()?; + if p.get("item").is_some() || p.get("sourceIn").is_some() || p.get("duration").is_some() { + return Err(bad("timeline.place", "give `item` or `items`, and no `sourceIn` or `duration` with `items`")); + } + if ids.is_empty() || ids.len() > MAX_PLACE_ITEMS { + return Err(bad("timeline.place", format!("`items` needs 1 to {MAX_PLACE_ITEMS} item ids"))); + } + let mut at = time_p(s, p, "").unwrap_or(s.playhead()); + s.edit_group("Place Clips", |s| { + let mut placed = Vec::new(); + for item in ids { + let mut one = p.clone(); + if let Some(o) = one.as_object_mut() { + o.remove("items"); + for k in ["time", "frame", "seconds"] { + o.remove(k); + } + o.insert("item".into(), json!(item.0)); + o.insert("time".into(), json!(at.0)); + } + let r = place_one(s, &one)?; + let clips: Vec = r["clips"].as_array().map(|a| a.iter().filter_map(|c| c.as_u64().map(ClipId)).collect()).unwrap_or_default(); + if let Some(end) = s.active_sequence().and_then(|q| clips.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()) { + at = end; + } + placed.push(json!(clips.iter().map(|c| c.0).collect::>())); + } + Ok(json!({"clips": placed.iter().flat_map(|c| c.as_array().cloned().unwrap_or_default()).collect::>(), "placed": placed})) + }) +} + /// Place a project item on the timeline (drag from Project, or Insert/Overwrite from source). pub(crate) fn place_item( s: &mut Session, @@ -774,11 +878,22 @@ fn build() -> Vec { "Sequence…", ["File", "New"], Some("Cmd+N"), - r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?}"#, + r#"{"name":str,"width":u32=1920,"height":u32=1080,"fps":f64=23.976,"sampleRate":u32=48000,"video":n=3,"audio":n=3,"mix":"Stereo|Mono|5.1|Adaptive"?,"trackType":"Standard|Mono|5.1|Adaptive"?,"fromItem":itemId?,"fromItems":[itemId]?}"#, always, - |s, p| { + |s, p| s.edit_group("New Sequence", |s| { + // `fromItems` is `fromItem` for a list: the first sets the sequence, the rest follow it back to back + let mut from_ids: Vec = match p.get("fromItems") { + None => Vec::new(), + Some(l) => l + .as_array() + .and_then(|a| a.iter().map(|v| v.as_u64().map(ItemId)).collect()) + .ok_or_else(|| bad("file.newSequence", "`fromItems` must be a list of item ids"))?, + }; + if let Some(one) = item_p(p, "fromItem") { + from_ids.insert(0, one); + } let mut st = SequenceSettings::default(); - if let Some(from) = item_p(p, "fromItem").and_then(|i| s.project.item(i)).and_then(|i| i.as_media()) { + if let Some(from) = from_ids.first().and_then(|i| s.project.item(*i)).and_then(|i| i.as_media()) { st = default_seq_settings_for(from); } if let Some(w) = checked_u32_p(p, "width", "file.newSequence")? { @@ -828,14 +943,19 @@ fn build() -> Vec { } Ok(id) })?; - if let Some(from) = item_p(p, "fromItem") { + if let Some((&from, rest)) = from_ids.split_first() { let dur = s.project.item(from).map(|i| i.duration()).unwrap_or_default(); let tg = s.targeting(); - place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?; + let ids = + place_item(s, from, TimeRange::new(Tick::ZERO, dur), Tick::ZERO, tg.video_dest, tg.audio_dest, false, "New Sequence From Clip", None)?; + if !rest.is_empty() { + let end = s.active_sequence().and_then(|q| ids.iter().filter_map(|c| q.find_item(*c).map(|(_, i)| i.end())).max()).unwrap_or_default(); + place_items(s, &json!({"time": end.0}), &json!(rest.iter().map(|i| i.0).collect::>()))?; + } } s.events.push(crate::Event::OpenSequence(id)); Ok(json!({"sequence": id.0})) - } + }) ), cmd!("file.newBin", "Bin", ["File", "New"], Some("Cmd+B"), r#"{"name":str,"parent":binId?}"#, always, |s, p| { let n = str_p(p, "name").unwrap_or("New Bin").to_string(); @@ -2074,66 +2194,11 @@ fn build() -> Vec { "Place Clip", [], None, - r#"{"item":id,"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, + r#"{"item":id|"items":[id] (back to back, one undo step),"track":"V1"|id|"A1" (sound only)?,"audioTrack":"A1"|id?,"time":ticks|"frame":i64|"seconds":f64,"insert":bool,"sourceIn":ticks?,"duration":ticks?,"video":bool=true,"audio":bool=true}"#, has_seq, - |s, p| { - let stream_flag = |key: &str| match p.get(key) { - None => Ok(true), - Some(Value::Bool(v)) => Ok(*v), - _ => Err(bad("timeline.place", format!("`{key}` must be a boolean"))), - }; - let (video, audio) = (stream_flag("video")?, stream_flag("audio")?); - if !video && !audio { - return Err(bad("timeline.place", "enable video, audio, or both")); - } - let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; - let at = time_p(s, p, "").unwrap_or(s.playhead()); - let tg = s.targeting(); - let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; - let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); - // `track` is where the picture goes and `audioTrack` where the sound goes. A caller that - // names an audio track as `track` asks for the sound alone, on that track. - let (v, a) = match (track_p(s, p, "track", "timeline.place")?, track_p(s, p, "audioTrack", "timeline.place")?) { - (Some(t), Some(_)) if is_audio(t) => { - return Err(bad("timeline.place", "`track` names an audio track and `audioTrack` is given as well: name the sound's track once")); - } - (_, Some(t)) if !is_audio(t) => return Err(bad("timeline.place", "`audioTrack` names a video track")), - (Some(t), None) if is_audio(t) && !pi.has_audio() => { - return Err(bad("timeline.place", "`track` names an audio track and this item has no sound")); - } - (Some(t), None) if is_audio(t) => (None, Some(t)), - (Some(_), None) if !pi.has_video() => { - return Err(bad("timeline.place", "`track` names a video track and this item has no picture: name an audio track")); - } - (v, a) => (v.or(tg.video_dest), a.or(tg.audio_dest)), - }; - let v = v.filter(|_| video); - let a = a.filter(|_| audio); - if p.get("video").is_some() && video && pi.has_video() && v.is_none() { - return Err(bad("timeline.place", "no video destination: add or enable a video track")); - } - if p.get("audio").is_some() && audio && pi.has_audio() && a.is_none() { - return Err(bad("timeline.place", "no audio destination: add or enable an audio track")); - } - let full = match &pi.kind { - // Settings ▸ Timeline ▸ Still Image Default Duration - ItemKind::Media(m) - if matches!(m.info.kind, filmcraft_media::MediaKind::Still) - || (matches!(m.info.kind, filmcraft_media::MediaKind::Synthetic) && m.info.duration.0 <= 0) => - { - s.prefs.timeline.still_duration(s.sequence_rate()) - } - _ => pi.duration(), - }; - let (mi, mo) = match &pi.kind { - ItemKind::Media(m) => (m.mark_in, m.mark_out.map(|o| o + pi.frame_rate().frame_duration())), - ItemKind::Subclip { range, .. } => (Some(range.start), Some(range.end())), - _ => (None, None), - }; - let sin = p.get("sourceIn").and_then(Value::as_i64).map(Tick).or(mi).unwrap_or_default(); - let dur = p.get("duration").and_then(Value::as_i64).map(Tick).unwrap_or_else(|| mo.unwrap_or(full) - sin); - let ids = place_item(s, item, TimeRange::new(sin, dur), at, v, a, bool_p(p, "insert").unwrap_or(false), "Place Clip", None)?; - Ok(json!({"clips": ids.iter().map(|c| c.0).collect::>()})) + |s, p| match p.get("items") { + Some(items) => place_items(s, p, items), + None => place_one(s, p), } ), cmd!("timeline.select", "Select Clips", [], None, r#"{"clips":[id],"transitions":[id]?,"add":bool,"toggle":bool}"#, has_seq, |s, p| { diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index eea55b32..303102e5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -799,6 +799,39 @@ impl Session { Ok(r) } + /// Run several undoable edits as one undo step labelled `label`. If `f` fails, every edit it made + /// is rolled back and the history is as before. + pub fn edit_group(&mut self, label: &str, f: impl FnOnce(&mut Session) -> Result) -> Result { + let (project, state, undo, redo, limit) = + (self.project.clone(), self.state.clone(), self.history.undo.len(), self.history.redo.clone(), self.history.limit); + // keep every step the group makes, however many, so the first one can be found afterwards + self.history.limit = usize::MAX; + let r = f(self); + self.history.limit = limit; + let first = self.history.undo.get(undo).map(|e| e.1.clone()); + match (r, first) { + (Ok(v), Some(first)) => { + self.history.undo.truncate(undo); + self.history.undo.push((label.to_string(), first)); + if self.history.undo.len() > limit { + self.history.undo.remove(0); + } + self.history.merge_key = None; + Ok(v) + } + (Ok(v), None) => Ok(v), + (Err(e), _) => { + self.history.undo.truncate(undo); + self.history.redo = redo; + self.history.merge_key = None; + self.project = project; + self.state = state; + self.bump(); + Err(e) + } + } + } + /// Edit the active sequence with the edit-algebra context. pub fn edit_sequence(&mut self, label: &str, f: impl FnOnce(&mut Sequence, &mut EditCtx, &mut EditorState) -> Result) -> Result { self.edit_sequence_as(label, None, f) @@ -1088,6 +1121,8 @@ mod panels_tests; #[cfg(test)] mod par_tests; #[cfg(test)] +mod place_items_tests; +#[cfg(test)] mod presets_tests; #[cfg(test)] mod previews_tests; diff --git a/crates/engine/src/place_items_tests.rs b/crates/engine/src/place_items_tests.rs new file mode 100644 index 00000000..ac0088cb --- /dev/null +++ b/crates/engine/src/place_items_tests.rs @@ -0,0 +1,109 @@ +//! `timeline.place` with `items` and `file.newSequence` with `fromItems`: several items back to +//! back, as one undo step. + +use filmcraft_project::ItemId; +use serde_json::json; + +use crate::Session; + +/// A session on an empty sequence, and the ids of three media items with picture and sound. +fn three() -> (Session, Vec) { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let items: Vec = s.project.items.values().filter(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).take(3).collect(); + assert!(items.len() >= 2, "the demo project has media to place"); + s.execute("file.newSequence", json!({"name": "Back to back", "video": 2, "audio": 2})).unwrap(); + (s, items) +} + +fn ends(s: &Session) -> Vec<(i64, i64)> { + let q = s.active_sequence().unwrap(); + q.video_tracks[0].items.iter().map(|i| (i.start.0, i.end().0)).collect() +} + +/// Dragging several selected items placed them one by one, each one an undo step. +#[test] +fn items_land_back_to_back_in_one_undo_step() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"items": items.iter().map(|i| i.0).collect::>(), "time": 0})).unwrap(); + assert_eq!(r["placed"].as_array().unwrap().len(), items.len()); + let spans = ends(&s); + assert_eq!(spans.len(), items.len()); + assert_eq!(spans[0].0, 0); + for w in spans.windows(2) { + assert_eq!(w[0].1, w[1].0, "each item starts where the one before ends"); + } + assert_eq!(s.history.undo.len(), steps + 1, "one undo step for all of them"); + assert_eq!(s.history.undo.last().map(|u| u.0.as_str()), Some("Place Clips")); + s.undo(); + assert!(ends(&s).is_empty(), "one undo takes them all off"); + s.redo(); + assert_eq!(ends(&s), spans); +} + +/// One bad item in the list leaves the sequence and the history as they were. +#[test] +fn a_failing_item_places_nothing() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let list = vec![items[0].0, u64::MAX - 1]; + assert!(s.execute("timeline.place", json!({"items": list, "time": 0})).is_err()); + assert!(ends(&s).is_empty()); + assert_eq!(s.history.undo.len(), steps); +} + +/// `items` is checked: not a list, empty, mixed with `item`, or with one item's range. +#[test] +fn items_are_checked() { + let (mut s, items) = three(); + let one = items[0].0; + for p in [ + json!({"items": one}), + json!({"items": []}), + json!({"items": ["x"]}), + json!({"items": [one], "item": one}), + json!({"items": [one], "duration": 5}), + json!({"items": [one], "sourceIn": 5}), + ] { + assert!(s.execute("timeline.place", p.clone()).is_err(), "{p}"); + } + assert!(ends(&s).is_empty()); +} + +/// A new sequence from several items is one undo step: the sequence and its clips go together. +#[test] +fn new_sequence_from_items_is_one_undo_step() { + let (mut s, items) = three(); + let steps = s.history.undo.len(); + let sequences = s.project.sequences().count(); + s.execute("file.newSequence", json!({"fromItems": items.iter().map(|i| i.0).collect::>()})).unwrap(); + assert_eq!(ends(&s).len(), items.len()); + assert_eq!(s.history.undo.len(), steps + 1); + s.undo(); + assert_eq!(s.project.sequences().count(), sequences); + assert!(s.execute("file.newSequence", json!({"fromItems": 3})).is_err()); +} + +/// Times at the edge of the number range are refused or placed, never a panic, and a refusal +/// leaves the sequence and the history as they were. +#[test] +fn hostile_times_never_panic_and_leave_nothing_half_placed() { + let (mut s, items) = three(); + let ids: Vec = items.iter().map(|i| i.0).collect(); + for t in [i64::MAX, i64::MAX - 5, i64::MIN, -1] { + let steps = s.history.undo.len(); + let r = s.execute("timeline.place", json!({"items": ids, "time": t})); + if r.is_err() { + assert_eq!(s.history.undo.len(), steps, "{t}"); + assert!(ends(&s).is_empty(), "{t}"); + } else { + assert_eq!(s.history.undo.len(), steps + 1, "{t}"); + s.undo(); + } + } + // zero-length and many items: capped, not unbounded + let many = vec![items[0].0; 10_001]; + assert!(s.execute("timeline.place", json!({"items": many, "time": 0})).is_err()); + assert!(ends(&s).is_empty()); +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..9da3f04b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -158,6 +158,12 @@ pub fn dragged_project_item(ui: &egui::Ui) -> Option { _ => None, } } +/// The items a drag from the Project panel carries: the whole selection when the dragged item is +/// one of several selected, otherwise just the dragged item. +pub fn dragged_selection(app: &FilmcraftApp, item: ItemId) -> Vec { + let sel = &app.session.state.project_selection; + if sel.len() > 1 && sel.contains(&item) { sel.clone() } else { vec![item] } +} pub fn dragged_effect(ui: &egui::Ui) -> Option { match payload(ui) { Some(DragPayload::Effect(e)) => Some(e), @@ -174,7 +180,10 @@ pub fn drag_ghost(app: &FilmcraftApp, ui: &egui::Ui) { let ctx = ui.ctx(); if let Some(p) = ctx.pointer_hover_pos() { let label = match &pl { - DragPayload::Item(i) => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(), + DragPayload::Item(i) => match dragged_selection(app, *i).len() { + 1 => app.session.project.item(*i).map(|x| x.name.clone()).unwrap_or_default(), + n => format!("{n} items"), + }, DragPayload::Source(s) => { let name = app.session.project.item(s.item).map(|i| i.name.as_str()).unwrap_or("Source"); let mode = match (s.video, s.audio) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..3c34aa8d 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -568,7 +568,11 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { && ui.rect_contains_pointer(rect) && ui.input(|i| i.pointer.any_released()) { - let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); + // a multi-selection makes the sequence from its first item and follows it, back to back, in one undo step + let items: Vec = crate::panels::dragged_selection(app, item).iter().map(|i| i.0).collect(); + if let Err(e) = app.session.execute("file.newSequence", json!({"fromItems": items})) { + app.ui.status = e.to_string(); + } } } @@ -2315,15 +2319,23 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)), TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)), }; - let mut params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}); + // dragging one of several selected project items places them all, back to back, as one edit + let items = match source { + None => crate::panels::dragged_selection(app, item), + Some(_) => vec![item], + }; + let mut params = json!({"track": vt, "audioTrack": at, "time": t.0, "insert": mods.command}); + match items.as_slice() { + [one] => params["item"] = json!(one.0), + many => params["items"] = json!(many.iter().map(|i| i.0).collect::>()), + } if let Some(source) = source { params["sourceIn"] = json!(source.range.start.0); params["duration"] = json!(source.range.duration.0); params["video"] = json!(source.video); params["audio"] = json!(source.audio); } - let r = app.session.execute("timeline.place", params); - if let Err(e) = r { + if let Err(e) = app.session.execute("timeline.place", params) { app.ui.status = e.to_string(); } crate::panels::clear_drag(ui); @@ -2416,20 +2428,12 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & TrackKind::Video => (Some(row.track.0), seq.audio_tracks.get(row.index).or(seq.audio_tracks.first()).map(|t| t.id.0)), TrackKind::Audio => (seq.video_tracks.get(row.index).or(seq.video_tracks.first()).map(|t| t.id.0), Some(row.track.0)), }; - let still = app.session.prefs.timeline.still_duration(rate); - let durations: Vec = items - .iter() - .map(|&item| { - let is_still = app.session.project.item(item).and_then(|i| i.as_media()).is_some_and(|m| m.info.kind == filmcraft_media::MediaKind::Still); - app.session.project.item(item).map(|i| i.duration()).filter(|d| d.0 > 0 && !is_still).unwrap_or(still) - }) - .collect(); let start = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]); - for (item, t) in items.iter().zip(chain_starts(start, &durations)) { - let params = json!({"item": item.0, "track": vt, "audioTrack": at, "time": t.0, "insert": false}); - if let Err(e) = app.session.execute("timeline.place", params) { - app.ui.status = e.to_string(); - } + // one command, one undo step: the files go end to end from the drop point + let ids: Vec = items.iter().map(|i| i.0).collect(); + let params = json!({"items": ids, "track": vt, "audioTrack": at, "time": start.0, "insert": false}); + if let Err(e) = app.session.execute("timeline.place", params) { + app.ui.status = e.to_string(); } } } @@ -2451,19 +2455,6 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & app.auto.add("timeline.tracks", layout.content, "tracks"); } -/// Start tick of each clip when the clips are laid end to end from `start`. Saturates rather than overflows. -pub(crate) fn chain_starts(start: Tick, durations: &[Tick]) -> Vec { - let mut t = start; - durations - .iter() - .map(|d| { - let at = t; - t = Tick(t.0.saturating_add(d.0.max(0))); - at - }) - .collect() -} - #[cfg(test)] mod waveform_tests { use super::waveform_display_gain; @@ -2486,41 +2477,3 @@ mod waveform_tests { assert!((db(waveform_display_gain(1.0, -6.0)) - (-6.0)).abs() < 0.1); } } - -#[cfg(test)] -mod chain_starts_tests { - use super::chain_starts; - use filmcraft_time::Tick; - - #[test] - fn empty_list_has_no_starts() { - assert!(chain_starts(Tick(100), &[]).is_empty()); - } - - #[test] - fn one_clip_starts_at_the_drop_point() { - assert_eq!(chain_starts(Tick(7), &[Tick(30)]), vec![Tick(7)]); - } - - #[test] - fn several_clips_are_laid_end_to_end() { - let durations = [Tick(10), Tick(25), Tick(5)]; - assert_eq!(chain_starts(Tick(100), &durations), vec![Tick(100), Tick(110), Tick(135)]); - } - - #[test] - fn start_near_i64_max_saturates_without_panicking() { - let starts = chain_starts(Tick(i64::MAX - 5), &[Tick(10), Tick(10), Tick(10)]); - assert_eq!(starts, vec![Tick(i64::MAX - 5), Tick(i64::MAX), Tick(i64::MAX)]); - } - - #[test] - fn zero_length_clips_share_one_start() { - assert_eq!(chain_starts(Tick(42), &[Tick(0), Tick(0), Tick(9)]), vec![Tick(42), Tick(42), Tick(42)]); - } - - #[test] - fn negative_durations_count_as_zero() { - assert_eq!(chain_starts(Tick(5), &[Tick(-3), Tick(4)]), vec![Tick(5), Tick(5)]); - } -} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32..af5d6c04 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -626,3 +626,40 @@ fn media_browser_drag_imports_into_the_project_panel() { assert_eq!(d.app().session.project.items.len(), n0 + 1, "kept after a drop on the Timeline"); let _ = std::fs::remove_dir_all(&dir); } + +/// Dragging one of several selected Project items onto the Timeline places all of them, back to +/// back, as one undo step; an item outside the selection goes alone. +#[test] +fn dragging_a_selection_to_the_timeline_places_every_item() { + let mut d = Driver::demo(); + let (bin, items) = d.footage(); + assert!(items.len() >= 3); + d.exec("file.newSequence", json!({"name": "Drop", "video": 2, "audio": 2})); + d.ok("ui.menu.invoke", json!({"id": "projectPanel.openBin", "params": {"bin": bin, "how": "inPlace"}})); + let card = |i: u64| format!("project.item.{i}"); + d.wait_for(&card(items[0])); + let clips = |d: &mut Driver| d.app().session.active_sequence().map(|q| q.video_tracks.iter().map(|t| t.items.len()).sum::()).unwrap_or(0); + let steps = d.app().session.history.undo.len(); + let drop = json!({"id": "timeline.tracks", "fx": 0.3, "fy": 0.1}); + + // an item that is not selected: one clip + d.exec("project.select", json!({"items": [items[2]]})); + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(clips(&mut d), 1, "an unselected item goes alone"); + d.exec("edit.undo", json!({})); + + // two of the selection: both, back to back, in one undo step + d.exec("project.select", json!({"items": [items[0], items[1]]})); + d.frames(2); + d.ok("ui.drag", json!({"from": {"id": card(items[0])}, "to": drop, "steps": 16})); + d.frames(4); + assert_eq!(clips(&mut d), 2, "the whole selection is placed"); + let mut v1: Vec<(i64, i64)> = + d.app().session.active_sequence().unwrap().video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect(); + v1.sort(); + assert_eq!(v1[0].1, v1[1].0, "back to back"); + assert_eq!(d.app().session.history.undo.len(), steps + 1, "the drag is one undo step"); + d.exec("edit.undo", json!({})); + assert_eq!(clips(&mut d), 0); +} diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9f..b166962d 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -152,3 +152,27 @@ fn with_drop_import_off_the_file_is_only_imported() { assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin"); assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status); } + +/// Several files dropped at once land end to end, and one undo takes all of them off the Timeline. +#[test] +fn several_files_dropped_together_are_placed_in_one_undo_step() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + pointer_to(&mut h, at); + for name in ["drop_many_a.png", "drop_many_b.png", "drop_many_c.png"] { + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still(name)))); + } + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (3, 0), "status: {}", h.state().ui.status); + let spans: Vec<(i64, i64)> = { + let q = h.state().session.active_sequence().unwrap(); + let mut v: Vec<_> = q.video_tracks.iter().flat_map(|t| t.items.iter()).map(|i| (i.start.0, i.end().0)).collect(); + v.sort(); + v + }; + assert!(spans.windows(2).all(|w| w[0].1 == w[1].0), "end to end: {spans:?}"); + h.state_mut().session.execute("edit.undo", serde_json::json!({})).unwrap(); + assert_eq!(clip_counts(&h), (0, 0), "one undo removes every placed clip"); +} From 0c2cc293bccdaf295c473209a3387bd4a85dff51 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:22:28 +0200 Subject: [PATCH 022/203] #342: translate the ProRes 4444 export presets i18n::tests::spanish_translates_dynamic_names lists every built-in export preset's name and description; the three new ProRes 4444 presets had no Spanish entry, so ui-egui's lib tests failed. Adds them to es, ja and zh-cn. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 6 ++++++ crates/ui-egui/src/i18n/ja.tsv | 6 ++++++ crates/ui-egui/src/i18n/zh-cn.tsv | 6 ++++++ 3 files changed, 18 insertions(+) diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..681fe92d 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2975,6 +2975,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 con alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application Aplicación Apply Guide Template Aplicar plantilla de guías Auto Save Now Guardar automáticamente ahora @@ -3184,6 +3187,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits y 48 kHz QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits y 48 kHz QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) con canal alfa, PCM de 24 bits y 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM de 24 bits y 48 kHz QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits y 48 kHz Quit FilmCraft Salir de FilmCraft Rate Stretch Tool Herramienta Ajuste de velocidad diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..be1f78a2 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2970,6 +2970,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444(アルファ付き) + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application アプリケーション Apply Guide Template ガイドテンプレートを適用 Auto Save Now 今すぐ自動保存 @@ -3179,6 +3182,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime、ProRes 422 HQ、24-bit 48 kHz PCM QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime、ProRes 422 LT、24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime、ProRes 422 Proxy、24-bit 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4)、24-bit 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime、ProRes 4444 (4:4:4) アルファチャンネル付き、24-bit 48 kHz PCM + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime、ProRes 4444 XQ (4:4:4)、24-bit 48 kHz PCM QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime、ProRes 422、24-bit 48 kHz PCM Quit FilmCraft FilmCraft を終了 Rate Stretch Tool レートストレッチツール diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..44e1de7d 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2963,6 +2963,9 @@ Apple ProRes 422 HQ Apple ProRes 422 HQ Apple ProRes 422 LT Apple ProRes 422 LT Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444(带 Alpha) + Apple ProRes 4444 XQ Apple ProRes 4444 XQ Application 应用程序 Apply Guide Template 应用参考线模板 Auto Save Now 立即自动保存 @@ -3172,6 +3175,9 @@ QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime,ProRes 422 HQ,24 位 48 kHz PCM QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime,ProRes 422 LT,24 位 48 kHz PCM QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime,ProRes 422 Proxy,24 位 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),24 位 48 kHz PCM + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime,ProRes 4444 (4:4:4),带 Alpha 通道,24 位 48 kHz PCM + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime,ProRes 4444 XQ (4:4:4),24 位 48 kHz PCM QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime,ProRes 422,24 位 48 kHz PCM Quit FilmCraft 退出 FilmCraft Rate Stretch Tool 速率伸缩工具 From 7f4bf2157b28437200b651fa067c6b0c69dbd91e Mon Sep 17 00:00:00 2001 From: Manuel Camacho Date: Sat, 10 Oct 2026 10:46:20 -0700 Subject: [PATCH 023/203] HW6: AV1 export format, enabled where a hardware encoder registers one (#30) Format::Av1 in export settings and jobs, SampleEntry::av1 (av01 + av1C) in isobmff, and the Export panel's Format list, profile and encoder rows. No encoder ships here: like H.265, AV1 is listed greyed out until a platform encoder registers a probe, and a job without one fails with "AV1 encoder not available yet". Split out of #409; the VA-API AV1 encoder follows on main's vaapi::va design. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/job.rs | 9 ++++-- crates/export/src/lib.rs | 27 +++++++++++++--- crates/export/src/settings.rs | 16 ++++++--- crates/export/src/settings_tests.rs | 33 ++++++++++++++++++- crates/isobmff/src/codec.rs | 4 +++ crates/ui-egui/src/i18n/es.tsv | 2 ++ crates/ui-egui/src/i18n/ja.tsv | 2 ++ crates/ui-egui/src/i18n/zh-cn.tsv | 2 ++ crates/ui-egui/src/panels/export_mode.rs | 41 +++++++++++++----------- 9 files changed, 106 insertions(+), 30 deletions(-) diff --git a/crates/export/src/job.rs b/crates/export/src/job.rs index 88864eaf..1154f99f 100644 --- a/crates/export/src/job.rs +++ b/crates/export/src/job.rs @@ -72,7 +72,7 @@ use rayon::prelude::*; use crate::audio_out::AudioOut; use crate::mxf_out::{MxfMux, MxfSetup}; use crate::pipeline::Pipeline; -use crate::settings::{AudioCodec, BitrateMode, Multiplexer}; +use crate::settings::{AudioCodec, BitrateMode}; use crate::{ AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, Progress, Report, Result, Stage, VideoEncoder, audio_factories, export_range, frame_span, note_stage, timed, video_factories, @@ -186,7 +186,10 @@ pub struct Exporter { /// Whether [`Exporter`] handles a format. pub fn stepped(format: Format) -> bool { - matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom) + matches!( + format, + Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom + ) } /// Whether an export is HDR: an HDR working space, SDR not asked for, and a format whose encoder @@ -249,7 +252,7 @@ impl Exporter { progress.set_status(format!("Exporting {} frames ({})", nframes, settings.format.label())); } let venc = make_venc(&settings, pipe.w, pipe.h, pipe.rate)?; - let brand = if settings.format.is_h26x() && settings.multiplexer == Multiplexer::Mp4 { Brand::Mp4 } else { Brand::Mov }; + let brand = if settings.format.is_mp4_with(settings.multiplexer) { Brand::Mp4 } else { Brand::Mov }; let audio = if settings.has_audio() { Some(AudioOut::new(project.clone(), seq, &settings, range)?) } else { None }; let aenc: Option> = match (&audio, settings.audio_codec()) { (Some(a), AudioCodec::Aac) => { diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 312d0301..48b1ca2e 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -70,6 +70,10 @@ pub enum Format { /// ([`hdr_available`]); elsewhere they are tone-mapped to SDR. #[serde(rename = "hevc", alias = "Hevc")] Hevc, + /// MPEG-4, AV1 Main (8-bit) video + AAC audio. Like H.265 it has no built-in encoder: offered + /// where a hardware encoder registers one. + #[serde(rename = "av1", alias = "Av1")] + Av1, /// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM. #[serde(rename = "prores", alias = "ProRes")] ProRes, @@ -112,6 +116,7 @@ impl Format { Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() { "h264" | "mp4" | "avc" | "m4v" => Format::H264, "hevc" | "h265" | "hvc1" | "hev1" | "x265" => Format::Hevc, + "av1" | "av01" => Format::Av1, "prores" | "mov" | "appleprores" => Format::ProRes, "dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr, "apv" | "apv1" => Format::Apv, @@ -132,6 +137,7 @@ impl Format { match self { Format::H264 => "h264", Format::Hevc => "hevc", + Format::Av1 => "av1", Format::ProRes => "prores", Format::DnxHr => "dnxhr", Format::Apv => "apv", @@ -148,7 +154,7 @@ impl Format { } pub fn extension(self) -> &'static str { match self { - Format::H264 | Format::Hevc => "mp4", + Format::H264 | Format::Hevc | Format::Av1 => "mp4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov", Format::PngSequence => "png", Format::TiffSequence => "tif", @@ -163,6 +169,7 @@ impl Format { match self { Format::H264 => "H.264", Format::Hevc => "H.265 (HEVC)", + Format::Av1 => "AV1", Format::ProRes => "Apple ProRes", Format::DnxHr => "Avid DNxHR", Format::Apv => "APV", @@ -184,15 +191,24 @@ impl Format { /// Whether the crate carries an encoder for the format; the others need one registered at runtime /// ([`register_format_probe`]). pub fn has_builtin_encoder(self) -> bool { - self != Format::Hevc + !matches!(self, Format::Hevc | Format::Av1) } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) } - pub const ALL: [Format; 14] = [ + /// A bitrate-controlled MPEG-4 video format with AAC audio: H.264, H.265 or AV1 (MPEG-4 only). + pub fn is_mp4_video(self) -> bool { + matches!(self, Format::H264 | Format::Hevc | Format::Av1) + } + /// Whether the file is MPEG-4 rather than QuickTime for `mux`. + pub fn is_mp4_with(self, mux: Multiplexer) -> bool { + self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) + } + pub const ALL: [Format; 15] = [ Format::H264, Format::Hevc, + Format::Av1, Format::ProRes, Format::DnxHr, Format::Apv, @@ -524,6 +540,9 @@ impl ExportSettings { if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass { return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); } + if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass { + return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); + } Ok(()) } } @@ -1641,7 +1660,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (total, count) } - Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => { + Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => { // Handled by the stepped exporter above; reaching here would be a dispatch bug. return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format))); } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 8e8fc7ef..0acb2f93 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -422,8 +422,8 @@ impl ExportSettings { pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, - (f, _) if f.is_h26x() && self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac, - (_, AudioCodec::Auto) if self.format.is_h26x() => AudioCodec::Aac, + (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, + (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, (_, c) => c, } @@ -451,7 +451,7 @@ impl ExportSettings { let fps = r.rate.num as f64 / r.rate.den as f64; let px = r.width as f64 * r.height as f64; let video_bps = match self.video_format() { - Format::H264 | Format::Hevc => r.target_kbps as f64 * 1000.0, + Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0, Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97, Format::DnxHr => { // nominal 1080p29.97 data rates of the DNxHR profiles (Mb/s) @@ -534,6 +534,14 @@ impl ExportSettings { }; v += &format!(", keyframe every {} frames", r.keyint); } + Format::Av1 => { + v += &format!(", AV1 Main (hardware encoder), {}", self.bitrate_mode.label()); + v += &match self.bitrate_mode { + BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)), + _ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)), + }; + v += &format!(", keyframe every {} frames", r.keyint); + } Format::ProRes => { use filmcraft_prores::Profile; v += match crate::prores_profile(&self.prores_profile) { @@ -565,7 +573,7 @@ impl ExportSettings { }; let container = match self.format { Format::H264 | Format::Hevc if self.multiplexer == Multiplexer::Mov => "QuickTime", - Format::H264 | Format::Hevc => "MP4", + Format::H264 | Format::Hevc | Format::Av1 => "MP4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime", Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(), Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence", diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index b494226b..80a53c1b 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -487,6 +487,37 @@ fn a_time_left_reads_like_a_clock() { assert!(!format_eta(Duration::MAX).is_empty(), "the largest duration cannot overflow"); } +#[test] +fn av1_is_an_mp4_format_that_needs_a_registered_encoder() { + for name in ["av1", "AV1", "av01"] { + assert_eq!(Format::from_name(name), Some(Format::Av1), "{name}"); + } + assert_eq!(Format::from_name(Format::Av1.id()), Some(Format::Av1)); + assert_eq!((Format::Av1.extension(), Format::Av1.label()), ("mp4", "AV1")); + assert!(Format::ALL.contains(&Format::Av1)); + let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "av1"})).unwrap(); + assert_eq!(s.format, Format::Av1); + assert_eq!(serde_json::to_value(&s).unwrap()["format"], "av1"); + // MP4 with AAC only: a QuickTime multiplexer setting left over from H.264 does not apply + let a = ExportSettings { format: Format::Av1, ..Default::default() }; + assert_eq!((a.audio_codec(), a.extension()), (AudioCodec::Aac, "mp4")); + let mov = ExportSettings { multiplexer: Multiplexer::Mov, ..a.clone() }; + assert_eq!((mov.audio_codec(), mov.extension()), (AudioCodec::Aac, "mp4")); + let b = a.estimate_bytes(1920, 1088, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert!((b as f64 - 25.4e6).abs() < 0.2e6, "{b}"); + let summary = a.summary(1920, 1088, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert_eq!(summary.format, "AV1 (MP4)"); + assert!(summary.video.contains("AV1 Main"), "{}", summary.video); + let e = ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..a.clone() }.validate().unwrap_err(); + assert!(e.to_string().contains("two-pass"), "{e}"); + assert!(!Format::Av1.has_builtin_encoder()); + assert!(!available(Format::Av1), "nothing registered an encoder for it"); + let (p, seq, m) = matte([0.0, 0.0, 1.0, 1.0], 160, 96, None); + let sc = Scratch::new("av1-missing"); + let err = export(&p, seq, &ExportSettings { path: sc.path("x.mp4"), include_audio: false, ..a.clone() }, &m, &Progress::default()).unwrap_err().to_string(); + assert!(err.contains("AV1") && err.contains("encoder not available"), "{err}"); +} + #[test] fn h265_is_a_format_that_needs_a_registered_encoder() { use std::sync::atomic::{AtomicBool, Ordering}; @@ -496,7 +527,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 14); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index ee926cb5..b9afd53e 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -1275,6 +1275,10 @@ impl SampleEntry { pub fn hevc(cfg: HevcConfig, width: u16, height: u16) -> Self { Self::video(FourCc(*b"hvc1"), CodecConfig::Hevc(cfg), width, height) } + /// AV1 entry (`av01`). + pub fn av1(cfg: Av1Config, width: u16, height: u16) -> Self { + Self::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), width, height) + } /// ProRes entry; `fourcc` one of `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`. pub fn prores(fourcc: FourCc, width: u16, height: u16) -> Self { let mut e = Self::video(fourcc, CodecConfig::ProRes { fourcc }, width, height); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..192dcc03 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3062,6 +3062,7 @@ Go to Sequence End Ir al final de la secuencia Go to Sequence Start Ir al inicio de la secuencia H.264 H.264 + AV1 AV1 H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sitios de video, VBR 1 pasada, 16 Mbps H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 pasada, 20 Mbps objetivo, AAC 320 kbps H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 pasadas, 18 Mbps @@ -3415,6 +3416,7 @@ Main (8-bit; Main 10 for HDR sequences) Main (8 bits; Main 10 para secuencias HDR) Encoder Codificador Hardware (H.265 has no software encoder) Hardware (H.265 no tiene codificador por software) + Hardware (AV1 has no software encoder) Hardware (AV1 no tiene codificador por software) Text Properties Propiedades de texto Text Box Height Altura del cuadro de texto Paragraph Text Texto de párrafo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 8dbf4419..ae497299 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3057,6 +3057,7 @@ Go to Sequence End シーケンスの終了位置へ移動 Go to Sequence Start シーケンスの開始位置へ移動 H.264 H.264 + AV1 AV1 H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps 動画共有サイト向けの H.264 1920×1080、VBR 1 パス、16 Mbps H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080、VBR 1 パス、ターゲット 20 Mbps、AAC 320 kbps H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080、VBR 2 パス、18 Mbps @@ -3410,6 +3411,7 @@ Main (8-bit; Main 10 for HDR sequences) メイン (8 ビット、HDR シーケンスではメイン 10) Encoder エンコーダー Hardware (H.265 has no software encoder) ハードウェア (H.265 にはソフトウェアエンコーダーがありません) + Hardware (AV1 has no software encoder) ハードウェア (AV1 にはソフトウェアエンコーダーがありません) Text Properties テキストのプロパティ Text Box Height テキストボックスの高さ Paragraph Text 段落テキスト diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..44919051 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3050,6 +3050,7 @@ Go to Sequence End 转到序列末尾 Go to Sequence Start 转到序列开头 H.264 H.264 + AV1 AV1 H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080(视频分享网站),VBR 1 遍,16 Mbps H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080,VBR 1 遍,目标 20 Mbps,AAC 320 kbps H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080,VBR 2 遍,18 Mbps @@ -3403,6 +3404,7 @@ Main (8-bit; Main 10 for HDR sequences) 主要(8 位;HDR 序列使用主要 10) Encoder 编码器 Hardware (H.265 has no software encoder) 硬件(H.265 无软件编码器) + Hardware (AV1 has no software encoder) 硬件(AV1 无软件编码器) Text Properties 文本属性 Text Box Height 文本框高度 Paragraph Text 段落文本 diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index b66ab552..0dc3fbb1 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -473,7 +473,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } if (s.has_audio() || !s.has_video() - || s.format.is_h26x() + || s.format.is_mp4_video() || matches!(s.format, Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) || s.format.is_mxf()) && !s.is_image_sequence() @@ -521,7 +521,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { if section(ui, &mut reg, &mut ex.open_sections, "effects", tl!("Effects"), &t) { pick_overlay = effects_section(ui, &mut reg, s, &t); } - if matches!(s.format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) + if matches!(s.format, Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg) && section(ui, &mut reg, &mut ex.open_sections, "metadata", tl!("Metadata"), &t) { let m = &mut s.metadata; @@ -612,17 +612,23 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T }); } match s.video_format() { - f @ (Format::H264 | Format::Hevc) => { - let hevc = f == Format::Hevc; + f @ (Format::H264 | Format::Hevc | Format::Av1) => { + // H.265 and AV1: hardware encoders only (one profile, level chosen by the encoder) + let hevc = f != Format::H264; if hevc { - // the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder - let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) { - (true, true) => tl!("Main 10 (10-bit, HDR)"), - (true, false) => tl!("Main (8-bit; Main 10 for HDR sequences)"), - (false, _) => tl!("Main (8-bit)"), - }; - row(ui, t, tl!("Profile"), |ui| ui.label(label)); - row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (H.265 has no software encoder)"))); + if f == Format::Av1 { + row(ui, t, tl!("Profile"), |ui| ui.label(tl!("Main (8-bit)"))); + row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (AV1 has no software encoder)"))); + } else { + // the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder + let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) { + (true, true) => tl!("Main 10 (10-bit, HDR)"), + (true, false) => tl!("Main (8-bit; Main 10 for HDR sequences)"), + (false, _) => tl!("Main (8-bit)"), + }; + row(ui, t, tl!("Profile"), |ui| ui.label(label)); + row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (H.265 has no software encoder)"))); + } } else { row(ui, t, tl!("Profile"), |ui| { let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High]; @@ -686,9 +692,9 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); // The system's hardware encoder where there is one (VideoToolbox on macOS, NVENC on NVIDIA - // GPUs on Windows); everything it does not take (two-pass, HDR, MXF) and every machine - // without one keeps the built-in encoder. - // H.265 has only the hardware encoder: choosing the format is the opt-in. + // GPUs on Windows, VA-API on Linux); everything it does not take (two-pass, HDR, MXF) and + // every machine without one keeps the built-in encoder. + // H.265 and AV1 have only hardware encoders: choosing the format is the opt-in. if !hevc { row(ui, t, "Hardware Encoding", |ui| { let mut on = s.hardware_encoding == HardwareEncoding::Auto; @@ -758,13 +764,12 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } } row(ui, t, tl!("Audio Format"), |ui| { - let fixed = s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf(); + let fixed = s.format.is_mp4_with(s.multiplexer) || audio_only || s.format.is_mxf(); let cur = match s.audio_codec() { AudioCodec::Aac => "AAC", _ => tl!("Uncompressed (PCM)"), }; - let labels = - vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))]; + let labels = vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !s.format.is_mp4_with(s.multiplexer))]; if fixed { ui.label(cur); } else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) { From 2cab09e69b3f4e2866ceea94585a29941a68d0b7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:29:55 +0200 Subject: [PATCH 024/203] #374: a clip's fades follow its edges on a regular trim Problem: dragging a clip's edge with the Selection tool (a regular trim) moved the edge but left a fade in from nothing / fade out to nothing where it was; only Ripple trims moved transitions along with the cut. Fix: after a regular trim, the trimmed clip's one-sided transitions are put back on its edges, and shortened when the clip gets shorter than them. Transitions between two clips are untouched (a regular trim cannot move their cut). Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 22 ++++++++++++++++++++++ crates/edit/src/tests.rs | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index 2f5a8382..1b978cf4 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -214,6 +214,26 @@ fn transitions_follow_cuts(before: &Sequence, after: &mut Sequence) { } } +/// Keep a clip's one-sided transitions (a fade in from nothing, a fade out to nothing) on its edges +/// after a regular trim moved them, no longer than the clip (#374). Transitions between two clips +/// are left alone: a regular trim cannot move their cut. +fn fades_follow_edges(track: &mut Track, clip: ClipId) { + let Some((start, end, len)) = track.item(clip).map(|i| (i.start, i.end(), i.duration)) else { return }; + for tr in &mut track.transitions { + match (tr.from, tr.to) { + (None, Some(to)) if to == clip => { + tr.duration = tr.duration.min(len); + tr.start = start; + } + (Some(from), None) if from == clip => { + tr.duration = tr.duration.min(len); + tr.start = end - tr.duration; + } + _ => {} + } + } +} + fn place(track: &mut Track, item: TrackItem) { let idx = track.items.partition_point(|i| i.start <= item.start); track.items.insert(idx, item); @@ -630,6 +650,8 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: } } transitions_follow_cuts(seq, &mut work); + } else if let Ok(track) = track_mut(&mut work, tid) { + fades_follow_edges(track, clip); } work.check().map_err(EditError::Other)?; *seq = work; diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs index ae258cb0..f8a5cb8d 100644 --- a/crates/edit/src/tests.rs +++ b/crates/edit/src/tests.rs @@ -464,6 +464,34 @@ fn transitions_stay_on_their_cuts() { fx.seq.check().unwrap(); } +#[test] +fn fades_follow_a_regular_trim() { + let mut fx = Fx::new(); + let v1 = fx.v(0); + let a = fx.put(v1, 10, 20, 10); + let fade = |id, start, from, to| Transition { + id: TransitionId(id), + effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), + start: f(start), + duration: f(6), + from, + to, + align: Default::default(), + reverse: false, + }; + fx.seq.track_mut(v1).unwrap().transitions = vec![fade(1, 10, None, Some(a)), fade(2, 24, Some(a), None)]; + let spans = |fx: &Fx| fx.seq.track(v1).unwrap().transitions.iter().map(|t| (t.start, t.duration)).collect::>(); + let mut n = fx.next; + trim(&mut fx.seq, a, Edge::In, TrimMode::Regular, f(2), &mut Fx::ctx(&mut n)).unwrap(); + trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, f(4), &mut Fx::ctx(&mut n)).unwrap(); + // the fade in starts with the clip (12), the fade out ends with it (34) + assert_eq!(spans(&fx), vec![(f(12), f(6)), (f(28), f(6))]); + // a clip trimmed shorter than its fades keeps them inside it + trim(&mut fx.seq, a, Edge::Out, TrimMode::Regular, -f(18), &mut Fx::ctx(&mut n)).unwrap(); + assert_eq!(spans(&fx), vec![(f(12), f(4)), (f(12), f(4))]); + fx.seq.check().unwrap(); +} + #[test] fn rate_stretch_and_speed() { let mut fx = Fx::new(); From 7f59211912f932e46b64bdc879fdb2f5785f702d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:31:27 +0200 Subject: [PATCH 025/203] #228: Export Frame reports a frame still loading instead of saving it blank Problem: file.exportFrame rendered and wrote the still without looking at filmcraft_media::pending. On the web, a frame WebCodecs is still decoding (or whose Blob bytes are still loading) is left out of the render, so the command succeeded with a transparent/blank picture passed off as the requested frame. Fix: clear the pending flag before rendering and check it afterwards; when a read was deferred, fail with "the frame is still loading; export it again in a moment" and write nothing. A later call exports the real frame. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/frame_export_tests.rs | 51 +++++++++++++++++++++++++ crates/engine/src/keyboard.rs | 11 +++++- 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/frame_export_tests.rs b/crates/engine/src/frame_export_tests.rs index 12702ba6..3e2a0003 100644 --- a/crates/engine/src/frame_export_tests.rs +++ b/crates/engine/src/frame_export_tests.rs @@ -101,3 +101,54 @@ fn frame_export_snapshots_are_independent_of_later_monitor_selection() { Arc::make_mut(&mut s.project).sequence_mut(seq).unwrap().settings.frame_rate = FrameRate { num: 1, den: i64::MAX }; assert!(keyboard::frame_export_target(&s, &json!({})).is_err()); } + +/// A source whose pictures are still on their way, as a WebCodecs decode or a Blob read on the web. +struct Loading(filmcraft_media::MediaInfo); + +impl filmcraft_media::MediaSource for Loading { + fn info(&self) -> &filmcraft_media::MediaInfo { + &self.0 + } + fn video_frame(&self, _: filmcraft_media::FrameRequest) -> filmcraft_media::Result> { + filmcraft_media::pending::mark(); + Err(filmcraft_media::MediaError::Decode("still decoding".into())) + } + fn audio(&self, _: i64, _: usize, _: u32) -> filmcraft_media::Result { + filmcraft_media::pending::mark(); + Err(filmcraft_media::MediaError::Decode("still loading".into())) + } +} + +#[test] +fn a_frame_still_loading_is_reported_not_saved_blank() { + let (mut s, item) = session(); + let generator = s.media.cached(item).unwrap(); + s.media.insert(item, Arc::new(Loading(generator.info().clone()))); + let dir = std::env::temp_dir().join(format!("fc-frame-loading-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + for target in ["program", "source"] { + let path = dir.join(format!("{target}.png")); + let err = s.execute("file.exportFrame", json!({"target":target,"path":path.to_string_lossy()})).unwrap_err(); + assert!(err.to_string().contains("still loading"), "{target}: {err}"); + assert!(!path.exists(), "{target}: nothing is written"); + } + // once the picture is there, the same export succeeds + s.media.insert(item, generator); + for target in ["program", "source"] { + let path = dir.join(format!("{target}.png")); + s.execute("file.exportFrame", json!({"target":target,"path":path.to_string_lossy()})).unwrap(); + assert!(path.exists(), "{target}"); + } + drop(s); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn an_earlier_deferred_read_does_not_fail_the_export() { + let (mut s, _) = session(); + filmcraft_media::pending::mark(); + let path = std::env::temp_dir().join(format!("fc-frame-stale-pending-{}.png", std::process::id())); + s.execute("file.exportFrame", json!({"target":"program","path":path.to_string_lossy()})).unwrap(); + assert!(!filmcraft_media::pending::is_set()); + std::fs::remove_file(path).unwrap(); +} diff --git a/crates/engine/src/keyboard.rs b/crates/engine/src/keyboard.rs index ad5f5d2a..57ddf72f 100644 --- a/crates/engine/src/keyboard.rs +++ b/crates/engine/src/keyboard.rs @@ -635,13 +635,20 @@ fn export_frame(s: &mut Session, p: &Value) -> Result { } }; let provider = s.media.full_res_provider(s.project.clone(), s.services.clone()); + // On the web a frame can still be decoding (WebCodecs) or its bytes loading (Blob reads): the + // render then leaves that clip out, so saving it would pass a blank or partial picture off as + // the requested frame (#228). Report it instead and write nothing; a later call succeeds. + let _ = filmcraft_media::pending::take(); let img = if target.source { filmcraft_render::render_item(&s.project, target.item, target.time, 1.0, &provider) - .ok_or_else(|| bad("file.exportFrame", "cannot decode the Source video frame"))? } else { let opts = filmcraft_render::RenderOptions { scale: 1.0, captions: true, ..Default::default() }; - filmcraft_render::render_sequence(&s.project, target.item, target.time, opts, &provider) + Some(filmcraft_render::render_sequence(&s.project, target.item, target.time, opts, &provider)) }; + if filmcraft_media::pending::take() { + return Err(EngineError::Other("the frame is still loading; export it again in a moment".into())); + } + let img = img.ok_or_else(|| bad("file.exportFrame", "cannot decode the Source video frame"))?; let (w, h) = ( u32::try_from(img.w).map_err(|_| bad("file.exportFrame", "image width exceeds limits"))?, u32::try_from(img.h).map_err(|_| bad("file.exportFrame", "image height exceeds limits"))?, From 1580ebb520c9e22d0f94047db0656d2a84d07fca Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:33:45 +0200 Subject: [PATCH 026/203] #266: quitting with unsaved changes asks to save first Problem: Quit, Cmd+Q or the window's close button exited at once with unsaved changes, leaving them to the recovery journal, so every launch after an unsaved session offered a recovery (docs/project-files.md: no "Save changes?" prompt on quit yet). Fix: a close request with unsaved changes is cancelled and the existing Save Project prompt asks Cancel / Don't Save / Save (automation ids quit.*), as in Premiere. Don't Save discards the changes and their recovery snapshot; Save on a never-saved project (here and in Close Project) opens the Save dialog and returns to the prompt when cancelled. The control channel's app.quit still quits without asking. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/control.rs | 2 + crates/ui-egui/src/lib.rs | 8 +++ crates/ui-egui/src/panels/clip_dialogs.rs | 60 +++++++++++++++---- crates/ui-egui/tests/clip_dialogs_ui.rs | 72 +++++++++++++++++++++++ docs/control-protocol.md | 2 +- docs/project-files.md | 8 ++- 6 files changed, 135 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 3cd86c35..cd408481 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -429,6 +429,8 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(Value::Null) } "app.quit" => { + // scripted quits don't wait on the save prompt; unsaved changes stay in the recovery journal + app.quit_confirmed = true; ctx.send_viewport_cmd(egui::ViewportCommand::Close); ok(Value::Null) } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0faa82e1..4c5f4ab0 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -291,6 +291,9 @@ pub struct FilmcraftApp { /// Fault injection for robustness tests: the next UI pass panics. #[doc(hidden)] pub panic_next_frame: bool, + /// The next close request quits without asking to save (Save / Don't Save was chosen in the + /// quit prompt, or the control channel's `app.quit`). + pub quit_confirmed: bool, fonts_ready: bool, pub integrated_titlebar: bool, pub last_timeline_width: f32, @@ -511,6 +514,7 @@ impl FilmcraftApp { styled: false, ui_error: None, panic_next_frame: false, + quit_confirmed: false, fonts_ready: false, integrated_titlebar: false, last_timeline_width: 1000.0, @@ -1882,6 +1886,10 @@ impl eframe::App for FilmcraftApp { self.playback.hidden = true; } self.timeline_still = if self.ui.timeline.animating() { 0 } else { self.timeline_still.saturating_add(1) }; + // Quitting with unsaved changes asks to save them first. + if ctx.input(|i| i.viewport().close_requested()) && panels::clip_dialogs::intercept_quit(self) { + ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); + } let had_synthetic = !self.synthetic.is_empty(); self.drain_control(ctx); if !self.synthetic.is_empty() && !had_synthetic { diff --git a/crates/ui-egui/src/panels/clip_dialogs.rs b/crates/ui-egui/src/panels/clip_dialogs.rs index 104961e3..2d5b8b53 100644 --- a/crates/ui-egui/src/panels/clip_dialogs.rs +++ b/crates/ui-egui/src/panels/clip_dialogs.rs @@ -1,7 +1,7 @@ //! Edit / Clip / File menu dialogs (M3.10): Paste Attributes, Remove Attributes, Offline File, //! Make Subclip, Edit Subclip, Modify ▸ Audio Channels, Modify ▸ Timecode, Frame Hold Options, -//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project -//! "save changes?" prompt. +//! Field Options, Clip Speed / Duration, Nest… (Nested Sequence Name), and the Close Project and +//! Quit "save changes?" prompts. //! //! The open dialog lives in `UiState::clip_dialog` (serde): the engine command it runs and the //! parameters being edited — the same JSON the command takes — so agents can open a dialog from @@ -26,7 +26,9 @@ //! - Field Options `fieldOptions.*`: `reverseFieldDominance`, `processing.`; //! - Clip Speed / Duration `speedDuration.*`: `speed`, `reverse`, `ripple`, //! `interpolation.`; -//! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`. +//! - Close Project `closeProject.save`, `closeProject.dontSave`, `closeProject.cancel`; +//! - Quit (asked when the window is closed with unsaved changes, see [`intercept_quit`]) +//! `quit.save`, `quit.dontSave`, `quit.cancel`. use egui::{Align2, RichText}; use filmcraft_project::{AudioChannelMap, AudioChannels, ItemKind, TrackKind}; @@ -56,6 +58,7 @@ fn meta(command: &str) -> Option<(&'static str, &'static str)> { "clip.fieldOptions" => (tl!("Field Options"), "fieldOptions"), "clip.speedDuration" => (tl!("Clip Speed / Duration"), "speedDuration"), "file.closeProject" => (tl!("Save Project"), "closeProject"), + "app.quit" => (tl!("Save Project"), "quit"), _ => return None, }) } @@ -89,7 +92,7 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Option bool { + if app.quit_confirmed || !app.session.is_dirty() { + return false; + } + if app.ui.clip_dialog.as_ref().is_none_or(|d| d.command != "app.quit") { + app.ui.clip_dialog = Some(ClipDialogDraft { command: "app.quit".into(), params: json!({}), info: Value::Null, error: String::new() }); + } + true +} + /// Initial parameters (and display info) of a command's dialog. fn defaults(app: &FilmcraftApp, id: &str) -> (Value, Value) { let s = &app.session; @@ -290,7 +306,8 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { let mut elems: Elems = Vec::new(); let mut action: Option<&'static str> = None; let project_name = app.session.project.name.clone(); - let can_save = app.session.path.is_some(); + // a never-saved project is saved through the Save dialog + let can_save = app.session.path.is_some() || app.hooks.pick_save.is_some(); // the title is translated; the window keeps one id whatever the interface language let id = egui::Id::new(("clip-dialog", pre)); egui::Window::new(title).id(id).collapsible(false).resizable(false).default_width(360.0).anchor(Align2::CENTER_CENTER, [0.0, 0.0]).show(ctx, |ui| { @@ -520,7 +537,7 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } } - "file.closeProject" => { + "file.closeProject" | "app.quit" => { ui.label(tlf!("Save changes to “{project_name}” before closing?", project_name)); ui.add_space(8.0); ui.horizontal(|ui| { @@ -586,16 +603,33 @@ pub fn show(app: &mut FilmcraftApp, ctx: &egui::Context) { } } Some("dontSave") | Some("save") => { - if action == Some("save") - && let Err(e) = app.session.execute("file.save", json!({})) - { - app.ui.status = e.to_string(); - app.ui.clip_dialog = None; - return; + if action == Some("save") { + let saved = if app.session.path.is_some() { + app.session.execute("file.save", json!({})).map(|_| ()).map_err(|e| e.to_string()) + } else { + app.file_dialog("file.save", &json!({})).map(|_| ()) + }; + if let Err(e) = saved { + app.ui.status = e; + app.ui.clip_dialog = None; + return; + } + // the Save dialog was cancelled: back to the prompt + if app.session.is_dirty() { + app.ui.clip_dialog = Some(d); + return; + } } - if let Err(e) = app.session.execute("file.closeProject", json!({"force": true})) { + // Quit ▸ Don't Save discards the changes: closing the project also clears their recovery + // snapshot, so the next launch doesn't offer them back + let close = d.command != "app.quit" || app.session.is_dirty(); + if close && let Err(e) = app.session.execute("file.closeProject", json!({"force": true})) { app.ui.status = e.to_string(); } + if d.command == "app.quit" { + app.quit_confirmed = true; + ctx.send_viewport_cmd(egui::ViewportCommand::Close); + } app.ui.clip_dialog = None; return; } diff --git a/crates/ui-egui/tests/clip_dialogs_ui.rs b/crates/ui-egui/tests/clip_dialogs_ui.rs index d02c1666..b6049371 100644 --- a/crates/ui-egui/tests/clip_dialogs_ui.rs +++ b/crates/ui-egui/tests/clip_dialogs_ui.rs @@ -88,6 +88,25 @@ impl Driver { fn item_named(&mut self, name: &str) -> u64 { self.app().session.project.items.values().find(|i| i.name == name).unwrap().id.0 } + + /// The window is asked to close (Quit, Cmd+Q, its close button) for one frame. + fn request_close(&mut self) { + self.harness.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close); + self.frames(1); + } + + /// The app sent `cmd` to the window in the last frame. + fn sent(&self, cmd: &egui::ViewportCommand) -> bool { + self.harness.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(cmd)) + } + + /// Make an unsaved change. + fn edit(&mut self) { + let c = self.v1(0); + self.exec("timeline.select", json!({"clips": [c.id.0]})); + self.menu("edit.label.yellow"); + assert!(self.app().session.is_dirty()); + } } #[test] @@ -269,3 +288,56 @@ fn speed_duration_from_the_clip_menu_opens_the_dialog() { assert_eq!(got.speed, 2.0); assert_eq!(got.time_interpolation, filmcraft_project::TimeInterpolation::FrameBlending); } + +#[test] +fn quitting_with_unsaved_changes_asks_to_save() { + let mut d = Driver::new(); + d.edit(); + // the close is cancelled and the prompt asks instead + d.request_close(); + assert!(d.sent(&egui::ViewportCommand::CancelClose), "the window must stay open"); + d.frames(2); + assert!(d.has("quit.save") && d.has("quit.dontSave") && d.has("quit.cancel")); + // Cancel keeps the app and the changes + d.click("quit.cancel"); + assert!(d.app().ui.clip_dialog.is_none()); + assert!(d.app().session.is_dirty() && !d.app().quit_confirmed); + d.request_close(); + assert!(d.sent(&egui::ViewportCommand::CancelClose), "asks again"); + d.frames(2); + // Don't Save discards the changes (and their recovery snapshot) and closes the window + d.click("quit.dontSave"); + assert!(d.app().quit_confirmed && !d.app().session.is_dirty()); + assert!(d.app().ui.clip_dialog.is_none()); + d.request_close(); + assert!(!d.sent(&egui::ViewportCommand::CancelClose), "the confirmed quit goes through"); +} + +#[test] +fn quit_save_on_a_new_project_uses_the_save_dialog_and_scripted_quit_does_not_ask() { + let path = std::env::temp_dir().join(format!("fc-quit-save-{}.fcproj", std::process::id())); + let _ = std::fs::remove_file(&path); + let mut d = Driver::new(); + d.app().session.path = None; + d.edit(); + let picked = path.to_string_lossy().into_owned(); + let mut answers = vec![None, Some(picked)]; + d.app().hooks.pick_save = Some(Box::new(move |_| if answers.is_empty() { None } else { answers.remove(0) })); + d.request_close(); + d.frames(2); + // the Save dialog is cancelled: back to the prompt, nothing quits + d.click("quit.save"); + assert!(d.app().ui.clip_dialog.is_some() && !d.app().quit_confirmed); + // saved: the project is written and the window closes + d.click("quit.save"); + assert!(d.app().quit_confirmed && !d.app().session.is_dirty()); + assert!(path.exists(), "saved to the chosen file"); + let _ = std::fs::remove_file(&path); + d.request_close(); + assert!(!d.sent(&egui::ViewportCommand::CancelClose)); + // the control channel's app.quit quits without the prompt + let mut d = Driver::new(); + d.edit(); + d.ok("app.quit", json!({})); + assert!(d.app().quit_confirmed); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 28b86c30..2a287a88 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | -| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | +| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus. `app.quit` quits without the save prompt (unsaved changes stay in the recovery journal); the command `app.quit` (Quit, Cmd+Q) asks first | | `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which a standalone export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | **Timeline thumbnails.** `ui.set {timeline: {thumbnails}}` sets the Timeline wrench menu's Show Video diff --git a/docs/project-files.md b/docs/project-files.md index 5c1b08fd..9f5be728 100644 --- a/docs/project-files.md +++ b/docs/project-files.md @@ -152,9 +152,11 @@ Premiere's auto-save can lose up to one interval of work. FilmCraft also keeps a over the original file. The recovered state goes into the new session's own journal before the old journal is deleted. - A normal quit with nothing unsaved removes the session directory. Quitting with unsaved changes - keeps the snapshot, marked `cleanExit`, and FilmCraft offers it on the next launch ("FilmCraft was - closed while … had unsaved changes"). There is no "Save changes?" prompt on quit yet, so this - keeps the work. + (Quit, Cmd+Q or the window's close button) first asks *"Save changes to 'X' before closing?"* + with **Cancel**, **Don't Save** and **Save** (Save on a never-saved project opens the Save dialog); + Don't Save discards the changes and their snapshot. A quit that does not ask (the control + channel's `app.quit`, the OS ending the session) keeps the snapshot, marked `cleanExit`, and FilmCraft offers it on the next launch ("FilmCraft was + closed while … had unsaved changes"). Desktop flags: `--recover` recovers the newest candidate without asking. `--no-recover` starts without asking; the changes stay available through File ▸ Recover Unsaved Changes…. `--data-dir From 1ff2b9fc8944a13aad8f69f9bb434632db6f1335 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:37:09 +0200 Subject: [PATCH 027/203] #284: panel menu Maximize Frame can be restored; an empty dock reopens panels Problem: the panel menu's Maximize Frame replaced the whole dock tree with a one-panel group, so there was no way back short of resetting the workspace, and closing that panel left an empty dock. DockNode::open_near only inserted beside the requested neighbour, Project or Program, so with those gone (or no panel left) every Window > panel command was a silent no-op. Fix: the panel menu now toggles the same Maximize/Restore Frame state as ` / Shift+` (labelled Restore Frame while maximized) and keeps the tree; a maximized panel that was closed counts as restored, and switching workspace clears it. open_near falls back to the first group, even an empty one, as its doc comment promised. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dock.rs | 36 +++++++++++++++++++++++++-- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/lib.rs | 2 ++ crates/ui-egui/src/panels/keyboard.rs | 7 ++++-- crates/ui-egui/src/panels/mod.rs | 8 ++++-- crates/ui-egui/tests/panels_ui.rs | 31 +++++++++++++++++++++++ 8 files changed, 81 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 05acf45b..ef680f5d 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -401,8 +401,19 @@ impl DockNode { } } } - if !add(self, p, near) && !add(self, p, PanelKind::Project) { - add(self, p, PanelKind::Program); + /// The first group of the tree, even an empty one (closing the last panel leaves the + /// root as an empty group: a panel opened then must still have somewhere to go). + fn add_first(n: &mut DockNode, p: PanelKind) { + match n { + DockNode::Split { a, .. } => add_first(a, p), + DockNode::Tabs { panels, active } => { + panels.push(p); + *active = panels.len() - 1; + } + } + } + if !add(self, p, near) && !add(self, p, PanelKind::Project) && !add(self, p, PanelKind::Program) { + add_first(self, p); } } } @@ -782,4 +793,25 @@ mod tests { let back: DockNode = serde_json::from_str(&s).unwrap(); assert_eq!(back, d); } + + /// #284: with Project and Program closed (or every panel closed) a panel still opens. + #[test] + fn open_near_falls_back_to_the_first_group() { + let mut d = workspace("Editing"); + let mut all = Vec::new(); + d.panels(&mut all); + for p in all { + d.close(p); + } + assert_eq!(d, DockNode::Tabs { panels: vec![], active: 0 }); + d.open_near(PanelKind::Program, PanelKind::Project); + assert!(d.is_visible(PanelKind::Program)); + d.restore_timeline(); + assert!(d.is_visible(PanelKind::Timeline)); + // a tree without Project or Program: the panel joins the first group + let mut d = hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects], 0), tabs(&[PanelKind::Markers], 0)); + d.open_near(PanelKind::Info, PanelKind::Source); + assert!(d.is_visible(PanelKind::Info)); + assert_eq!(d, hsplit(SplitSize::Ratio(0.5), tabs(&[PanelKind::Effects, PanelKind::Info], 1), tabs(&[PanelKind::Markers], 0))); + } } diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f45ce9d..4aae9a52 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -497,6 +497,7 @@ Close Panel Cerrar panel Close Other Timeline Panels Cerrar los demás paneles de línea de tiempo Maximize Frame Maximizar marco + Restore Frame Restaurar marco Restore Workspace Restaurar espacio de trabajo Reveal Sequence in Project Mostrar secuencia en el proyecto Video Thumbnails Miniaturas de video diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3d54f873..d94a5e41 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -495,6 +495,7 @@ Close Panel パネルを閉じる Close Other Timeline Panels 他のタイムラインパネルを閉じる Maximize Frame パネルを最大化 + Restore Frame パネルを元に戻す Restore Workspace ワークスペースを復元 Reveal Sequence in Project プロジェクトでシーケンスを表示 Video Thumbnails ビデオサムネイル diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index e504e341..30b2831f 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -488,6 +488,7 @@ Close Panel 关闭面板 Close Other Timeline Panels 关闭其他时间轴面板 Maximize Frame 最大化帧 + Restore Frame 还原帧 Restore Workspace 恢复工作区 Reveal Sequence in Project 在项目面板中显示序列 Video Thumbnails 视频缩略图 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 0faa82e1..6ec07e12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -752,6 +752,8 @@ impl FilmcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); + // a workspace is shown whole: no frame stays maximized over it + self.ui.keys.maximized = None; self.ui.dock = dock::saved_layout(&self.workspaces, name); if self.workspaces.current != name { let mut next = self.workspaces.clone(); diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 674769d6..55ee8031 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -232,8 +232,11 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val // ------------------------------------------------------------------ frames and panels -fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { - app.ui.keys.maximized = if app.ui.keys.maximized.is_some() { None } else { Some(p) }; +/// Maximize `p`, or restore the layout when a panel is maximized (also the panel menu's Maximize / +/// Restore Frame). A maximized panel that has since been closed counts as restored. +pub(crate) fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { + let current = app.ui.keys.maximized.filter(|q| app.ui.dock.contains(*q)); + app.ui.keys.maximized = if current.is_some() { None } else { Some(p) }; if let Some(p) = app.ui.keys.maximized { app.ui.dock.activate(p); app.ui.focused = p; diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..4351e4fc 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -244,8 +244,12 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { app.ui.dock.close(p); close = true; } - if ui.button(tl!("Maximize Frame")).clicked() { - app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![p], active: 0 }; + // the same frame state as ` / Shift+`: the dock tree is kept, so Restore brings it back + let maximized = app.ui.keys.maximized.is_some_and(|q| app.ui.dock.contains(q)); + let r = ui.button(if maximized { tl!("Restore Frame") } else { tl!("Maximize Frame") }); + app.auto.add(&format!("panel.menu.{}.maximize", p.id()), r.rect, if maximized { "Restore Frame" } else { "Maximize Frame" }); + if r.clicked() { + keyboard::toggle_maximize(app, p); close = true; } if ui.button(tl!("Restore Workspace")).clicked() { diff --git a/crates/ui-egui/tests/panels_ui.rs b/crates/ui-egui/tests/panels_ui.rs index e395f4a8..836a8f94 100644 --- a/crates/ui-egui/tests/panels_ui.rs +++ b/crates/ui-egui/tests/panels_ui.rs @@ -177,6 +177,37 @@ fn every_panel_opens_from_the_window_menu() { } } +/// #284 point 3: the panel menu's Maximize Frame keeps the layout (Restore Frame brings it back), +/// and closing every panel still leaves a dock that Window ▸ can reopen panels into. +#[test] +fn panel_menu_maximize_restores_and_an_empty_dock_reopens() { + use filmcraft_ui_egui::dock::{DockNode, PanelKind}; + let mut d = Driver::demo(); + let layout = d.app().ui.dock.clone(); + d.click("panel.menu.Program"); + assert_eq!(d.label("panel.menu.Program.maximize"), "Maximize Frame"); + d.click("panel.menu.Program.maximize"); + assert_eq!(d.app().ui.keys.maximized, Some(PanelKind::Program)); + assert!(!d.has("panel.Timeline"), "only the maximized panel is shown"); + d.click("panel.menu.Program"); + assert_eq!(d.label("panel.menu.Program.maximize"), "Restore Frame"); + d.click("panel.menu.Program.maximize"); + assert_eq!(d.app().ui.keys.maximized, None); + assert_eq!(d.app().ui.dock, layout, "the layout survives maximize / restore"); + assert!(d.has("panel.Timeline")); + + let mut all = Vec::new(); + layout.panels(&mut all); + for p in all { + d.ok("ui.panel.close", json!({"panel": p.id()})); + } + assert_eq!(d.app().ui.dock, DockNode::Tabs { panels: vec![], active: 0 }); + d.ok("ui.menu.invoke", json!({"id": "window.panel.Program"})); + d.ok("ui.menu.invoke", json!({"id": "window.panel.Timeline"})); + d.frames(3); + assert!(d.has("panel.tab.Program") && d.has("panel.tab.Timeline")); +} + #[test] fn lumetri_scopes_layouts_menu_and_footer() { let mut d = Driver::demo(); From 13d8e73e8ef288ae86c9743186a02d0838643508 Mon Sep 17 00:00:00 2001 From: Tommaso Maccagni Date: Sat, 10 Oct 2026 17:22:04 +0200 Subject: [PATCH 028/203] export: a frame planned as one CPU image does not take a pooled GPU renderer With GPU rendering on, a frame the planner hands back whole (an adjustment layer, a complex transition, an HDR / wide-gamut sequence) was still rendered by render_sequence, but inside RendererPool::with: as many frames at a time as there are pooled renderers (2 to 4) instead of one per export thread. A timeline graded on an adjustment layer exported slower with Auto than with Off. Pipeline::frame asks the planner first (plan::is_cpu_frame, the test plan_frame itself makes) and renders such a frame on the CPU directly. It counts as a fallback now, not as a GPU frame. The picture does not change. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/pipeline.rs | 8 ++++++-- crates/export/src/tests.rs | 30 ++++++++++++++++++++++++++++++ crates/render/src/plan.rs | 21 +++++++++++++++------ 3 files changed, 51 insertions(+), 8 deletions(-) diff --git a/crates/export/src/pipeline.rs b/crates/export/src/pipeline.rs index 306aef2f..a2c4db22 100644 --- a/crates/export/src/pipeline.rs +++ b/crates/export/src/pipeline.rs @@ -213,8 +213,12 @@ impl Pipeline { pub fn frame(&self, f: i64, sources: &dyn SourceProvider) -> (Vec, Vec) { let t = self.rate.tick_of(f); // The registered GPU renderer goes first; `None` (no adapter, a plan the GPU cannot - // draw, any internal error) falls back to the CPU reference renderer below. - let gpu = match self.renderer.as_ref() { + // draw, any internal error) falls back to the CPU reference renderer below. A frame the + // planner hands back as one CPU image anyway (an adjustment layer, a complex transition, + // HDR) does not take a pooled renderer: it would render on the CPU inside the pool, as + // many frames at a time as there are renderers instead of one per export thread. + let pool = self.renderer.as_ref().filter(|_| !filmcraft_render::plan::is_cpu_frame(&self.project, self.seq, t)); + let gpu = match pool { Some(pool) => { let asked = web_time::Instant::now(); pool.with(|r| { diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 656ddd9f..ef7b182a 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -357,6 +357,36 @@ fn gpu_hook_scenarios() { } // at most one panic per pooled renderer (4): none is used again after it panicked assert!(PANICS.load(std::sync::atomic::Ordering::SeqCst) <= 4, "renderer reused after a panic: {}", PANICS.load(std::sync::atomic::Ordering::SeqCst)); + + // 5. A frame the planner hands back as one CPU image (an adjustment layer over the clip from + // frame 12) is rendered by the CPU without taking a pooled renderer: it counts as a + // fallback, and the renderer's picture reaches only the frames before it. + crate::reset_frame_renderers_for_tests(); + crate::register_frame_renderer(|| Some(Box::new(FakeRenderer) as Box)); + let (p, seq, m) = project(); + let mut p = Arc::unwrap_or_clone(p); + let r = FrameRate::FPS_24; + let adj = p.add_item("adj", Label::Iris, ItemKind::AdjustmentLayer { width: 320, height: 180, rate: r, duration: Tick(TICKS_PER_SECOND) }, None); + let over = p.make_track_item(adj, TrackKind::Video, r.tick_of(12), TimeRange::new(Tick::ZERO, r.tick_of(12)), r).unwrap(); + let v2 = filmcraft_project::TrackId(p.alloc_id()); + let q = p.sequence_mut(seq).unwrap(); + q.video_tracks.push(filmcraft_project::Track::new(v2, TrackKind::Video, "Video 2".into())); + q.video_tracks[1].items.push(over); + let p = Arc::new(p); + let mk24 = |path: &String, gpu: crate::GpuRendering| { + let mut s = ExportSettings { format: Format::PngSequence, path: path.clone(), ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, r.tick_of(24))); + s.gpu_rendering = gpu; + export(&p, seq, &s, &m, &Progress::default()).unwrap() + }; + mk24(&tmp("adj-off.png"), crate::GpuRendering::Off); + mk24(&tmp("adj-auto.png"), crate::GpuRendering::Auto); + let frame = |name: &str, f: &str| std::fs::read(tmp(&format!("{name}{f}.png"))).unwrap(); + assert_ne!(frame("adj-off", "011"), frame("adj-auto", "011"), "a layered frame goes to the renderer"); + for f in ["012", "023"] { + assert_eq!(frame("adj-off", f), frame("adj-auto", f), "frame {f}: an adjustment layer frame is the CPU's"); + } + assert_eq!((gpu_render_stats().frames, gpu_render_stats().fallbacks), (12, 12), "{:?}", gpu_render_stats()); crate::reset_frame_renderers_for_tests(); } diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index f986711c..92b333cd 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -191,12 +191,7 @@ fn gpu_chain<'a>(project: &Project, item: &'a TrackItem, opts: RenderOptions) -> pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOptions, sources: &dyn SourceProvider) -> FramePlan { let Some(seq) = project.sequence(seq_id) else { return FramePlan::Image(crate::Image::new(1, 1)) }; let (w, h) = output_size(seq, opts.scale); - // HDR / wide-gamut sequences composite and convert on the CPU. - if !seq.settings.color.is_plain() { - return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); - } - // Whole-frame fallback: adjustment layers or complex transitions anywhere at t. - if !layered_at(project, seq, t) { + if whole_frame_on_cpu(project, seq, t) { return FramePlan::Image(crate::render_sequence(project, seq_id, t, opts, sources)); } let mut layers = PlanStack::default(); @@ -214,6 +209,20 @@ pub fn plan_frame(project: &Project, seq_id: ItemId, t: Tick, opts: RenderOption layers.finish(w, h) } +/// Whether [`plan_frame`] hands the frame at `t` back as one CPU image ([`FramePlan::Image`]). A +/// GPU renderer gains nothing on such a frame, so a caller with only a few of them (the export's +/// pool) can render it on the CPU without taking one. +pub fn is_cpu_frame(project: &Project, seq_id: ItemId, t: Tick) -> bool { + project.sequence(seq_id).is_none_or(|seq| whole_frame_on_cpu(project, seq, t)) +} + +/// The frames [`plan_frame`] does not plan: an HDR / wide-gamut sequence composites and converts +/// on the CPU, and so does a frame with an adjustment layer or a complex transition anywhere at +/// `t` (whole-frame fallback). +fn whole_frame_on_cpu(project: &Project, seq: &Sequence, t: Tick) -> bool { + !seq.settings.color.is_plain() || !layered_at(project, seq, t) +} + /// Whether the frame of `seq` at `t` can be planned as layers: no adjustment layer and no /// transition the compositor cannot mix itself is showing. fn layered_at(project: &Project, seq: &Sequence, t: Tick) -> bool { From d215d0e8a3390d9f15e5b538c67de6763e39716b Mon Sep 17 00:00:00 2001 From: Tommaso Maccagni Date: Sat, 10 Oct 2026 17:22:56 +0200 Subject: [PATCH 029/203] gpu: a build without a wgpu backend exports on the CPU instead of failing filmcraft-gpu asks wgpu for no backend (`default-features = false`); a build that includes the app gets them through eframe. A CLI built on its own (`cargo run --release -p filmcraft-cli`, as the README and docs/contributing.md do) has none, and `wgpu::Instance::default()` panics there: an export with GPU rendering on ended as "internal error (see the crash log); the job was stopped". ExportRenderer::new returns None in such a build, like a host without an adapter. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/export_renderer.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/gpu/src/export_renderer.rs b/crates/gpu/src/export_renderer.rs index b97a5acf..f86f51a8 100644 --- a/crates/gpu/src/export_renderer.rs +++ b/crates/gpu/src/export_renderer.rs @@ -98,12 +98,19 @@ pub struct ExportRenderer { } impl ExportRenderer { - /// The off-screen device, or `None` when this host has no usable adapter. + /// The off-screen device, or `None` when this host has no usable adapter or this build no + /// wgpu backend. pub fn new() -> Option { // `None` is kept too: a machine without an adapter does not search again on every export static SHARED: std::sync::OnceLock> = std::sync::OnceLock::new(); let (instance, device, queue) = SHARED .get_or_init(|| { + // A build without a wgpu backend for this platform (`cargo build -p filmcraft-cli` + // alone: this crate asks for none) has no GPU to offer, and `Instance::default()` + // panics there. + if wgpu::Instance::enabled_backend_features().is_empty() { + return None; + } let instance = wgpu::Instance::default(); let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())).ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; From ba5727029aebf1f463bb562de213c8125618de26 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:46:06 +0200 Subject: [PATCH 030/203] #626: undecodable video shows the MEDIA UNREADABLE slate, not black Problem: a file that opens but whose picture FilmCraft cannot decode (H.264 High 10 / 4:2:2 / 4:4:4, interlaced, a codec without a decoder) rendered black with no sign of why. The H.264 and HEVC decoders' "unsupported" errors reached the media source as generic decode errors, and the renderer drops frames that fail. Fix: the H.264 / HEVC wrappers report unsupported features as CodecError::Unsupported. The media pool wraps every file source it opens so a frame failing as unsupported renders the existing MEDIA UNREADABLE slate and the item is marked Unreadable with the decoder's reason (as for files that fail to open). Decodable frames, damaged frames and the sound are untouched. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/video.rs | 26 ++++++- crates/engine/src/media_pool.rs | 127 ++++++++++++++++++++++++++++++-- 2 files changed, 144 insertions(+), 9 deletions(-) diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs index 86b21b7d..fa8eb9de 100644 --- a/crates/codecs/src/video.rs +++ b/crates/codecs/src/video.rs @@ -224,7 +224,7 @@ impl H264Decoder { fn make_inner(avcc: &[u8], threads: usize) -> Result { let mut dec = filmcraft_h264::Decoder::with_threads(threads); if !avcc.is_empty() { - dec.configure_avcc(avcc).map_err(|e| CodecError::Decode(e.to_string()))?; + dec.configure_avcc(avcc).map_err(h264_error)?; } Ok(dec) } @@ -264,9 +264,19 @@ impl H264Decoder { } } +/// An H.264 decoder error as a [`CodecError`]: a feature the decoder does not implement (High +/// 4:2:2, field pictures...) is [`CodecError::Unsupported`], so the media pool shows the +/// unreadable slate instead of a black picture (#626); anything else is a decode error. +fn h264_error(e: filmcraft_h264::Error) -> CodecError { + match e { + filmcraft_h264::Error::Unsupported(what) => CodecError::Unsupported(format!("H.264: {what}")), + e => CodecError::Decode(e.to_string()), + } +} + impl VideoDecoder for H264Decoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { - let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?; + let pics = self.dec.decode(sample, pts).map_err(h264_error)?; Ok(pics.into_iter().map(Self::convert).collect()) } fn flush(&mut self) -> Vec { @@ -328,7 +338,7 @@ pub struct HevcDecoder { impl HevcDecoder { pub fn new(hvcc: Vec) -> Result { - let dec = filmcraft_hevc::Decoder::from_hvcc(&hvcc).map_err(|e| CodecError::Decode(e.to_string()))?; + let dec = filmcraft_hevc::Decoder::from_hvcc(&hvcc).map_err(hevc_error)?; let (length_size, highest_tid) = hvcc_length_size_and_tid(&hvcc); Ok(Self { hvcc, dec, length_size, highest_tid }) } @@ -368,9 +378,17 @@ impl HevcDecoder { } } +/// An HEVC decoder error as a [`CodecError`] (see [`h264_error`]). +fn hevc_error(e: filmcraft_hevc::Error) -> CodecError { + match e { + filmcraft_hevc::Error::Unsupported(what) => CodecError::Unsupported(format!("HEVC: {what}")), + e => CodecError::Decode(e.to_string()), + } +} + impl VideoDecoder for HevcDecoder { fn decode(&mut self, sample: &[u8], pts: i64) -> Result> { - let pics = self.dec.decode(sample, pts).map_err(|e| CodecError::Decode(e.to_string()))?; + let pics = self.dec.decode(sample, pts).map_err(hevc_error)?; Ok(pics.into_iter().map(Self::convert).collect()) } fn flush(&mut self) -> Vec { diff --git a/crates/engine/src/media_pool.rs b/crates/engine/src/media_pool.rs index f82aa78b..ac849710 100644 --- a/crates/engine/src/media_pool.rs +++ b/crates/engine/src/media_pool.rs @@ -4,7 +4,9 @@ //! - Sources are cached per item **and per reference** (the file path, the offline flag), so a //! relink, Make Offline or their undo picks up the right file on the next frame. //! - Media that can't be opened (missing, unreadable) or was made offline renders the offline -//! slate ([`filmcraft_render::offline`]) instead of failing; the reason is kept for the UI. +//! slate ([`filmcraft_render::offline`]) instead of failing; the reason is kept for the UI. So +//! do the frames of a file that opens but whose picture FilmCraft cannot decode (a codec or +//! profile without a decoder). //! - With proxies enabled ([`MediaPool::set_use_proxies`]) items with an attached proxy are read //! from it. Proxy frames are smaller; the source still reports the full-resolution size, and the //! compositor derives its pixel scale from the frame it gets, so effects and Motion render the @@ -34,7 +36,7 @@ pub struct MediaPool { /// item → (reference key, source) sources: RwLock>, proxies: RwLock>, - offline: RwLock>, + offline: Arc>>, use_proxies: AtomicBool, /// Openers tried before the built-in ones (MP4/MOV + codecs register here). pub openers: RwLock>, @@ -46,7 +48,7 @@ impl Default for MediaPool { Self { sources: RwLock::new(HashMap::new()), proxies: RwLock::new(HashMap::new()), - offline: RwLock::new(HashMap::new()), + offline: Arc::new(RwLock::new(HashMap::new())), use_proxies: AtomicBool::new(false), openers: RwLock::new(filmcraft_codecs::openers()), } @@ -139,8 +141,16 @@ impl MediaPool { /// Cache a source opened from `path` for an item. pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) { - self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src)); self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); + let src = self.unsupported_as_slate(item, path, src); + self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (format!("file:{path}"), src)); + } + + /// `src` (opened from `path` for `item`) showing the unreadable slate for the frames it + /// cannot decode. + fn unsupported_as_slate(&self, item: ItemId, path: &str, src: SharedSource) -> SharedSource { + let slate = SlateSource::new(src.info().clone(), &file_name(path), OfflineReason::Unreadable); + Arc::new(UnsupportedAsSlate { inner: src, slate, item, path: path.to_string(), offline: self.offline.clone() }) } pub fn remove(&self, item: ItemId) { @@ -249,7 +259,7 @@ impl MediaPool { } { Ok(s) => { self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item); - s + self.unsupported_as_slate(item, path, s) } Err(_) if filmcraft_media::pending::is_set() => { // the host is still fetching the file's bytes: show the slate for now, retry later @@ -373,6 +383,42 @@ impl MediaSource for SlateSource { } } +/// A file's source whose picture shows the unreadable slate for each frame its decoder reports as +/// unsupported (H.264 High 4:2:2, a codec without a decoder...), instead of no picture at all, +/// and marks the item unreadable with the reason, so the monitors and the Project panel say why +/// (#626). Its sound plays as before. +struct UnsupportedAsSlate { + inner: SharedSource, + slate: SlateSource, + item: ItemId, + path: String, + offline: Arc>>, +} + +impl MediaSource for UnsupportedAsSlate { + fn info(&self) -> &MediaInfo { + self.inner.info() + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + match self.inner.video_frame(req) { + Err(MediaError::Unsupported(why)) => { + self.offline.write().unwrap_or_else(|e| e.into_inner()).entry(self.item).or_insert_with(|| { + log::warn!("media unreadable: {}: {why}", self.path); + OfflineStatus { reason: OfflineReason::Unreadable, path: self.path.clone(), error: why } + }); + self.slate.video_frame(req) + } + r => r, + } + } + fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.inner.audio(start, frames, sample_rate) + } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + self.inner.audio_stream(stream, start, frames, sample_rate) + } +} + /// A proxy standing in for its full-resolution media: reports the full-resolution info and hands /// out the proxy's (smaller) frames. A proxy whose aspect ratio differs from the original is /// resampled to the original's aspect so it lines up exactly. @@ -501,4 +547,75 @@ mod tests { assert!(matches!(MediaPool::default().open_file(&missing, &crate::FsServices), Err(MediaError::Offline(_)))); std::fs::remove_dir_all(&dir).unwrap(); } + + /// A file whose first frame decodes and whose later frames need a decoder FilmCraft lacks. + struct PartlyDecodable(MediaInfo); + impl MediaSource for PartlyDecodable { + fn info(&self) -> &MediaInfo { + &self.0 + } + fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result> { + match req.time.0 { + 0 => Ok(Arc::new(VideoFrame::rgba8(2, 2, vec![7; 16]))), + 1 => Err(MediaError::Decode("damaged slice".into())), + _ => Err(MediaError::Unsupported("H.264: field pictures".into())), + } + } + fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + Ok(AudioBuffer::silence(sample_rate, 3, frames)) + } + } + + /// #626: frames the decoder does not support show the unreadable slate (not nothing) and the + /// item says why; decodable frames, damaged ones and the sound are left alone. + #[test] + fn undecodable_frames_show_the_unreadable_slate() { + let info = GeneratorSource::new(filmcraft_media::Generator::ColorMatte { color: [0.0; 4] }, 16, 8, Default::default(), filmcraft_time::Tick(100)) + .info() + .clone(); + let pool = MediaPool::default(); + let item = ItemId(1); + pool.insert_file(item, "/media/cam/a.mov", Arc::new(PartlyDecodable(info))); + let src = pool.cached(item).unwrap(); + let at = |t| src.video_frame(FrameRequest { time: filmcraft_time::Tick(t), scale: 1.0 }); + assert_eq!(at(0).unwrap().to_rgba8(), vec![7; 16]); + assert!(matches!(at(1), Err(MediaError::Decode(_)))); + assert!(pool.offline_status(item).is_none(), "nothing unsupported yet"); + let f = at(2).unwrap(); + assert_eq!((f.width, f.height), (16, 8)); + assert_eq!(f.to_rgba8(), filmcraft_render::offline::slate_rgba8(16, 8, "a.mov", OfflineReason::Unreadable)); + let st = pool.offline_status(item).unwrap(); + assert_eq!((st.reason, st.path.as_str(), st.error.as_str()), (OfflineReason::Unreadable, "/media/cam/a.mov", "H.264: field pictures")); + assert_eq!(src.audio(0, 4, 48_000).unwrap().channels.len(), 3); + // linking the item to another file forgets the reason + pool.insert_file(item, "/media/cam/b.mov", src.clone()); + assert!(pool.offline_status(item).is_none()); + } + + /// #626 end to end: an H.264 profile FilmCraft does not decode yet (4:4:4) imports and shows + /// the unreadable slate with the decoder's reason, instead of a black picture. + #[test] + fn ffmpeg_unsupported_h264_profile_shows_the_unreadable_slate() { + let Some(ffmpeg) = filmcraft_testkit::oracle::ffmpeg_or_skip("media_pool") else { return }; + let path = filmcraft_testkit::fixtures_dir("engine/media_pool").join("h264_444_64x32.mp4"); + let Some(path) = filmcraft_testkit::fixtures::generate(&path, |tmp| { + std::process::Command::new(&ffmpeg) + .args(["-y", "-loglevel", "error", "-f", "lavfi", "-i", "testsrc2=size=64x32:rate=24:duration=0.5"]) + .args(["-c:v", "libx264", "-preset", "fast", "-pix_fmt", "yuv444p"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) else { + return; + }; + let mut s = crate::Session::default(); + let r = s.execute("file.import", serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let item = ItemId(r["items"][0].as_u64().unwrap()); + let src = s.media.source_for(&s.project, item, &*s.services).unwrap(); + let f = src.video_frame(FrameRequest { time: filmcraft_time::Tick::ZERO, scale: 1.0 }).unwrap(); + assert_eq!(f.to_rgba8(), filmcraft_render::offline::slate_rgba8(64, 32, "h264_444_64x32.mp4", OfflineReason::Unreadable)); + let st = s.media.offline_status(item).unwrap(); + assert_eq!(st.reason, OfflineReason::Unreadable); + assert!(st.error.contains("H.264") && st.error.contains("chroma_format_idc 3"), "{}", st.error); + } } From 8f1b7c3494265a03a8b025fb692599d121690618 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:45:20 +0300 Subject: [PATCH 031/203] program monitor: drag a rectangle's corners round (#744) A rectangle's rounded corners could only be set by typing a Corner Radius in Properties. As in Premiere, a selected rectangle now shows four small circles just inside its corners: dragging one towards the middle rounds all four corners (Corner Radius is one value), dragging it back out squares them, and the radius stops at half the shorter side, where the renderer caps it too. The circles sit on each corner's diagonal at the radius, at least 12 points in, and show only on rectangles at least four times that across on screen. While dragging, a white outline of the rounded rectangle previews the result; the radius is set on release with graphics.set, one undo step per drag. Automation ids: program.layer...corner.. Tests: graphics_handles_ui (the circles round all four corners, clamp and undo; ellipses and tiny rectangles have none). --- crates/ui-egui/src/panels/graphics.rs | 97 ++++++++++++++++++++- crates/ui-egui/tests/graphics_handles_ui.rs | 56 +++++++++++- docs/graphics.md | 2 +- 3 files changed, 151 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/panels/graphics.rs b/crates/ui-egui/src/panels/graphics.rs index 3060810a..7cf5f47b 100644 --- a/crates/ui-egui/src/panels/graphics.rs +++ b/crates/ui-egui/src/panels/graphics.rs @@ -11,12 +11,14 @@ //! - **Selection tool:** click selects a layer (bounding box with handles and anchor point); drag //! moves it; drag the anchor point to move that alone. What a handle does depends on the layer //! (see [`HandleDrag`]): point text scales about its anchor point, a paragraph-text box is -//! resized and its text re-wraps, a shape stretches away from its opposite side. +//! resized and its text re-wraps, a shape stretches away from its opposite side. A rectangle +//! also has four small circles just inside its corners: dragging one rounds all four corners. //! - **Rectangle / Ellipse tools:** drag out a shape. **Pen tool:** click points; click the first //! point again (or press Return) to close the path; Esc cancels. //! //! All edits go through `graphics.*` commands. Automation ids: `program.layer..`, //! `program.layer...handle.`, `program.layer...anchor`, +//! `program.layer...corner.`, //! `program.textEdit`, `graphics.*` in the panels. use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -164,6 +166,8 @@ enum DragKind { Handle(usize), /// The layer's anchor point: it moves, the layer stays where it is. Anchor, + /// Corner-radius handle `n` of a rectangle (see [`corner_handles`]). + Corner(usize), NewShape, /// The Type tool dragging out the box of a new paragraph-text layer. NewTextBox, @@ -405,6 +409,61 @@ fn is_paragraph(t: &filmcraft_project::graphic::TextProps) -> bool { t.box_width > 0.0 && !t.vertical } +/// Corner-radius handles sit at least this far (in points) inside a rectangle's corners, and show +/// only on a rectangle at least four times as wide and tall on screen. +const CORNER_INSET: f32 = 12.0; + +/// A rectangle layer's corner radius as it is drawn (at most half its shorter side) and its four +/// corner-radius handles on screen (TL, TR, BR, BL); `None` for other layers and for a rectangle +/// too small on screen to hold them. +fn corner_handles(v: &LayerView) -> Option<(f32, [Pos2; 4])> { + let LayerContent::Shape(s) = &v.spec.content else { return None }; + let q = v.quad(); + if s.shape != 0 || (q[1] - q[0]).length() < 4.0 * CORNER_INSET || (q[3] - q[0]).length() < 4.0 * CORNER_INSET { + return None; + } + let r = s.corner_radius.min(s.size.0 / 2.0).min(s.size.1 / 2.0).max(0.0); + Some((r, corner_points(v, r))) +} + +/// Where the corner-radius handles of rectangle `v` sit for radius `r`: on each corner's diagonal, +/// where the rounded corner's arc is centred, but at least [`CORNER_INSET`] points in. +fn corner_points(v: &LayerView, r: f32) -> [Pos2; 4] { + let b = v.local; + let d = r.max(CORNER_INSET / screen_scale(&v.to_screen).max(1e-6)).min((b[2] - b[0]) / 2.0).min((b[3] - b[1]) / 2.0); + [(b[0] + d, b[1] + d), (b[2] - d, b[1] + d), (b[2] - d, b[3] - d), (b[0] + d, b[3] - d)].map(|(x, y)| sp(&v.to_screen, x, y)) +} + +/// The corner radius after dragging handle `n` of rectangle `v` (radius `r0`) from `start` to +/// `cur`: the pointer's travel into the shape along the corner's diagonal, up to half the shorter +/// side. +fn corner_drag(v: &LayerView, r0: f32, n: usize, start: Pos2, cur: Pos2) -> f32 { + let (Some(a), Some(b)) = (v.to_local(start), v.to_local(cur)) else { return r0 }; + let (sx, sy) = match n { + 0 => (1.0, 1.0), + 1 => (-1.0, 1.0), + 2 => (-1.0, -1.0), + _ => (1.0, -1.0), + }; + let inward = ((b.0 - a.0) * sx + (b.1 - a.1) * sy) / 2.0; + let half = ((v.local[2] - v.local[0]).min(v.local[3] - v.local[1]) / 2.0).max(0.0); + (r0 + inward).clamp(0.0, half) +} + +/// The outline of rectangle `v` with corner radius `r`, on screen. +fn round_rect_outline(v: &LayerView, r: f32) -> Vec { + let b = v.local; + let mut pts = Vec::new(); + // each corner's arc, clockwise from the top-right + for (cx, cy, from) in [(b[2] - r, b[1] + r, -90.0_f32), (b[2] - r, b[3] - r, 0.0), (b[0] + r, b[3] - r, 90.0), (b[0] + r, b[1] + r, 180.0)] { + for i in 0..=8 { + let a = (from + 90.0 * i as f32 / 8.0).to_radians(); + pts.push(sp(&v.to_screen, cx + r * a.cos(), cy + r * a.sin())); + } + } + pts +} + /// The layer's anchor point on screen. fn anchor_screen(v: &LayerView) -> Pos2 { sp(&v.to_screen, v.spec.transform.anchor.x as f32, v.spec.transform.anchor.y as f32) @@ -506,6 +565,12 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra painter.rect_filled(hr, 0.0, Color32::WHITE); app.auto.add(&format!("program.layer.{}.{}.handle.{n}", v.clip.0, v.layer), hr.expand(4.0), label); } + // a rectangle's corner-radius handles, just inside its corners + for (n, c) in corner_handles(v).map(|(_, pts)| pts).iter().flatten().enumerate() { + painter.circle_filled(*c, 3.0, Color32::WHITE); + painter.circle_stroke(*c, 3.0, Stroke::new(1.0, accent)); + app.auto.add(&format!("program.layer.{}.{}.corner.{n}", v.clip.0, v.layer), Rect::from_center_size(*c, vec2(12.0, 12.0)), "corner radius"); + } // anchor point let a = anchor_screen(v); painter.circle_stroke(a, 4.0, Stroke::new(1.0, accent)); @@ -528,8 +593,15 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra Tool::Type | Tool::VerticalType => ui.ctx().set_cursor_icon(egui::CursorIcon::Text), Tool::Rectangle | Tool::Ellipse | Tool::Pen => ui.ctx().set_cursor_icon(egui::CursorIcon::Crosshair), Tool::Selection => { + let corner = views + .iter() + .filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer)) + .find_map(|v| corner_handles(v).and_then(|(_, pts)| pts.iter().position(|c| (*c - p).length() <= 6.0))); if editing.as_ref().is_some_and(|e| views.iter().any(|v| v.clip.0 == e.clip && v.layer == e.layer && v.hit(p))) { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + } else if let Some(n) = corner { + // along the corner's diagonal, the way the drag works + ui.ctx().set_cursor_icon(if n % 2 == 0 { egui::CursorIcon::ResizeNwSe } else { egui::CursorIcon::ResizeNeSw }); } else if views.iter().any(|v| v.hit(p)) { ui.ctx().set_cursor_icon(egui::CursorIcon::Move); } @@ -576,10 +648,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra let selected = || views.iter().filter(|v| Some(v.clip) == sel_clip && sel_layers.contains(&v.layer)); let anchor = selected().find(|v| tool == Tool::Selection && (anchor_screen(v) - p).length() <= 7.0); let handle = selected().find_map(|v| handle_points(&v.quad()).iter().position(|c| (*c - p).length() <= 7.0).map(|n| (v.clip, v.layer, n))); + let corner = selected() + .find_map(|v| corner_handles(v).and_then(|(_, pts)| pts.iter().position(|c| (*c - p).length() <= 6.0)).map(|n| (v.clip, v.layer, n))); if let Some(v) = anchor { drag = Some(DragState { kind: DragKind::Anchor, clip: v.clip, layer: v.layer, start: p }); } else if let Some((c, l, n)) = handle { drag = Some(DragState { kind: DragKind::Handle(n), clip: c, layer: l, start: p }); + } else if let Some((c, l, n)) = corner { + drag = Some(DragState { kind: DragKind::Corner(n), clip: c, layer: l, start: p }); } else if tool == Tool::Type && !views.iter().any(|v| v.is_text() && v.hit(p)) { drag = Some(DragState { kind: DragKind::NewTextBox, clip: ClipId(0), layer: 0, start: p }); } else if tool == Tool::Selection @@ -641,6 +717,17 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra painter.line_segment([a - vec2(0.0, 6.0), a + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::WHITE)); } } + DragKind::Corner(n) => { + if let Some(v) = v + && let Some((r0, _)) = corner_handles(v) + { + let r = corner_drag(v, r0, n, d.start, cur); + painter.add(egui::Shape::closed_line(round_rect_outline(v, r), Stroke::new(1.0, Color32::WHITE))); + for c in corner_points(v, r) { + painter.circle_stroke(c, 3.0, Stroke::new(1.0, Color32::WHITE)); + } + } + } DragKind::NewTextBox => { painter.rect_stroke(Rect::from_two_pos(d.start, cur), 0.0, Stroke::new(1.0, t.danger), StrokeKind::Middle); } @@ -697,6 +784,14 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra )); } } + DragKind::Corner(n) if moved => { + if let Some(v) = v + && let Some((r0, _)) = corner_handles(v) + { + let r = corner_drag(v, r0, n, d.start, cur); + actions.push(("graphics.set".into(), json!({"clip": v.clip.0, "layer": v.layer, "props": {"corner_radius": r}}))); + } + } DragKind::NewTextBox if moved => { end_edit(app, ui); actions.push(new_text_action(app, pic, frame, d.start, Some(cur))); diff --git a/crates/ui-egui/tests/graphics_handles_ui.rs b/crates/ui-egui/tests/graphics_handles_ui.rs index b7553926..cf392822 100644 --- a/crates/ui-egui/tests/graphics_handles_ui.rs +++ b/crates/ui-egui/tests/graphics_handles_ui.rs @@ -432,6 +432,58 @@ fn polygon_handles_keep_the_opposite_corner() { assert!(near(d.handle(&pre, 2), (br.0 + 50.0, br.1 + 40.0)), "bottom-right follows the pointer"); } +/// The Corner Radius of the shape layer of graphic clip `clip`. +fn corner_radius(d: &mut Driver, clip: u64) -> f64 { + let s = &d.harness.state().session; + let (_, it) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap(); + let e = it.effects.iter().find(|e| e.effect == "graphic_shape").unwrap(); + e.param("corner_radius").unwrap().value_at(filmcraft_time::Tick::ZERO).as_f64().unwrap() +} + +#[test] +fn a_rectangles_corner_circles_round_all_its_corners() { + let mut d = Driver::new(); + let pre = d.shape(); + let clip: u64 = pre.split('.').nth(2).unwrap().parse().unwrap(); + let (tl, br) = (d.handle(&pre, 0), d.handle(&pre, 2)); + let k = (br.0 - tl.0) / 500.0; // screen points per layer pixel + let corners: Vec<(f64, f64)> = (0..4).map(|n| d.at(&pre, &format!("corner.{n}"))).collect(); + // just inside each corner, on its diagonal + assert!(corners[0].0 > tl.0 + 5.0 && corners[0].1 > tl.1 + 5.0 && corners[2].0 < br.0 - 5.0 && corners[2].1 < br.1 - 5.0, "{corners:?}"); + + // the top-left circle dragged 40 points down and right: a radius of 40 points' travel + d.drag(corners[0], (40.0, 40.0)); + let r = corner_radius(&mut d, clip); + assert!((r - 40.0 / k).abs() < 2.0, "radius {r}, expected {}", 40.0 / k); + // the circles moved in to the radius, all four alike + let (tl2, br2) = (d.handle(&pre, 0), d.handle(&pre, 2)); + assert!(near(tl, tl2) && near(br, br2), "the box stays"); + let c0 = d.at(&pre, "corner.0"); + let c2 = d.at(&pre, "corner.2"); + assert!(near(c0, (tl.0 + r * k, tl.1 + r * k)) && near(c2, (br.0 - r * k, br.1 - r * k)), "{c0:?} {c2:?}"); + + // any circle drags all four; past the middle it stops at half the shorter side + d.drag(c2, (-300.0, -300.0)); + assert_eq!(corner_radius(&mut d, clip), 150.0); + // back out squares them; one undo step per drag + let c1 = d.at(&pre, "corner.1"); + d.drag(c1, (200.0, -200.0)); + assert_eq!(corner_radius(&mut d, clip), 0.0); + d.exec("edit.undo", json!({})); + assert_eq!(corner_radius(&mut d, clip), 150.0); +} + +#[test] +fn only_rectangles_big_enough_get_corner_circles() { + let mut d = Driver::new(); + let r = d.exec("graphics.newShape", json!({"shape": "ellipse", "position": [800, 500], "size": [500, 300]})); + let pre = d.select(r["clip"].as_u64().unwrap()); + assert!(d.ok("ui.elements", json!({"prefix": format!("{pre}corner")})).as_array().unwrap().is_empty(), "an ellipse has none"); + let r = d.exec("graphics.newShape", json!({"shape": "rectangle", "position": [800, 500], "size": [20, 20]})); + let pre = d.select(r["clip"].as_u64().unwrap()); + assert!(d.ok("ui.elements", json!({"prefix": format!("{pre}corner")})).as_array().unwrap().is_empty(), "a tiny rectangle has none"); +} + #[test] fn shape_edge_handles_stretch_one_axis() { let mut d = Driver::new(); @@ -466,7 +518,7 @@ fn a_hidden_layer_has_no_box_and_cannot_be_clicked() { let v = d.ok("ui.elements", json!({"prefix": layer})); v.as_array().unwrap().iter().map(|e| e["id"].as_str().unwrap().to_string()).collect() }; - assert_eq!(ids(&mut d).len(), 10, "the layer, its eight handles and its anchor: {:?}", ids(&mut d)); + assert_eq!(ids(&mut d).len(), 14, "the layer, its eight handles, four corner circles and its anchor: {:?}", ids(&mut d)); let r = d.rect(&layer); let inside = (r[0] + r[2] / 4.0, r[1] + r[3] / 2.0); @@ -485,5 +537,5 @@ fn a_hidden_layer_has_no_box_and_cannot_be_clicked() { // (elsewhere on it, so that the two clicks are not a double click) d.ok("ui.click", json!({"x": r[0] + r[2] * 0.75, "y": inside.1})); d.frames(4); - assert_eq!(ids(&mut d).len(), 10); + assert_eq!(ids(&mut d).len(), 14); } diff --git a/docs/graphics.md b/docs/graphics.md index fd04667d..e19dcbbd 100644 --- a/docs/graphics.md +++ b/docs/graphics.md @@ -24,7 +24,7 @@ kerning and ligatures, bidi, line breaking, all drawn as vectors in linear light | Group | Properties (parameter ids) | |---|---| | Text | `text` (Source Text), `font`, `font_style`, `size` (px), `align` (Left/Center/Right/Justify), `tracking` (1/1000 em), `kerning`, `ligatures`, `leading` (px added to the natural line height), `baseline_shift`, `faux_bold`, `faux_italic`, `caps` (Normal/All Caps/Small Caps), `underline`, `box_width` (0 = point text, else paragraph text wrapped at that width), `box_height` (paragraph text: lines that do not fit are not shown; 0 = as tall as the text) | -| Shape | `shape` (Rectangle/Ellipse/Polygon/Path), `size` [w, h], `sides`, `corner_radius`, `points` (path vertices relative to the layer origin) | +| Shape | `shape` (Rectangle/Ellipse/Polygon/Path), `size` [w, h], `sides`, `corner_radius` (one radius for all four corners, at most half the shorter side; on the Program monitor, drag one of the four small circles just inside a selected rectangle's corners), `points` (path vertices relative to the layer origin) | | Appearance | `fill`, `fill_color`; `stroke`, `stroke_color`, `stroke_width`, `stroke_type` (Outer/Center/Inner) and the same for `stroke2`; `background`, `background_color`, `background_opacity`, `background_size` (padding), `background_radius`; `shadow`, `shadow_color`, `shadow_opacity`, `shadow_angle` (135° = down-right), `shadow_distance`, `shadow_size`, `shadow_blur` | | Transform | `position` (graphic canvas px), `anchor` (layer px), `scale`, `scale_width`, `uniform_scale`, `rotation`, `opacity` | From 3190eab4efc96f53f8ce1ef00f049076841f07ef Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 21:55:11 +0200 Subject: [PATCH 032/203] #313: the Source monitor remembers its clips; Close and Close All Problem: the Source monitor only knew the one clip it showed. Opening another clip forgot the previous one, there was no list to switch back from, and no way to close a Source clip (Premiere keeps a list of the recently opened clips with Close / Close All). Fix: EditorState keeps `source_history` (most recent first, at most 20), updated after every command; items removed from the project drop out. New commands `source.close` (the next most recent clip opens in its place) and `source.closeAll`. The Source panel menu lists the recent clips (the shown one ticked) to switch between, then Close / Close All. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 3 ++ crates/engine/src/lib.rs | 8 ++++ crates/engine/src/source_monitor.rs | 69 ++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/panels/mod.rs | 4 ++ crates/ui-egui/src/panels/monitor.rs | 34 ++++++++++++++ 8 files changed, 121 insertions(+) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index c1f2f56d..d56a03b3 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2081,6 +2081,9 @@ fn build() -> Vec { s.events.push(crate::Event::OpenSource(id)); Ok(Value::Null) }), + // Source panel: Close / Close All, for the clip shown and the list of recent clips (#313) + cmd!("source.close", "Close", [], None, "{}", crate::source_monitor::has_source_clip, |s, _| crate::source_monitor::close(s, false)), + cmd!("source.closeAll", "Close All", [], None, "{}", crate::source_monitor::has_source_clip, |s, _| crate::source_monitor::close(s, true)), cmd!("source.setPlayhead", "Set Source Playhead", [], None, r#"{"time":ticks|"frame":i64|"seconds":f64}"#, always, |s, p| { let item = s.state.source_item.ok_or_else(|| EngineError::Other("no source clip".into()))?; let view = crate::clip_ops::source_view(s, item); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index fe6f94be..01166df8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -231,6 +231,10 @@ pub struct EditorState { /// Item loaded in the Source monitor and its playhead (media time). pub source_item: Option, pub source_playhead: Tick, + /// Clips opened in the Source monitor, most recent first: the Source panel's list of recent + /// clips (#313). Kept up to date after every command; items gone from the project drop out. + #[serde(default)] + pub source_history: Vec, /// Selected timeline items. pub selection: Vec, /// Selected project panel items. @@ -646,6 +650,9 @@ impl Session { let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (spec.run)(self, ¶ms))) .unwrap_or_else(|_| Err(EngineError::Other(format!("internal error in `{id}` (see the crash log); the command did not complete")))); self.exec_depth -= 1; + if self.exec_depth == 0 { + source_monitor::track_history(self); + } // source graphics: an edited instance updates the shared layers and the other instances if r.is_ok() && spec.journal && id.starts_with("graphics.") && !self.project.source_graphics.is_empty() { graphic_templates::sync_source_graphics(self); @@ -881,6 +888,7 @@ impl Session { { self.state.source_item = None; } + source_monitor::track_history(self); if let Some(seq) = self.state.active_sequence.and_then(|s| p.sequence(s)) { self.state.selection.retain(|c| seq.find_item(*c).is_some()); self.state.caption_selection.retain(|c| seq.find_caption(*c).is_some()); diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs index cdbcf35f..5c1de07f 100644 --- a/crates/engine/src/source_monitor.rs +++ b/crates/engine/src/source_monitor.rs @@ -90,6 +90,52 @@ pub fn source_command(id: &str) -> bool { ) } +/// Most clips the Source monitor's list of recent clips keeps. +pub const SOURCE_HISTORY_MAX: usize = 20; + +/// Keep the Source monitor's recent clips in step with the clip it shows: the open clip moves to +/// the front, items no longer in the project drop out. +pub(crate) fn track_history(s: &mut Session) { + let current = s.state.source_item; + let project = &s.project; + let history = &mut s.state.source_history; + history.retain(|i| project.item(*i).is_some()); + if let Some(cur) = current.filter(|i| project.item(*i).is_some()) + && history.first() != Some(&cur) + { + history.retain(|i| *i != cur); + history.insert(0, cur); + } + history.truncate(SOURCE_HISTORY_MAX); +} + +/// Enablement of Close / Close All: a clip is open in the Source monitor. +pub(crate) fn has_source_clip(s: &Session) -> std::result::Result<(), String> { + s.state.source_item.map(|_| ()).ok_or_else(|| "no clip is open in the Source monitor".into()) +} + +/// Source panel ▸ Close (`all`: Close All). The open clip leaves the Source monitor and its list +/// of recent clips, and the next most recent one opens in its place, as in Premiere; Close All +/// empties the list and the monitor. +pub(crate) fn close(s: &mut Session, all: bool) -> Result { + if all { + s.state.source_history.clear(); + } else if let Some(cur) = s.state.source_item { + s.state.source_history.retain(|i| *i != cur); + } + let next = s.state.source_history.iter().copied().find(|i| s.project.item(*i).is_some()); + match next { + Some(i) => { + s.execute("source.open", json!({"item": i.0}))?; + } + None => { + s.state.source_item = None; + s.state.source_playhead = Tick::ZERO; + } + } + Ok(json!({"item": next.map(|i| i.0)})) +} + fn marker_list(p: &mut Project, item: ItemId) -> Result<&mut Vec> { let it = p.item_mut(item).ok_or_else(|| EngineError::Other("Source item is unavailable".into()))?; match &mut it.kind { @@ -288,6 +334,29 @@ mod tests { s.execute("markers.clearCurrent", json!({"target":"source"})).unwrap(); assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty()); } + /// #313: the Source monitor remembers the clips opened in it, most recent first; Close shows + /// the next most recent one and Close All empties the monitor. + #[test] + fn source_clips_are_remembered_and_closed_in_turn() { + let mut s = fixture(); + let a = ItemId(5); + let mut others: Vec = s.project.items.values().filter(|i| i.as_media().is_some() && i.id != a).map(|i| i.id).collect(); + others.sort(); + let [b, c] = [others[0], others[1]]; + for i in [b, c, a] { + s.execute("source.open", json!({"item": i.0})).unwrap(); + } + assert_eq!(s.state.source_history, [a, c, b]); + assert_eq!(s.execute("source.close", json!({})).unwrap()["item"], c.0); + assert_eq!((s.state.source_item, s.state.source_history.clone()), (Some(c), vec![c, b])); + // an item deleted from the project leaves the list + s.execute("project.delete", json!({"items": [b.0]})).unwrap(); + assert!(s.project.item(b).is_none()); + assert!(!s.state.source_history.contains(&b)); + s.execute("source.closeAll", json!({})).unwrap(); + assert_eq!((s.state.source_item, s.state.source_history.len()), (None, 0)); + assert!(s.execute("source.close", json!({})).is_err(), "nothing left to close"); + } #[test] fn invalid_marker_parameters_do_not_edit_the_project() { let mut s = fixture(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 3f45ce9d..2af7e2a2 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -496,6 +496,7 @@ {panel} — coming in a later milestone {panel}: llegará en una versión posterior Close Panel Cerrar panel Close Other Timeline Panels Cerrar los demás paneles de línea de tiempo + Close All Cerrar todo Maximize Frame Maximizar marco Restore Workspace Restaurar espacio de trabajo Reveal Sequence in Project Mostrar secuencia en el proyecto diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 3d54f873..d3289309 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -494,6 +494,7 @@ {panel} — coming in a later milestone {panel} — 今後のマイルストーンで実装予定 Close Panel パネルを閉じる Close Other Timeline Panels 他のタイムラインパネルを閉じる + Close All すべて閉じる Maximize Frame パネルを最大化 Restore Workspace ワークスペースを復元 Reveal Sequence in Project プロジェクトでシーケンスを表示 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index e504e341..e161fd52 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -487,6 +487,7 @@ {panel} — coming in a later milestone {panel} — 将在后续里程碑中推出 Close Panel 关闭面板 Close Other Timeline Panels 关闭其他时间轴面板 + Close All 全部关闭 Maximize Frame 最大化帧 Restore Workspace 恢复工作区 Reveal Sequence in Project 在项目面板中显示序列 diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..66d0d475 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -272,6 +272,10 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { ui.separator(); close |= media_browser::panel_menu(app, ui); } + if p == PanelKind::Source { + ui.separator(); + close |= monitor::source_panel_menu(app, ui); + } }); }); // A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index d94867f3..a57d0483 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -822,6 +822,40 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) } } +/// The Source panel menu's clips (#313): the recently opened clips, the shown one ticked, to +/// switch between, then Close / Close All. Returns whether the menu is done. +pub fn source_panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { + let mut done = false; + let current = app.session.state.source_item; + let recent: Vec<(u64, String)> = + app.session.state.source_history.iter().filter_map(|i| app.session.project.item(*i).map(|it| (i.0, it.name.clone()))).collect(); + for (id, name) in &recent { + let shown = current.is_some_and(|c| c.0 == *id); + let r = ui.button(if shown { format!("✓ {name}") } else { name.clone() }); + app.auto.add(&format!("panel.menu.Source.recent.{id}"), r.rect, name); + if r.clicked() { + if !shown && let Err(e) = app.session.execute("source.open", json!({"item": id})) { + app.ui.status = e.to_string(); + } + done = true; + } + } + if !recent.is_empty() { + ui.separator(); + } + for (cmd, id, label) in [("source.close", "close", tl!("Close")), ("source.closeAll", "closeAll", tl!("Close All"))] { + let r = ui.add_enabled(current.is_some(), egui::Button::new(label)); + app.auto.add(&format!("panel.menu.Source.{id}"), r.rect, label); + if r.clicked() { + if let Err(e) = app.session.execute(cmd, json!({})) { + app.ui.status = e.to_string(); + } + done = true; + } + } + done +} + fn source_nav(app: &mut FilmcraftApp, cmd: &str) { let Some(item) = app.session.state.source_item else { return }; let Some(v) = filmcraft_engine::clip_ops::source_view(&app.session, item) else { return }; From ad8080159632b28a9be0725d6d2f39692c61c200 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 22:01:54 +0200 Subject: [PATCH 033/203] #313: double-clicking a Timeline clip opens it in the Source monitor Problem: the Timeline only reacted to double-clicks on transitions and nested sequences, so double-clicking an ordinary clip did nothing instead of Premiere's opening it in the Source monitor. Fix: `source.open` takes an optional `clip` (a clip of the active sequence): its media opens in the Source monitor, parked on the frame under the playhead when the playhead is over the clip, otherwise on the clip's first frame. The Timeline's Selection-tool double-click on a clip body (not its edge, not a nested sequence or graphic) dispatches it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 5 ++- crates/engine/src/source_monitor.rs | 47 ++++++++++++++++++++++++++- crates/ui-egui/src/panels/timeline.rs | 15 +++++++++ 3 files changed, 65 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index d5618b99..31645e87 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -2118,7 +2118,10 @@ fn build() -> Vec { Ok(Value::Null) }), // ================= Source monitor ================= - cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?}"#, always, |s, p| { + cmd!("source.open", "Open in Source Monitor", [], None, r#"{"item":id?,"clip":id?}"#, always, |s, p| { + if let Some(clip) = clip_p(p, "clip") { + return crate::source_monitor::open_clip(s, clip); + } let id = item_p(p, "item") .or(s.state.project_selection.first().copied()) .ok_or_else(|| bad("source.open", "need `item` (or a Project panel selection)"))?; diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs index cdbcf35f..84790a52 100644 --- a/crates/engine/src/source_monitor.rs +++ b/crates/engine/src/source_monitor.rs @@ -3,7 +3,7 @@ use crate::clip_ops::source_view; use crate::commands::bad; use crate::{EngineError, Result, Session}; -use filmcraft_project::{ItemId, Label, Marker, MarkerId, MarkerKind, Project}; +use filmcraft_project::{ClipId, ItemId, ItemKind, Label, Marker, MarkerId, MarkerKind, Project}; use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick, TimeRange}; use serde_json::{Value, json}; @@ -203,6 +203,27 @@ pub(crate) fn route(s: &mut Session, id: &str, p: &Value) -> Result Result { + let id = "source.open"; + let seq = s.active_sequence().ok_or(EngineError::NoSequence)?; + let (_, it) = seq.find_item(clip).ok_or_else(|| bad(id, "no such clip in the active sequence"))?; + let t = s.playhead(); + let time = it.source_time_at(if t >= it.start && t < it.end() { t } else { it.start }); + let item = it.item; + match s.project.item(item).map(|i| &i.kind) { + None => return Err(bad(id, "the clip's source is unavailable")), + Some(ItemKind::Graphic { .. }) => return Err(bad(id, "a graphic clip has no source to open")), + Some(_) => {} + } + s.execute(id, json!({"item": item.0}))?; + // source.setPlayhead snaps to the source frame grid and keeps the playhead inside a subclip + s.execute("source.setPlayhead", json!({"time": time.0}))?; + Ok(json!({"item": item.0, "time": s.state.source_playhead.0})) +} + fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result> { p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose() } @@ -289,6 +310,30 @@ mod tests { assert!(source_view(&s, ItemId(5)).unwrap().markers.is_empty()); } #[test] + fn timeline_clip_opens_in_source_at_the_matching_frame() { + let mut s = fixture(); + let seq = s.active_sequence().unwrap(); + let it = seq + .all_tracks() + .flat_map(|t| t.items.iter()) + .find(|i| i.source_in > Tick::ZERO && s.project.item(i.item).is_some_and(|p| p.as_media().is_some())) + .unwrap() + .clone(); + let rate = s.sequence_rate(); + let inside = it.start + rate.tick_of(3); + s.set_playhead(inside); + let r = s.execute("source.open", json!({"clip": it.id.0})).unwrap(); + assert_eq!(r["item"], it.item.0); + assert_eq!(s.state.source_item, Some(it.item)); + let view = source_view(&s, it.item).unwrap(); + assert_eq!(s.state.source_playhead, view.rate.snap(it.source_time_at(inside))); + // playhead off the clip: the clip's first frame + s.set_playhead(it.end() + rate.tick_of(1)); + s.execute("source.open", json!({"clip": it.id.0})).unwrap(); + assert_eq!(s.state.source_playhead, view.rate.snap(it.source_in)); + assert!(s.execute("source.open", json!({"clip": u64::MAX})).is_err()); + } + #[test] fn invalid_marker_parameters_do_not_edit_the_project() { let mut s = fixture(); let before = s.project.to_json(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 9e17453a..7f434c70 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2289,6 +2289,21 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } } + // ---- double-click any other clip (not on its edge): open its media in the Source monitor at + // the matching frame (Premiere); graphic clips have no source to show + if resp.double_clicked() + && tool == Tool::Selection + && let Some(p) = resp.interact_pointer_pos() + && let Hit::Clip { clip, edge: None, .. } = hit(seq, layout, p) + && seq + .find_item(clip) + .and_then(|(_, it)| app.session.project.item(it.item)) + .is_some_and(|i| !matches!(i.kind, filmcraft_project::ItemKind::Sequence(_) | filmcraft_project::ItemKind::Graphic { .. })) + && let Err(e) = app.session.execute("source.open", json!({"clip": clip.0})) + { + app.ui.status = e.to_string(); + } + // ---- context menu on clips (right-clicking an unselected clip selects it first); on a clip's // edge, the edit point menu instead (right-clicking an unselected edit point selects it first) let edit_point_menu_id = egui::Id::new("timeline.editPointMenu.target"); From fad5255dc91f7620f1f08ade598361f3d5b7ba50 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 16:08:58 -0400 Subject: [PATCH 034/203] media: importable tests after the items (clippy items_after_test_module, #677 follow-up) Co-Authored-By: Claude Opus 5.5 --- crates/media/src/lib.rs | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index d65a0630..bfc94de7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -180,20 +180,6 @@ pub fn is_importable(path: &Path) -> bool { STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) } -#[cfg(test)] -mod importable_tests { - use super::*; - use std::path::Path; - - #[test] - fn camera_extensions_match_regardless_of_case() { - for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { - assert!(is_importable(Path::new(name)), "{name}"); - } - assert!(!is_importable(Path::new("notes.txt"))); - } -} - /// A pluggable opener (containers/codecs register themselves here from higher-level crates). pub type Opener = fn(name: &str, bytes: Arc<[u8]>) -> Option>; @@ -244,3 +230,17 @@ impl MediaSource for OfflineSource { Ok(AudioBuffer::silence(sample_rate, self.info.audio().map_or(2, |a| a.channels as usize), frames)) } } + +#[cfg(test)] +mod importable_tests { + use super::*; + use std::path::Path; + + #[test] + fn camera_extensions_match_regardless_of_case() { + for name in ["clip.mov", "clip.MOV", "clip.Mov", "shot.MP4", "IMG_0001.JPG", "sound.WAV", "take.M2TS"] { + assert!(is_importable(Path::new(name)), "{name}"); + } + assert!(!is_importable(Path::new("notes.txt"))); + } +} From 45f2b8e297079ae22d3dd6eb24f623c2fdfa29e6 Mon Sep 17 00:00:00 2001 From: Alex Brazh <69682842+alex-brazh@users.noreply.github.com> Date: Sat, 10 Oct 2026 22:55:52 +0300 Subject: [PATCH 035/203] codecs: an open GOP's leading pictures decode from the GOP before, not as black frames In an open GOP (iPhone HEVC; x265 `open-gop`) each CRA after the first is followed in the file by RASL pictures shown before it, predicted from the GOP before. GopCache started a decoding run at `sync_before(i)`, the sync sample at or before the wanted sample in decoding order: for a RASL picture that is the CRA after it, and the decoders (software and VideoToolbox) rightly leave a run's leading RASL pictures out. The frame was never produced, so the request failed ("frame not produced") and the clip's layer was left out: a black frame (or the track below) wherever a seek landed on one of the 3-in-24 RASL frames of iPhone video, such as a clip trimmed to start on one, the parked playhead, and parallel export. - `decode_start`: a sample shown before its sync sample starts the run at the sync sample before (the MPEG and MXF readers already did this in their own `sync_before`; this covers MP4/MOV and Matroska too). - A running decoder that started after that point is not continued for it: it left the picture out. Tests: a synthetic open-GOP stream in gop.rs, and an x265 open-GOP oracle test (MP4 and Matroska, every frame from a fresh source and each CRA before its leading pictures) against ffmpeg. Both fail without the fix. --- crates/codecs/src/gop.rs | 111 ++++++++++++++++++++++++++++-- crates/codecs/tests/mp4_oracle.rs | 52 ++++++++++++++ 2 files changed, 159 insertions(+), 4 deletions(-) diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index b23b130e..c1395e43 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -122,6 +122,18 @@ pub trait VideoSamples { fn make_decoder(&self) -> crate::Result>; } +/// The sync sample to start decoding at for sample `i`: its sync sample, or an earlier one when +/// `i` is a leading picture of an open GOP (stored after its random-access picture, shown before +/// it, like the RASL pictures after every HEVC CRA of iPhone video). Those are predicted from the +/// GOP before, so a decoder starting at their random-access picture leaves them out. +fn decode_start(s: &dyn VideoSamples, i: usize) -> usize { + let mut key = s.sync_before(i); + while key > 0 && s.pts(i) < s.pts(key) { + key = s.sync_before(key - 1).min(key - 1); + } + key +} + struct State { decoder: Option>, /// The decoder only produces intra pictures: frames decode independently and in parallel. @@ -604,12 +616,13 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } - let mut key = s.sync_before(i); + let mut key = decode_start(s, i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet // (a frame-threaded decoder holds many pictures in flight). Otherwise the frame was - // evicted or lies in another GOP: restart at the sync sample. - let running = st.next != usize::MAX && st.next <= n; + // evicted or lies in another GOP: restart at the sync sample. A run that started after + // `key` (at the random-access picture of a wanted leading picture) left the picture out. + let running = st.next != usize::MAX && st.next <= n && st.start <= key; // Decoding on through a short stretch into the next GOP is cheaper than a restart, and // playback wants those frames anyway. let near = st.next <= key && key - st.next <= CONTINUE_THROUGH; @@ -694,7 +707,7 @@ impl GopCache { want_pts: i64, n: usize, ) -> crate::Result> { - let mut key = s.sync_before(i); + let mut key = decode_start(s, i); // Continue the running decoder when the wanted sample is ahead within this GOP run. let continuing = *next != usize::MAX && *next > key && *next <= i + 16 && *next <= n; if !continuing { @@ -1261,4 +1274,94 @@ mod tests { } assert!(reused() - before >= more as u64 - 2, "{} of {more} planes recycled", reused() - before); } + + /// An open-GOP stream like iPhone HEVC: 8 pictures a GOP; after the first, each GOP stores + /// its random-access picture (display 8·g) first, then two leading pictures shown before it + /// (8·g − 2, 8·g − 1, predicted from the GOP before), then the rest. pts = 1000 · display. + struct OpenGop { + /// (display index, sync) in decode order + order: Vec<(i64, bool)>, + resets: Arc, + } + + impl OpenGop { + fn new(gops: i64) -> Self { + let mut order: Vec<(i64, bool)> = (0..6).map(|d| (d, d == 0)).collect(); + for g in 1..gops { + order.extend([(8 * g, true), (8 * g - 2, false), (8 * g - 1, false)]); + order.extend((8 * g + 1..8 * g + 6).map(|d| (d, false))); + } + Self { order, resets: Default::default() } + } + } + + /// Leaves out the leading pictures of the random-access picture a run starts at (RASL + /// pictures, as the HEVC decoders do), outputs the others at once. + struct OpenGopDec { + run_start: Option, + resets: Arc, + } + + impl VideoDecoder for OpenGopDec { + fn decode(&mut self, _sample: &[u8], pts: i64) -> crate::Result> { + let start = *self.run_start.get_or_insert(pts); + Ok(if pts < start { Vec::new() } else { vec![picture((pts, false))] }) + } + fn flush(&mut self) -> Vec { + Vec::new() + } + fn reset(&mut self) { + self.resets.fetch_add(1, Ordering::Relaxed); + self.run_start = None; + } + fn name(&self) -> &str { + "open gop" + } + } + + impl VideoSamples for OpenGop { + fn count(&self) -> usize { + self.order.len() + } + fn pts(&self, i: usize) -> i64 { + self.order[i].0 * 1000 + } + fn sync_before(&self, i: usize) -> usize { + (0..=i).rev().find(|&k| self.order[k].1).unwrap_or(0) + } + fn sample_at(&self, t: i64) -> Option { + self.order.iter().position(|&(d, _)| d == t / 1000) + } + fn read(&self, i: usize) -> crate::Result> { + Ok(vec![i as u8]) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(OpenGopDec { run_start: None, resets: self.resets.clone() })) + } + } + + /// A leading picture of an open GOP, asked for first (a clip trimmed to start on it, the + /// playhead parked on it), came out black: decoding started at the random-access picture + /// after it, which leaves it out. + #[test] + fn leading_pictures_of_an_open_gop_decode_from_the_gop_before() { + let s = OpenGop::new(6); + for d in [6usize, 7, 14, 15, 39] { + let c = GopCache::new(None); + assert_eq!(index_of(&c.frame(&s, d as i64 * 1000).expect("frame")), d); + } + // the random-access picture first, then its leading pictures: the run that started at + // it left them out, so they restart a GOP earlier + let c = GopCache::new(None); + assert_eq!(index_of(&c.frame(&s, 16_000).expect("frame")), 16); + assert_eq!(index_of(&c.frame(&s, 15_000).expect("frame")), 15); + assert_eq!(index_of(&c.frame(&s, 14_000).expect("frame")), 14); + // playing through: one seek, every picture in turn + let s = OpenGop::new(6); + let c = GopCache::new(None); + for d in 0..46usize { + assert_eq!(index_of(&c.frame(&s, d as i64 * 1000).expect("frame")), d); + } + assert_eq!(s.resets.load(Ordering::Relaxed), 1); + } } diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/mp4_oracle.rs index aef90e15..012424d7 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/mp4_oracle.rs @@ -163,3 +163,55 @@ fn cropped_pictures_match_ffmpeg() { } } } + +/// An open GOP (x265 `open-gop`, like iPhone HEVC): each CRA after the first is followed in the +/// file by RASL pictures shown before it, predicted from the GOP before. Asked for first (a clip +/// trimmed to start on one, the playhead parked on one), such a picture came out black: decoding +/// started at the CRA after it, which leaves its RASL pictures out. Every frame, asked of a fresh +/// source and in an order that visits each CRA before its leading pictures, is ffmpeg's, from MP4 +/// and from Matroska. +#[test] +fn open_gop_leading_pictures_decode_alone() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let args = [ + "-f", + "lavfi", + "-i", + "testsrc2=size=160x96:rate=30:duration=2,noise=alls=10:allf=t", + "-c:v", + "libx265", + "-x265-params", + "log-level=error:keyint=12:min-keyint=12:scenecut=0:open-gop=1:bframes=3", + "-tag:v", + "hvc1", + "-pix_fmt", + "yuv420p", + ]; + let Some(mp4) = fixture(&ff, "open_gop_x265.mp4", &args) else { + eprintln!("SKIPPED: ffmpeg without libx265"); + return; + }; + let mkv = fixture(&ff, "open_gop_x265.mkv", &["-i", mp4.to_str().unwrap(), "-c", "copy"]).unwrap(); + let raw = ffmpeg_frames(&ff, &mp4, "yuv420p"); + let frame_bytes = 160 * 96 * 3 / 2; + let want: Vec<_> = raw.chunks(frame_bytes).map(|f| raw_planes(f, 160, 96, 80, 48, 1)).collect(); + assert_eq!(want.len(), 60); + for path in [&mp4, &mkv] { + let name = path.file_name().unwrap().to_str().unwrap(); + let open = || filmcraft_codecs::open_bytes(name, bytes(path)).unwrap(); + let rate = open().info().frame_rate(); + let frame = |src: &filmcraft_media::SharedSource, i: usize| { + let f = src.video_frame(filmcraft_media::FrameRequest::full(rate.tick_of(i as i64))).unwrap_or_else(|e| panic!("{name}: frame {i}: {e}")); + assert!(planes(&f) == want[i], "{name}: frame {i} differs from ffmpeg's"); + }; + for i in 0..want.len() { + frame(&open(), i); + } + let src = open(); + for cra in [12, 24, 36, 48] { + for i in [cra, cra - 1, cra - 2, cra - 3] { + frame(&src, i); + } + } + } +} From a8b3d7f70391e751934a76e80787ca0ff766b91f Mon Sep 17 00:00:00 2001 From: impostazioni <25422260+impostazioni@users.noreply.github.com> Date: Sat, 10 Oct 2026 21:52:28 +0100 Subject: [PATCH 036/203] Timeline: Option-drag copies clips instead of moving them Holding Option (Alt) while dragging timeline clips now leaves the originals in place and drops copies where the pointer lets go, as in Premiere. The copies are selected; copies of linked clips are linked to each other, and transitions that would travel with a move are copied. - edit: copy_items (overwrite / insert, new ids, links and transitions) - engine: timeline.move takes `copy: true`, returns `copied` - ui: the drag remembers Option, shows the copy under the pointer while the originals stay, copy cursor - tests: engine copy/link/locked-track test, headless Option-drag UI test Co-Authored-By: Claude Opus 5.5 --- crates/edit/src/lib.rs | 54 +++++++++++ crates/engine/src/commands.rs | 27 +++++- crates/engine/src/timeline_move_tests.rs | 35 +++++++ crates/ui-egui/src/panels/timeline.rs | 27 +++++- .../ui-egui/tests/timeline_option_drag_ui.rs | 92 +++++++++++++++++++ docs/control-protocol.md | 5 +- 6 files changed, 230 insertions(+), 10 deletions(-) create mode 100644 crates/ui-egui/tests/timeline_option_drag_ui.rs diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs index e12c5652..d79efe38 100644 --- a/crates/edit/src/lib.rs +++ b/crates/edit/src/lib.rs @@ -506,6 +506,60 @@ pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_ Ok(()) } +/// Copy items to (track, time) with overwrite or insert semantics, leaving the originals where +/// they are (Option-drag). `moves`: (item, destination track, start of the copy). The copies get +/// new ids; copies of linked clips are linked to each other, not to the originals, and the +/// transitions that would travel with a move are copied with them. Returns the copies' ids in +/// the order of `moves`. +pub fn copy_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_mode: bool, ctx: &mut EditCtx) -> Result> { + let mut work = seq.clone(); + let mut placed = Vec::new(); + for (c, dest, start) in moves { + let (_, it) = work.find_item(*c).ok_or(EditError::NoItem(*c))?; + let mut it = it.clone(); + if work.track(*dest).ok_or(EditError::NoTrack(*dest))?.locked { + return Err(EditError::Locked); + } + it.start = (*start).max(Tick::ZERO); + placed.push((*dest, it)); + } + // worked out on the original ids, then moved over to the copies + let carried = carried_transitions(&work, &placed); + let mut ids = HashMap::new(); + let mut links = HashMap::new(); + for (_, it) in &mut placed { + let id = ClipId(ctx.alloc()); + ids.insert(it.id, id); + it.id = id; + if let Some(l) = it.link { + it.link = Some(*links.entry(l).or_insert_with(|| ctx.alloc())); + } + } + // a copy whose linked partner is not copied is not linked + let mut counts: HashMap = HashMap::new(); + for l in placed.iter().filter_map(|(_, it)| it.link) { + *counts.entry(l).or_default() += 1; + } + for (_, it) in &mut placed { + if it.link.is_some_and(|l| counts.get(&l).copied().unwrap_or(0) < 2) { + it.link = None; + } + } + let out = if insert_mode { insert(&mut work, placed, ctx)? } else { overwrite(&mut work, placed, ctx)? }; + for (dest, mut tr) in carried { + tr.id = TransitionId(ctx.alloc()); + tr.from = tr.from.and_then(|c| ids.get(&c).copied()); + tr.to = tr.to.and_then(|c| ids.get(&c).copied()); + let t = track_mut(&mut work, dest)?; + if [tr.from, tr.to].into_iter().flatten().all(|c| t.item(c).is_some()) && (tr.from.is_some() || tr.to.is_some()) { + t.transitions.push(tr); + t.sort(); + } + } + *seq = work; + Ok(out) +} + /// The transitions that travel with a move (#227, #374): those whose clips all move, onto one /// track and by one offset (a fade at a moved clip's edge, or the cut between two clips that move /// together), shifted to where those clips land. diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6f2eb007..ab00abc2 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -302,7 +302,9 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option { /// every clip of the call lands later by the same amount, so the spacing asked for is kept. /// `overwritten` lists every clip the move covered without being asked to move it: its length /// before and after (ticks) and the clips what is left of it now lives on as (none = removed; a -/// clip a moved clip lands inside is split in two). +/// clip a moved clip lands inside is split in two). With `copy: true` (Option-drag) the clips stay +/// where they are and copies land at the destinations instead; the copies are selected and listed +/// as `copied`. fn move_clips(s: &mut Session, p: &Value) -> Result { const CMD: &str = "timeline.move"; let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new(); @@ -345,19 +347,31 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { moves.push((clip, track, start)); } let ins = bool_p(p, "insert").unwrap_or(false); + let copy = bool_p(p, "copy").unwrap_or(false); // every clip on the sequence now: id -> (track, start, end, project item) let spots = |s: &Session| -> Vec<(u64, TrackId, Tick, Tick, ItemId)> { let q = s.active_sequence(); q.into_iter().flat_map(|q| q.all_tracks()).flat_map(|t| t.items.iter().map(move |i| (i.id.0, t.id, i.start, i.end(), i.item))).collect() }; let before = spots(s); - s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?; + let copied = if copy { + let label = if ins { "Copy (Insert)" } else { "Copy" }; + s.edit_sequence(label, |q, ctx, st| { + let ids = edit::copy_items(q, &moves, ins, ctx)?; + st.selection = ids.clone(); + st.transition_selection.clear(); + Ok(ids) + })? + } else { + s.edit_sequence(if ins { "Move (Insert)" } else { "Move" }, |q, ctx, _| Ok(edit::move_items(q, &moves, ins, ctx)?))?; + Vec::new() + }; // An overwrite shortens, splits or removes whatever already sits where a clip lands, and a // shortened clip may live on under a new id: name each clip the move changed without being // asked to, with what is left of it, so the caller hears about it instead of finding out later. let after = spots(s); - let moved = |c: u64| moves.iter().any(|m| m.0.0 == c); - let new = |c: u64| !before.iter().any(|b| b.0 == c); + let moved = |c: u64| moves.iter().any(|m| m.0.0 == c) && !copy; + let new = |c: u64| !before.iter().any(|b| b.0 == c) && !copied.iter().any(|k| k.0 == c); let mut overwritten = Vec::new(); for &(c, track, start, end, item) in before.iter().filter(|b| !moved(b.0)) { let kept = after.iter().find(|a| a.0 == c); @@ -369,6 +383,9 @@ fn move_clips(s: &mut Session, p: &Value) -> Result { let now: i64 = pieces.iter().map(|a| (a.3 - a.2).0).sum(); overwritten.push(json!({"clip": c, "was": (end - start).0, "now": now, "pieces": pieces.iter().map(|a| a.0).collect::>()})); } + if copy { + return Ok(json!({"copied": copied.iter().map(|c| c.0).collect::>(), "overwritten": overwritten})); + } Ok(json!({"moved": moves.iter().map(|m| m.0.0).collect::>(), "overwritten": overwritten})) } @@ -2251,7 +2268,7 @@ fn build() -> Vec { "Move Clips", [], None, - r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?}"#, + r#"{"moves":[{"clip":id,"track":id|"V2","time":ticks}],"insert":bool,"linked":bool?,"copy":bool?}"#, has_seq, move_clips ), diff --git a/crates/engine/src/timeline_move_tests.rs b/crates/engine/src/timeline_move_tests.rs index e80c2f97..6f176b81 100644 --- a/crates/engine/src/timeline_move_tests.rs +++ b/crates/engine/src/timeline_move_tests.rs @@ -247,3 +247,38 @@ fn a_move_that_lands_on_another_clip_reports_what_it_cut() { let r = s.execute("timeline.move", json!({"moves": [{"clip": a1.id.0, "track": a_track.0, "time": a2.start.0}], "insert": true, "linked": false})).unwrap(); assert_eq!(r["overwritten"], json!([])); } + +/// Option-drag (`copy: true`) moved the clips instead of copying them. +#[test] +fn copy_leaves_the_originals_and_links_the_copies() { + let mut s = demo(); + let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0); + let to = v.end() + s.sequence_rate().tick_of(24); + let before = s.project.clone(); + // the picture is listed; its linked sound is copied with it + let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": to.0}], "copy": true})).unwrap(); + let copies: Vec = r["copied"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect(); + assert_eq!(copies.len(), 2, "picture and sound are copied: {r}"); + assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, v.start), (a_track, a.start)), "the originals stay"); + let q = s.active_sequence().unwrap(); + let (vt, vc) = q.find_item(copies[0]).unwrap(); + let (at, ac) = q.find_item(copies[1]).unwrap(); + assert_eq!((vt, vc.start, vc.item, vc.source_in, vc.duration), (q.video_tracks[1].id, to, v.item, v.source_in, v.duration)); + assert_eq!((at, ac.start), (a_track, to), "the sound copy keeps its offset on its own track"); + assert!(vc.link.is_some() && vc.link == ac.link && vc.link != v.link, "the copies are linked to each other only"); + assert_eq!(s.state.selection, copies, "the copies are selected"); + q.check().unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(*s.project, *before, "one undo step"); + // copying just one side (linked: false) leaves the copy unlinked + let r = s.execute("timeline.move", json!({"moves": [{"clip": a.id.0, "track": a_track.0, "time": to.0}], "copy": true, "linked": false})).unwrap(); + let c = ClipId(r["copied"][0].as_u64().unwrap()); + assert_eq!(r["copied"].as_array().unwrap().len(), 1); + assert!(s.active_sequence().unwrap().find_item(c).unwrap().1.link.is_none()); + // a copy onto a locked track is refused and changes nothing + s.execute("edit.undo", json!({})).unwrap(); + s.execute("timeline.setTrack", json!({"track": "V2", "locked": true})).unwrap(); + let r = s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": to.0}], "copy": true, "linked": false})); + assert!(r.is_err()); + assert_eq!(s.active_sequence().unwrap().all_tracks().map(|t| t.items.len()).sum::(), 2); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 28b75008..55c8b9c1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -89,6 +89,9 @@ pub enum Drag { start_track: TrackId, offset: Tick, track_delta: i32, + /// Option is held: the drop copies the clips instead of moving them (as of the last + /// drag frame, so letting go of both at once still copies). + copy: bool, }, Trim { clip: ClipId, @@ -398,6 +401,12 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // item, so the waveform and thumbnail stay where the media is instead of squeezing (#374) let mut edited: HashMap = HashMap::new(); preview_drag(app, &seq, &mut previews, &mut edited); + // Option-drag copies: the clips stay where they are and their copies follow the pointer + let copying = matches!(app.tl.drag, Some(Drag::Move { copy: true, .. })); + let copies: Vec<(ClipId, (Tick, Tick, Option))> = if copying { previews.drain().collect() } else { Vec::new() }; + if copying { + ctx.set_cursor_icon(CursorIcon::Copy); + } for r in &rows { let Some(tr) = seq.track(r.track) else { continue }; let clip_rect = if r.kind == TrackKind::Video { vclip } else { aclip }; @@ -449,6 +458,15 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { } } } + for (cid, (start, dur, mt)) in &copies { + if let Some((home, it)) = seq.find_item(*cid) + && mt.unwrap_or(home) == r.track + { + let body = Rect::from_min_max(pos2(layout.x_of(*start), r.rect.min.y + 1.0), pos2(layout.x_of(*start + *dur), r.rect.max.y - 1.0)); + draw_clip(app, &ctx, &p, body, it, r.kind, true, &t, rate); + app.auto.add(&format!("timeline.clip.{}.copy", cid.0), body.intersect(content), &it.name); + } + } // Show Through Edits: a small bow-tie on cuts between continuous pieces of one clip if app.session.state.show_through_edits { for te in filmcraft_edit::through::track_through_edits(tr) { @@ -2080,7 +2098,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & } if resp.drag_started() { let clips = app.session.state.selection.clone(); - Some(Drag::Move { clips, grab_tick: t, start_track: track, offset: Tick::ZERO, track_delta: 0 }) + Some(Drag::Move { clips, grab_tick: t, start_track: track, offset: Tick::ZERO, track_delta: 0, copy: mods.alt }) } else { None } @@ -2173,7 +2191,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & (Some(c), Some(s)) if c.kind == s.kind => c.index as i32 - s.index as i32, _ => 0, }; - Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta }) + Some(Drag::Move { clips, grab_tick, start_track, offset, track_delta, copy: mods.alt }) } Drag::Trim { clip, edge, mode, from, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; @@ -2240,12 +2258,13 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & && let Some(d) = app.tl.drag.take() { let r = match d { - Drag::Move { clips, offset, track_delta, .. } if offset != Tick::ZERO || track_delta != 0 => { + Drag::Move { clips, offset, track_delta, copy, .. } if offset != Tick::ZERO || track_delta != 0 => { let moves: Vec = clips .iter() .filter_map(|c| seq.find_item(*c).map(|(tid, it)| json!({"clip": c.0, "track": shift_track(seq, tid, track_delta).unwrap_or(tid).0, "time": (it.start + offset).max(Tick::ZERO).0}))) .collect(); - Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false}))) + // Option held while dropping copies (Premiere: Option-drag duplicates) + Some(app.session.execute("timeline.move", json!({"moves": moves, "insert": mods.command, "linked": false, "copy": copy || mods.alt}))) } Drag::Trim { clip, edge, mode, delta, .. } if delta != Tick::ZERO => Some(app.session.execute( "timeline.trim", diff --git a/crates/ui-egui/tests/timeline_option_drag_ui.rs b/crates/ui-egui/tests/timeline_option_drag_ui.rs new file mode 100644 index 00000000..01840f43 --- /dev/null +++ b/crates/ui-egui/tests/timeline_option_drag_ui.rs @@ -0,0 +1,92 @@ +//! Option-drag of a timeline clip copies it (Premiere): the original stays, a copy lands where +//! the pointer lets go and is selected; a plain drag still moves. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000).build_eframe(move |_cc| app); + let mut d = Driver { harness, tx }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + assert_eq!(v["ok"], json!(true), "{method} {params} failed: {v}"); + return v["result"].clone(); + } + } + panic!("no reply to {method} {params}"); + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } +} + +fn clip_count(d: &mut Driver) -> usize { + d.harness.state().session.active_sequence().map(|q| q.all_tracks().map(|t| t.items.len()).sum()).unwrap_or(0) +} + +#[test] +fn option_drag_copies_a_clip_and_a_plain_drag_moves_it() { + let mut d = Driver::demo(); + // the clip on V2 (no linked sound), and empty space to its right + let q = d.harness.state().session.active_sequence().unwrap().clone(); + let v2 = q.video_tracks[1].items[0].clone(); + let n = clip_count(&mut d); + let r = d.rect(&format!("timeline.clip.{}", v2.id.0)); + let (x, y) = (r[0] + 10.0, r[1] + r[3] / 2.0); + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 240.0, "y": y}, "steps": 12, "modifiers": {"alt": true}})); + d.frames(3); + let q2 = d.harness.state().session.active_sequence().unwrap().clone(); + assert_eq!(clip_count(&mut d), n + 1, "one copy was added"); + assert_eq!(q2.find_item(v2.id).map(|(_, i)| i.start), Some(v2.start), "the original stays"); + let sel = d.harness.state().session.state.selection.clone(); + assert_eq!(sel.len(), 1); + let (track, copy) = q2.find_item(sel[0]).unwrap(); + assert_ne!(copy.id, v2.id); + assert_eq!((track, copy.item, copy.duration), (q.video_tracks[1].id, v2.item, v2.duration)); + assert!(copy.start > v2.start, "the copy is where the pointer let go"); + // one undo removes the copy + d.exec("edit.undo", json!({})); + assert_eq!(clip_count(&mut d), n); + // without Option the same drag moves the clip + d.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + 240.0, "y": y}, "steps": 12})); + d.frames(3); + assert_eq!(clip_count(&mut d), n); + assert!(d.harness.state().session.active_sequence().unwrap().find_item(v2.id).unwrap().1.start > v2.start, "moved"); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 28b86c30..fc8f62a8 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -102,7 +102,10 @@ is, and so does one whose position would not change (the listed clip only change lists every clip that moved, partners included. No clip is moved before the sequence start: if one would be, all clips of the call land later by the same amount (their spacing is kept). A `time` beyond the representable range is an "invalid parameters" error. The timeline -panel's drag passes `linked: false`, because it already lists exactly the clips it moves. +panel's drag passes `linked: false`, because it already lists exactly the clips it moves. With +`copy: true` (Option-drag in the timeline) the clips stay where they are and copies land at the +destinations instead (linked partners are copied too, unless `linked: false`); the result lists +the new clips as `copied`, they are selected, and copies of linked clips are linked to each other. **Trim mode** (`crates/engine/src/trim.rs`): `trim.selectEditPoint` / `trim.selectNearest` enter trim mode (the Program monitor becomes the Trim Monitor, ids `trimMonitor.*`); `trim.monitor` returns what From baadac2cf81f57b3b980cd260e6c1b48b6869cdc Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Sat, 10 Oct 2026 14:59:52 -0600 Subject: [PATCH 037/203] UI.9: integrate shared docking and preserve workspace behavior --- Cargo.lock | 10 + Cargo.toml | 2 + ROADMAP.md | 3 + apps/filmcraft/src/args.rs | 24 +- apps/filmcraft/src/main.rs | 4 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/README.md | 25 ++ crates/ui-egui/src/automation.rs | 29 +- crates/ui-egui/src/control.rs | 52 ++- crates/ui-egui/src/dock.rs | 331 +++++++++----- crates/ui-egui/src/dock_shared.rs | 113 +++++ crates/ui-egui/src/dock_ui.rs | 394 +++++++++++++++++ crates/ui-egui/src/i18n/es.tsv | 6 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 + crates/ui-egui/src/lib.rs | 139 +++--- crates/ui-egui/src/menus.rs | 7 + crates/ui-egui/src/panels/keyboard.rs | 8 +- crates/ui-egui/src/panels/mod.rs | 106 +++-- crates/ui-egui/src/panels/monitor.rs | 175 ++++++-- crates/ui-egui/src/panels/timeline.rs | 7 + crates/ui-egui/src/panels/tools.rs | 13 +- crates/ui-egui/src/panels/workspaces.rs | 9 +- crates/ui-egui/src/state.rs | 3 + crates/ui-egui/tests/monitor_geometry.rs | 185 ++++++++ crates/ui-egui/tests/project_panel_ui.rs | 19 +- crates/ui-egui/tests/shared_docking_ui.rs | 515 ++++++++++++++++++++++ crates/ui-egui/tests/timecode_ui.rs | 34 ++ crates/ui-egui/tests/timeline_drop_ui.rs | 114 +++++ crates/ui-egui/tests/ui_control_input.rs | 277 ++++++++++++ crates/ui-egui/tests/ui_dock_resize.rs | 95 ++++ crates/ui-egui/tests/ui_keyboard_focus.rs | 107 +++++ docs/control-protocol.md | 43 +- xtask/src/main.rs | 2 +- 33 files changed, 2551 insertions(+), 304 deletions(-) create mode 100644 crates/ui-egui/src/dock_shared.rs create mode 100644 crates/ui-egui/src/dock_ui.rs create mode 100644 crates/ui-egui/tests/monitor_geometry.rs create mode 100644 crates/ui-egui/tests/shared_docking_ui.rs create mode 100644 crates/ui-egui/tests/ui_control_input.rs create mode 100644 crates/ui-egui/tests/ui_dock_resize.rs create mode 100644 crates/ui-egui/tests/ui_keyboard_focus.rs diff --git a/Cargo.lock b/Cargo.lock index d94a4457..66008b52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1011,6 +1011,15 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=5c874d51eeb3de70e735c8ce87a59a7dedd47675#5c874d51eeb3de70e735c8ce87a59a7dedd47675" +dependencies = [ + "egui", + "serde", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -2262,6 +2271,7 @@ name = "filmcraft-ui-egui" version = "0.5.0" dependencies = [ "cpu-time", + "craft-ui", "eframe", "egui", "egui_kittest", diff --git a/Cargo.toml b/Cargo.toml index 1d306cff..cfe2ae9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,6 +67,8 @@ proptest = "1" roxmltree = "0.21" # ui +# Shared frontend components; keep this dependency above the engine boundary. +craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } egui = "0.36" eframe = { version = "0.36", default-features = false } egui_kittest = "0.36" diff --git a/ROADMAP.md b/ROADMAP.md index e22211db..b604e069 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,8 @@ # FilmCraft Roadmap +- UI.6: Compact Source and Program monitors separate timecodes from view settings and retain Source drag choices. Transport controls use the shared toolbar sizing helper, with overflow actions in an operable More menu and a scrolling fallback at tiny widths. +- UI.3: Panel tabs, edge splits, tab reordering and floating groups use the shared docking model and drop resolver. Floating geometry is saved with named workspaces while legacy layout JSON remains readable. Dock dividers use bounded shared geometry and keyboard-accessible resize handles. Tools and dock tabs expose actual widget focus, paint focus outlines, and support keyboard traversal/activation. Control input reaches focused widgets in background windows; optional input isolation supports unattended validation. Existing workspace trees and saved layout formats are retained. + Progress toward parity with Adobe Premiere Pro, with estimates. Updated as milestones land. **Last updated:** 2026-10-05 · **Feature checklist:** ~87% · **Ready for real work:** ~50–60% · **Code:** 35 crates, 1605+ tests diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a968..db208012 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -7,6 +7,8 @@ Usage: filmcraft [options] [project.fcproj | media files...] Options: --control start the localhost JSON-lines control server (also: FILMCRAFT_CONTROL_PORT) + --control-input-only accept widget input only from the control server + (requires --control or FILMCRAFT_CONTROL_PORT) --demo open the demo project --empty start with an empty project --recover recover the newest unsaved changes without asking @@ -29,6 +31,7 @@ pub enum Cli { #[derive(Debug, PartialEq)] pub struct Launch { pub control_port: Option, + pub control_input_only: bool, /// A project and / or media files to open. pub files: Vec, pub demo: bool, @@ -46,7 +49,7 @@ pub struct Launch { /// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error /// too, and for a port from the environment exactly as for one from `--control`. pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { - let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; + let mut l = Launch { control_port: None, control_input_only: false, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; let (mut help, mut version) = (false, false); let mut args = args.into_iter(); while let Some(a) = args.next() { @@ -58,6 +61,7 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator (l.demo, l.startup_flag) = (true, true), + "--control-input-only" => l.control_input_only = true, "--empty" => (l.demo, l.startup_flag) = (false, true), "--recover" => l.recover = Some(true), "--no-recover" => l.recover = Some(false), @@ -81,6 +85,9 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -267,7 +267,7 @@ fn main() -> eframe::Result { } if let Some(port) = control_port { let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); + app = if control_input_only { app.with_control_input_only(rx) } else { app.with_control(rx) }; } Ok(Box::new(app)) }), diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 4a99b17b..950c22ac 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true workspace = true [dependencies] +craft-ui = { workspace = true } filmcraft-time = { workspace = true } filmcraft-geom = { workspace = true } filmcraft-color = { workspace = true } diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 699a95d4..0e9583e3 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -57,3 +57,28 @@ range validation and undo; the UI owns the drag gesture. See `source_drag_ui` an Source range handles use transient gesture previews and the engine's integer-frame `source_monitor::adjust_range`; release dispatches one mark edit. Monitor command routing keeps Source navigation, markers and marked-range playback separate from Program. + +## Pane layout controls + +Dock transactions and pointer targets use `craft-ui::docking`; bounded split geometry and +keyboard-accessible resize handles use `craft-ui::layout`. FilmCraft adapts its existing saved +dock tree and panel identities to the shared model. Panel tabs or their header grip can move +between tab groups, split at an edge, reorder, or float when released with Cmd/Ctrl. Floating +panels move by their empty header and resize from the bottom-right corner. Timeline sequence +tabs retain their sequence-opening, closing and horizontal reorder behavior; the header grip +moves the Timeline panel. Workspace saves retain floating groups alongside the legacy layout +JSON, and workspaces without floating data still load. Automation IDs and workspace commands +remain the integration boundary. + +Tools and dock tabs expose their actual egui widget IDs, paint keyboard focus, and support +Tab/Shift-Tab traversal and Space/Enter activation. Control-injected keys can reach focused +widgets in a background window. The optional `--control-input-only` native launch mode isolates +automation from native widget/menu input; `ui.inspect` reports native and effective focus +separately. Focus and input-ownership regressions cover document/playhead preservation. + +Compact monitor layouts separate the current and duration timecodes from zoom, resolution and +settings. Source video/audio drag choices retain their existing placement behavior. Transport +buttons use `craft-ui::layout::fit_toolbar`; commands beyond the available width remain available +through More, including their selected state and Source/Program routing. A scrolling fallback +retains all commands if even More cannot fit. `monitor_geometry` checks the painted readouts, +control bounds, overflow actions and Source drag payloads across monitor widths and themes. diff --git a/crates/ui-egui/src/automation.rs b/crates/ui-egui/src/automation.rs index 9b9ae3bb..850fcb8d 100644 --- a/crates/ui-egui/src/automation.rs +++ b/crates/ui-egui/src/automation.rs @@ -10,6 +10,15 @@ pub struct Element { pub id: String, pub label: String, pub rect: [f32; 4], + /// Present only when this entry represents a real egui widget, rather than a click alias. + #[serde(rename = "widgetId", skip_serializing_if = "Option::is_none")] + pub widget_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub focusable: Option, + #[serde(skip)] + pub(crate) response_id: Option, } #[derive(Default)] @@ -21,7 +30,25 @@ pub struct Registry { impl Registry { pub fn add(&mut self, id: &str, rect: Rect, label: &str) { - self.elements.push(Element { id: id.to_string(), label: label.to_string(), rect: [rect.min.x, rect.min.y, rect.width(), rect.height()] }); + self.elements.push(Element { + id: id.to_string(), + label: label.to_string(), + rect: [rect.min.x, rect.min.y, rect.width(), rect.height()], + widget_id: None, + enabled: None, + focusable: None, + response_id: None, + }); + } + /// Observe the widget without changing its accessibility role or input behavior. + pub fn add_response(&mut self, id: &str, response: &egui::Response, label: &str) { + self.add(id, response.rect, label); + if let Some(element) = self.elements.last_mut() { + element.widget_id = Some(format!("{:016x}", response.id.value())); + element.enabled = Some(response.enabled()); + element.focusable = Some(response.sense.is_focusable()); + element.response_id = Some(response.id); + } } pub fn begin_frame(&mut self) { self.previous = std::mem::take(&mut self.elements); diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 16260258..7b4bdd6d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -288,7 +288,9 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) if req.method == "ui.panel.show" { app.show_panel(panel); } else { - app.ui.dock.close(panel); + if let Err(error) = app.apply_dock(craft_ui::docking::Action::Close { panel }) { + return err(&error); + } } ok(Value::Null) } @@ -299,6 +301,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) Err(e) => return err(e), }; let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(pos)); if req.method == "ui.click" { let button = match s("button") { @@ -312,6 +315,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerButton { pos, button, pressed: false, modifiers: m }); } } + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.drag" => { @@ -323,6 +327,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let steps = p.get("steps").and_then(Value::as_u64).unwrap_or(12).max(2); let m = modifiers(p); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::PointerMoved(a)); app.synthetic.push(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: m }); for i in 1..=steps { @@ -330,6 +335,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) app.synthetic.push(egui::Event::PointerMoved(a + (b - a) * f)); } app.synthetic.push(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.scroll" => { @@ -339,6 +345,7 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) }; let dx = p.get("dx").and_then(Value::as_f64).unwrap_or(0.0) as f32; let dy = p.get("dy").and_then(Value::as_f64).unwrap_or(0.0) as f32; + app.synthetic.push(egui::Event::ModifiersChanged(modifiers(p))); app.synthetic.push(egui::Event::PointerMoved(pos)); app.synthetic.push(egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, @@ -352,18 +359,22 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) pressed: false, modifiers: Default::default(), }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.key" => { let Some(name) = s("key") else { return err("missing `key`") }; let Some((m, key)) = crate::menus::parse_shortcut(name) else { return err(format!("unknown key `{name}`")) }; let m = as_pressed(m | modifiers(p)); + app.synthetic.push(egui::Event::ModifiersChanged(m)); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: m }); app.synthetic.push(egui::Event::Key { key, physical_key: None, pressed: false, repeat: false, modifiers: m }); + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); Outcome::AfterInput } "ui.type" => { let Some(text) = s("text") else { return err("missing `text`") }; + app.synthetic.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); app.synthetic.push(egui::Event::Text(text.to_string())); app.synthetic.push(egui::Event::Key { key: egui::Key::F35, physical_key: None, pressed: false, repeat: false, modifiers: Default::default() }); Outcome::AfterInput @@ -404,9 +415,30 @@ pub fn handle(app: &mut FilmcraftApp, ctx: &egui::Context, req: &ControlRequest) ok(Value::Null) } "ui.focus" => { - // Explicit request: activate the app and take keyboard focus. - ctx.send_viewport_cmd(egui::ViewportCommand::Focus); - ok(Value::Null) + if p.is_null() || p.as_object().is_some_and(|object| object.is_empty()) { + // Existing explicit request: activate the app and take OS keyboard focus. + ctx.send_viewport_cmd(egui::ViewportCommand::Focus); + return ok(Value::Null); + } + let Some(object) = p.as_object().filter(|object| object.len() == 1 && object.contains_key("id")) else { + return err("ui.focus expects {} or {id: string}"); + }; + let Some(id) = object.get("id").and_then(Value::as_str) else { return err("ui.focus needs a string `id`") }; + let Some(element) = app.auto.find(id) else { return err(format!("no element `{id}` (see ui.elements)")) }; + let Some(widget_id) = element.response_id else { return err(format!("element `{id}` has no focusable widget")) }; + let Some(response) = ctx.read_response(widget_id) else { return err(format!("widget `{id}` is not present")) }; + if !response.enabled() + || !response.sense.is_focusable() + || !response.interact_rect.is_finite() + || !response.interact_rect.is_positive() + || !response.interact_rect.intersects(ctx.content_rect()) + { + return err(format!("widget `{id}` is not enabled, visible and focusable")); + } + // A widget target never activates the native window. The next ui.inspect reports + // actual focus ownership separately from this request acknowledgement. + response.request_focus(); + ok(json!({"focusRequested": id, "widgetId": format!("{:016x}", widget_id.value())})) } "app.quit" => { ctx.send_viewport_cmd(egui::ViewportCommand::Close); @@ -435,10 +467,22 @@ fn merge(v: &mut Value, patch: &Value) { pub fn inspect(app: &FilmcraftApp, ctx: &egui::Context) -> Value { let size = ctx.content_rect().size(); + let keyboard_focus = ctx.memory(|memory| memory.focused()).map(|id| { + let elements: Vec<_> = app.auto.previous.iter().filter(|element| element.response_id == Some(id)).collect(); + json!({ + "widgetId": format!("{:016x}", id.value()), + "elementIds": elements.iter().map(|element| element.id.as_str()).collect::>(), + "label": elements.first().map(|element| element.label.as_str()), + "active": ctx.read_response(id).is_some_and(|response| response.has_focus()), + }) + }); json!({ "window": [size.x, size.y], "pixelsPerPoint": ctx.pixels_per_point(), "fps": app.fps, + "keyboardFocus": keyboard_focus, + "input": {"mode": if app.control_input_only { "controlOnly" } else { "interactive" }, + "nativeWindowFocused": app.native_window_focused, "effectiveWindowFocused": ctx.input(|input| input.focused)}, "ui": app.ui, "sourcePlayback": {"playing": app.source_playback.clock.playing, "audioClock": app.source_playback.clock.audio_clock, "playhead": app.session.state.source_playhead.0}, "playback": {"playing": app.playback.playing, "speed": app.playback.speed, "loop": app.playback.looping, "audioClock": app.playback.audio_clock, "dropped": app.playback.meter.counts().1, "shown": app.playback.meter.counts().0, "preroll": app.playback.preroll.is_some()}, diff --git a/crates/ui-egui/src/dock.rs b/crates/ui-egui/src/dock.rs index 05acf45b..218bca0c 100644 --- a/crates/ui-egui/src/dock.rs +++ b/crates/ui-egui/src/dock.rs @@ -5,6 +5,8 @@ use egui::{Align2, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; use serde::{Deserialize, Serialize}; +use craft_ui::layout::{ResizeHandle, ResizeHandleStyle, SplitAxis, SplitSize as SharedSplitSize, split_rect}; + use crate::icons::{self, Icon}; use crate::theme::Tokens; @@ -137,6 +139,16 @@ pub enum SplitSize { FixedB(f32), } +impl SplitSize { + fn shared(self) -> SharedSplitSize { + match self { + Self::Ratio(value) => SharedSplitSize::Ratio(value), + Self::FixedA(value) => SharedSplitSize::FixedFirst(value), + Self::FixedB(value) => SharedSplitSize::FixedSecond(value), + } + } +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub enum DockNode { /// `vertical`: children stacked top/bottom; else side by side. @@ -183,11 +195,24 @@ pub struct WorkspacePrefs { pub struct SavedWorkspace { pub name: String, pub layout: serde_json::Value, + /// Kept raw for the same per-workspace failure isolation as the dock tree. + #[serde(default, skip_serializing_if = "serde_json::Value::is_null")] + pub floating: serde_json::Value, } impl WorkspacePrefs { pub fn load(path: &std::path::Path) -> Self { - std::fs::read(path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default() + use std::io::Read as _; + // Preferences are untrusted; bound reading before JSON can allocate from it. + const MAX_BYTES: u64 = 4 * 1024 * 1024; + let Some(bytes) = std::fs::File::open(path).ok().and_then(|file| { + let mut bytes = Vec::new(); + file.take(MAX_BYTES + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() as u64 <= MAX_BYTES).then_some(bytes) + }) else { + return Self::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() } pub fn save(&self, path: &std::path::Path) -> std::io::Result<()> { if let Some(d) = path.parent() { @@ -228,7 +253,55 @@ pub fn find(prefs: &WorkspacePrefs, query: &str) -> Option { /// The saved layout of a workspace: the user's saved version, else the built-in default. pub fn saved_layout(prefs: &WorkspacePrefs, name: &str) -> DockNode { - prefs.saved.iter().find(|s| s.name == name).and_then(|s| serde_json::from_value(s.layout.clone()).ok()).unwrap_or_else(|| workspace(name)) + saved_docking(prefs, name).root +} + +pub fn saved_docking(prefs: &WorkspacePrefs, name: &str) -> Layout { + let saved = prefs.saved.iter().find(|saved| saved.name == name).and_then(|saved| { + let mut layout = Layout { + root: serde_json::from_value(saved.layout.clone()).ok()?, + floating: if saved.floating.is_null() { vec![] } else { serde_json::from_value(saved.floating.clone()).ok()? }, + }; + if layout.floating.is_empty() { + migrate_legacy_effects(&mut layout.root); + } + layout.validate().ok().map(|()| layout) + }); + saved.unwrap_or_else(|| Layout { root: workspace(name), floating: vec![] }) +} + +/// Older Effects workspaces stored the same Effect Controls panel twice: selected beside +/// Source, and an inactive duplicate beside Essential Graphics. Remove only that known +/// duplicate shape; unrelated malformed or ambiguous saved layouts still fail validation. +fn migrate_legacy_effects(root: &mut DockNode) { + let Ok(shared) = crate::dock_shared::encode(root, &[]) else { return }; + if shared.panels().iter().filter(|panel| ***panel == PanelKind::EffectControls).count() != 2 { + return; + } + fn selected_source(node: &DockNode) -> bool { + match node { + DockNode::Tabs { panels, active } => { + panels.contains(&PanelKind::Source) && panels.contains(&PanelKind::AudioClipMixer) && panels.get(*active) == Some(&PanelKind::EffectControls) + } + DockNode::Split { a, b, .. } => selected_source(a) || selected_source(b), + } + } + fn remove_inactive(node: &mut DockNode) { + match node { + DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EffectControls, PanelKind::EssentialGraphics] && *active == 1 => { + panels.remove(0); + *active = 0; + } + DockNode::Split { a, b, .. } => { + remove_inactive(a); + remove_inactive(b); + } + DockNode::Tabs { .. } => {} + } + } + if selected_source(root) { + remove_inactive(root); + } } /// The default layout of a named workspace. @@ -262,7 +335,7 @@ pub fn workspace(name: &str) -> DockNode { hsplit(Ratio(0.5), tabs(&[Source, EffectControls, AudioClipMixer], 1), tabs(&[Program], 0)), hsplit(Ratio(0.3), tabs(&[Project, MediaBrowser, Info, Markers, History], 0), bottom_editing()), ), - vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EffectControls, EssentialGraphics], 1)), + vsplit(Ratio(0.5), tabs(&[Effects], 0), tabs(&[EssentialGraphics], 0)), ), "Audio" => hsplit( FixedB(320.0), @@ -327,17 +400,7 @@ impl DockNode { } /// Make `p` the active tab of its group. Returns false if not present. pub fn activate(&mut self, p: PanelKind) -> bool { - match self { - DockNode::Split { a, b, .. } => a.activate(p) || b.activate(p), - DockNode::Tabs { panels, active } => { - if let Some(i) = panels.iter().position(|x| *x == p) { - *active = i; - true - } else { - false - } - } - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Activate { panel: p }) } pub fn is_visible(&self, p: PanelKind) -> bool { match self { @@ -347,19 +410,7 @@ impl DockNode { } /// Close a panel (remove its tab; empty groups collapse their split). pub fn close(&mut self, p: PanelKind) { - if let DockNode::Split { a, b, .. } = self { - a.close(p); - b.close(p); - let empty = |n: &DockNode| matches!(n, DockNode::Tabs { panels, .. } if panels.is_empty()); - if empty(a) { - *self = (**b).clone(); - } else if empty(b) { - *self = (**a).clone(); - } - } else if let DockNode::Tabs { panels, active } = self { - panels.retain(|x| *x != p); - *active = (*active).min(panels.len().saturating_sub(1)); - } + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Close { panel: p }); } /// Put the Timeline panel back where the workspaces keep it (left of the audio meters) after /// it was closed, so opening a sequence always has somewhere to show it. @@ -383,27 +434,40 @@ impl DockNode { } /// Add a panel as a tab next to `near` (or into the first group). pub fn open_near(&mut self, p: PanelKind, near: PanelKind) { - if self.contains(p) { - self.activate(p); + if crate::dock_shared::encode(self, &[]).is_err() { return; } - fn add(n: &mut DockNode, p: PanelKind, near: PanelKind) -> bool { - match n { - DockNode::Split { a, b, .. } => add(a, p, near) || add(b, p, near), - DockNode::Tabs { panels, active } => { - if panels.contains(&near) { - panels.push(p); - *active = panels.len() - 1; - true - } else { - false - } - } - } - } - if !add(self, p, near) && !add(self, p, PanelKind::Project) { - add(self, p, PanelKind::Program); - } + let anchor = [near, PanelKind::Project, PanelKind::Program].into_iter().find(|panel| self.contains(*panel)); + crate::dock_shared::apply_tree(self, craft_ui::docking::Action::Open { panel: p, anchor }); + } +} + +/// A floating group retains the same panel identities as docked tabs. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Floating { + pub panels: Vec, + pub active: usize, + /// [x, y, width, height] in logical points. + pub rect: [f32; 4], +} + +/// App persistence shape; all mutations pass through the shared docking model. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct Layout { + pub root: DockNode, + #[serde(default)] + pub floating: Vec, +} + +impl Layout { + pub fn apply(&mut self, action: craft_ui::docking::Action) -> Result<(), String> { + crate::dock_shared::apply(self, action) + } + pub fn validate(&self) -> Result<(), String> { + crate::dock_shared::encode(&self.root, &self.floating)?.validate().map_err(|error| error.to_string()) + } + pub fn contains(&self, panel: PanelKind) -> bool { + self.root.contains(panel) || self.floating.iter().any(|group| group.panels.contains(&panel)) } } @@ -420,6 +484,7 @@ pub struct Group { #[derive(Debug, Clone, PartialEq)] pub enum DockAction { Activate(PanelKind), + BeginDrag(PanelKind), Focus(PanelKind), Close(PanelKind), PanelMenu(PanelKind, egui::Pos2), @@ -430,22 +495,40 @@ pub enum DockAction { MoveSequence(u64, usize), } -/// Size of a split's first child in `avail` points: the requested size, keeping both children at -/// least 20 points when there is room (and never more than `avail`). Must not panic for any size: -/// a tiny window (or browser canvas) gives splits less than 40 points. -fn split_first(size: SplitSize, avail: f32) -> f32 { - let want = match size { - SplitSize::Ratio(r) => avail * r, - SplitSize::FixedA(px) => px, - SplitSize::FixedB(px) => avail - px, - }; - let lo = 20.0_f32.min(avail * 0.5); - let hi = (avail - 20.0).max(lo); - want.clamp(lo, hi) -} - /// Lay out the tree into group rects (with `gap` gutters) and handle gutter dragging. pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, path: &str, out: &mut Vec, reg: &mut crate::automation::Registry) { + let mut actions = Vec::new(); + layout_with_actions(ui, node, rect, t, path, out, reg, &mut actions); + for action in actions { + crate::dock_shared::apply_tree(node, action); + } +} + +/// Lay out chrome while returning mutations for the application's UI command route. +pub fn layout_with_actions( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { + layout_inner(ui, node, rect, t, path, &[], out, reg, actions); +} + +fn layout_inner( + ui: &mut egui::Ui, + node: &mut DockNode, + rect: Rect, + t: &Tokens, + path: &str, + tree_path: &[bool], + out: &mut Vec, + reg: &mut crate::automation::Registry, + actions: &mut Vec>, +) { match node { DockNode::Tabs { panels, active } => { let tab_h = if panels.len() == 1 && panels[0].compact() { 8.0 } else { t.tab_h }; @@ -453,45 +536,24 @@ pub fn layout(ui: &mut egui::Ui, node: &mut DockNode, rect: Rect, t: &Tokens, pa out.push(Group { path: path.to_string(), rect, content, panels: panels.clone(), active: *active }); } DockNode::Split { vertical, size, a, b } => { - let g = t.gap; - let total = if *vertical { rect.height() } else { rect.width() }; - let avail = (total - g).max(0.0); - let first = split_first(*size, avail); - let (ra, gutter, rb) = if *vertical { - ( - Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + first)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first), pos2(rect.max.x, rect.min.y + first + g)), - Rect::from_min_max(pos2(rect.min.x, rect.min.y + first + g), rect.max), - ) - } else { - ( - Rect::from_min_max(rect.min, pos2(rect.min.x + first, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first, rect.min.y), pos2(rect.min.x + first + g, rect.max.y)), - Rect::from_min_max(pos2(rect.min.x + first + g, rect.min.y), rect.max), - ) - }; - // gutter drag (a slightly larger hit area than the visible gap) - let hit = gutter.expand2(if *vertical { vec2(0.0, 3.0) } else { vec2(3.0, 0.0) }); + let axis = if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }; + let Some(split) = split_rect(rect, axis, size.shared(), t.gap, 20.0) else { return }; let id = egui::Id::new(("dock-gutter", path.to_string())); - let resp = ui.interact(hit, id, Sense::drag()); - reg.add(&format!("dock.gutter.{path}"), hit, "gutter"); - if resp.hovered() || resp.dragged() { - ui.ctx().set_cursor_icon(if *vertical { egui::CursorIcon::ResizeVertical } else { egui::CursorIcon::ResizeHorizontal }); + let handle_style = ResizeHandleStyle { hovered: t.focus.gamma_multiply(0.4), dragged: t.focus.gamma_multiply(0.9), focused: t.focus }; + let label = if *vertical { tl!("Height:") } else { tl!("Width:") }; + let response = ResizeHandle::new(id, axis, split.divider, label, handle_style).value(split.first_size(), 0.0..=split.available()).show(ui); + reg.add_response(&format!("dock.gutter.{path}"), &response.response, "gutter"); + if response.delta != 0.0 + && let Some(resized) = split.resized(size.shared(), response.delta, 40.0) + { + actions.push(craft_ui::docking::Action::ResizeSplit { path: tree_path.to_vec(), size: resized }); } - if resp.dragged() { - let d = if *vertical { resp.drag_delta().y } else { resp.drag_delta().x }; - let nf = (first + d).clamp(40.0, (avail - 40.0).max(40.0)); - *size = match *size { - SplitSize::Ratio(_) => SplitSize::Ratio(nf / avail.max(1.0)), - SplitSize::FixedA(_) => SplitSize::FixedA(nf), - SplitSize::FixedB(_) => SplitSize::FixedB(avail - nf), - }; - } - if resp.dragged() || resp.hovered() { - ui.painter().rect_filled(gutter, 0.0, t.focus.gamma_multiply(if resp.dragged() { 0.9 } else { 0.4 })); - } - layout(ui, a, ra, t, &format!("{path}a"), out, reg); - layout(ui, b, rb, t, &format!("{path}b"), out, reg); + let mut first_path = tree_path.to_vec(); + first_path.push(false); + let mut second_path = tree_path.to_vec(); + second_path.push(true); + layout_inner(ui, a, split.first, t, &format!("{path}a"), &first_path, out, reg, actions); + layout_inner(ui, b, split.second, t, &format!("{path}b"), &second_path, out, reg, actions); } } } @@ -512,11 +574,39 @@ pub fn draw_group_chrome( t: &Tokens, reg: &mut crate::automation::Registry, ) -> Vec { + draw_group_chrome_details(ui, g, focused, seqs, t, reg).actions +} + +#[derive(Default)] +pub struct Chrome { + pub actions: Vec, + pub tabs: Vec<(PanelKind, Rect)>, +} + +pub fn draw_group_chrome_details( + ui: &mut egui::Ui, + g: &Group, + focused: PanelKind, + seqs: &SeqTabs, + t: &Tokens, + reg: &mut crate::automation::Registry, +) -> Chrome { let mut actions = Vec::new(); + let mut tab_rects: Vec<(PanelKind, Rect)> = Vec::new(); let painter = ui.painter().clone(); painter.rect_filled(g.rect, t.radius, t.panel_bg); let active_panel = g.panels.get(g.active).copied(); let compact = g.panels.len() == 1 && g.panels[0].compact(); + // A narrow header grip moves the entire panel, including Timeline panels whose tabs + // separately reorder open sequences. + if let Some(panel) = active_panel { + let grip = Rect::from_min_size(g.rect.min, vec2(if compact { g.rect.width() } else { 10.0 }, if compact { 8.0 } else { t.tab_h })); + let response = ui.interact(grip, egui::Id::new(("panel-drag", g.path.clone())), Sense::drag()); + reg.add_response(&format!("panel.drag.{}", panel.id()), &response, "Move panel"); + if response.drag_started() { + actions.push(DockAction::BeginDrag(panel)); + } + } if compact { // grip dots let c = pos2(g.rect.center().x, g.rect.min.y + 4.0); @@ -578,10 +668,12 @@ pub fn draw_group_chrome( if let Some(r) = more { let resp = ui.interact(r, egui::Id::new(("tab-overflow", g.path.clone())), Sense::click()); let name = if g.panels.contains(&PanelKind::Timeline) { "timeline.tabs.more".to_string() } else { format!("panel.tabs.more.{}", g.path) }; - reg.add(&name, r, "more tabs"); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), "more tabs")); + reg.add_response(&name, &resp, "more tabs"); let c = if resp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, r.shrink(4.0).translate(vec2(-2.0, 0.0)), Icon::ChevronRight, c); icons::paint(&painter, r.shrink(4.0).translate(vec2(2.0, 0.0)), Icon::ChevronRight, c); + paint_widget_focus(&painter, &resp, t); if resp.clicked() { let open = ui.ctx().data(|d| d.get_temp::(list_id)).unwrap_or(false); ui.ctx().data_mut(|d| d.insert_temp(list_id, !open)); @@ -602,8 +694,22 @@ pub fn draw_group_chrome( // a sequence tab keeps its identity when the tabs are reordered under the pointer let resp = match tab.seq { Some(id) => ui.interact(tab_rect, egui::Id::new(("tab-seq", g.path.clone(), id)), Sense::click_and_drag()), - None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click()), + None => ui.interact(tab_rect, egui::Id::new(("tab", g.path.clone(), i)), Sense::click_and_drag()), }; + if tab.seq.is_none() && resp.drag_started() { + actions.push(DockAction::BeginDrag(*p)); + } + if let Some((_, rect)) = tab_rects.iter_mut().find(|(panel, _)| panel == p) { + *rect = rect.union(tab_rect); + } else { + tab_rects.push((*p, tab_rect)); + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), is_active, &tab.title)); + ui.ctx().accesskit_node_builder(resp.id, |node| { + node.set_role(egui::accesskit::Role::Tab); + node.set_selected(is_active); + }); + paint_widget_focus(&painter, &resp, t); if let Some(id) = tab.seq { seq_rects.push((id, tab_rect)); if resp.dragged() { @@ -611,10 +717,10 @@ pub fn draw_group_chrome( } } if let Some(id) = tab.seq { - reg.add(&format!("timeline.tab.{id}"), tab_rect, &tab.title); + reg.add_response(&format!("timeline.tab.{id}"), &resp, &tab.title); } if tab.seq.is_none() || is_active { - reg.add(&format!("panel.tab.{}", p.id()), tab_rect, p.title()); + reg.add_response(&format!("panel.tab.{}", p.id()), &resp, p.title()); } let label_x = tab_rect.min.x + 8.0 + close_w; let label_w = galley.size().x; @@ -625,10 +731,12 @@ pub fn draw_group_chrome( // × closes the sequence's tab (the sequence stays in the project) let cr = Rect::from_center_size(pos2(tab_rect.min.x + 10.0, text_y), vec2(8.0, 8.0)); let cresp = ui.interact(cr.expand(4.0), egui::Id::new(("tab-close", g.path.clone(), id)), Sense::click()); - reg.add(&format!("timeline.tab.{id}.close"), cr.expand(4.0), "close sequence"); + cresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, cresp.enabled(), "close sequence")); + reg.add_response(&format!("timeline.tab.{id}.close"), &cresp, "close sequence"); let cc = if cresp.hovered() { t.tab_text_active } else { t.tab_text }; painter.line_segment([cr.left_top(), cr.right_bottom()], Stroke::new(1.2, cc)); painter.line_segment([cr.right_top(), cr.left_bottom()], Stroke::new(1.2, cc)); + paint_widget_focus(&painter, &cresp, t); if cresp.clicked() { actions.push(DockAction::CloseSequence(id)); closed = true; @@ -637,12 +745,14 @@ pub fn draw_group_chrome( if is_active { let mr = Rect::from_center_size(pos2(label_x + label_w + 12.0, text_y), vec2(12.0, 10.0)); let mresp = ui.interact(mr.expand(3.0), egui::Id::new(("tab-menu", g.path.clone())), Sense::click()); - reg.add(&format!("panel.menu.{}", p.id()), mr, "panel menu"); + mresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, mresp.enabled(), "panel menu")); + reg.add_response(&format!("panel.menu.{}", p.id()), &mresp, "panel menu"); let mc = if mresp.hovered() { t.tab_text_active } else { t.tab_text }; icons::paint(&painter, Rect::from_center_size(mr.center(), vec2(16.0, 16.0)), Icon::Hamburger, mc); // 1 pt underline spanning label + ≡, 23 pt below the frame top let uy = strip.min.y + 23.0; painter.line_segment([pos2(label_x, uy), pos2(mr.max.x, uy)], Stroke::new(1.0, t.tab_text_active)); + paint_widget_focus(&painter, &mresp, t); if mresp.clicked() { actions.push(DockAction::PanelMenu(*p, mr.left_bottom())); } @@ -697,8 +807,8 @@ pub fn draw_group_chrome( let label = if tab.active { format!("✓ {}", tab.title) } else { tab.title.clone() }; let resp = ui.button(label); match tab.seq { - Some(id) => reg.add(&format!("timeline.tabs.list.{id}"), resp.rect, &tab.title), - None => reg.add(&format!("panel.tabs.list.{}", tab.panel.id()), resp.rect, &tab.title), + Some(id) => reg.add_response(&format!("timeline.tabs.list.{id}"), &resp, &tab.title), + None => reg.add_response(&format!("panel.tabs.list.{}", tab.panel.id()), &resp, &tab.title), } if resp.clicked() { picked = Some(tab); @@ -730,7 +840,13 @@ pub fn draw_group_chrome( { actions.push(DockAction::Focus(p)); } - actions + Chrome { actions, tabs: tab_rects } +} + +fn paint_widget_focus(painter: &egui::Painter, response: &egui::Response, tokens: &Tokens) { + if response.has_focus() { + painter.rect_stroke(response.rect.shrink(2.0), 2.0, Stroke::new(1.5, tokens.focus), StrokeKind::Inside); + } } /// Placeholder body for panels that are not implemented yet. @@ -744,6 +860,9 @@ mod tests { #[test] fn split_first_never_panics_on_tiny_areas() { + let split_first = |size: SplitSize, avail: f32| { + split_rect(Rect::from_min_size(egui::Pos2::ZERO, vec2(avail, 100.0)), SplitAxis::Horizontal, size.shared(), 0.0, 20.0).unwrap().first_size() + }; for avail in [0.0, 1.0, 15.0, 20.0, 35.0, 39.9, 40.0, 100.0, 2000.0] { for size in [ SplitSize::Ratio(0.0), diff --git a/crates/ui-egui/src/dock_shared.rs b/crates/ui-egui/src/dock_shared.rs new file mode 100644 index 00000000..d5bb28c2 --- /dev/null +++ b/crates/ui-egui/src/dock_shared.rs @@ -0,0 +1,113 @@ +//! Convert the persisted workspace schema to shared docking transactions. + +use craft_ui::docking::{Action, Floating as SharedFloating, Layout as SharedLayout, Node}; +use craft_ui::layout::{SplitAxis, SplitSize as SharedSplitSize}; + +use crate::dock::{DockNode, Floating, Layout, PanelKind, SplitSize}; + +const MAX_DEPTH: usize = 32; +const MAX_NODES: usize = 4096; +const MAX_PANELS: usize = 128; + +fn encode_node(node: &DockNode, depth: usize, count: &mut usize) -> Result, String> { + *count = count.saturating_add(1); + if depth > MAX_DEPTH || *count > MAX_NODES { + return Err("workspace dock tree exceeds supported limits".into()); + } + Ok(match node { + DockNode::Tabs { panels, active } => { + if panels.len() > MAX_PANELS { + return Err("too many workspace tabs".into()); + } + Node::Tabs { panels: panels.clone(), active: *active } + } + DockNode::Split { vertical, size, a, b } => Node::Split { + axis: if *vertical { SplitAxis::Vertical } else { SplitAxis::Horizontal }, + size: match *size { + SplitSize::Ratio(value) => SharedSplitSize::Ratio(value), + SplitSize::FixedA(value) => SharedSplitSize::FixedFirst(value), + SplitSize::FixedB(value) => SharedSplitSize::FixedSecond(value), + }, + first: Box::new(encode_node(a, depth.saturating_add(1), count)?), + second: Box::new(encode_node(b, depth.saturating_add(1), count)?), + }, + }) +} + +fn decode_node(node: Node) -> Result { + Ok(match node { + Node::Tabs { panels, active } => DockNode::Tabs { panels, active }, + Node::Stack { .. } => return Err("FilmCraft does not use stacked panels".into()), + Node::Split { axis, size, first, second } => DockNode::Split { + vertical: axis == SplitAxis::Vertical, + size: match size { + SharedSplitSize::Ratio(value) => SplitSize::Ratio(value), + SharedSplitSize::FixedFirst(value) => SplitSize::FixedA(value), + SharedSplitSize::FixedSecond(value) => SplitSize::FixedB(value), + }, + a: Box::new(decode_node(*first)?), + b: Box::new(decode_node(*second)?), + }, + }) +} + +pub(crate) fn encode(root: &DockNode, floating: &[Floating]) -> Result, String> { + if floating.len() > MAX_PANELS || floating.iter().any(|group| group.panels.len() > MAX_PANELS) { + return Err("too many floating workspace panels".into()); + } + let root = if matches!(root, DockNode::Tabs { panels, active: 0 } if panels.is_empty()) { None } else { Some(encode_node(root, 0, &mut 0)?) }; + Ok(SharedLayout { + root, + floating: floating.iter().map(|group| SharedFloating { panels: group.panels.clone(), active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn decode(layout: SharedLayout) -> Result { + Ok(Layout { + root: match layout.root { + Some(root) => decode_node(root)?, + None => DockNode::Tabs { panels: vec![], active: 0 }, + }, + floating: layout.floating.into_iter().map(|group| Floating { panels: group.panels, active: group.active, rect: group.rect }).collect(), + }) +} + +pub(crate) fn apply(layout: &mut Layout, action: Action) -> Result<(), String> { + let mut shared = encode(&layout.root, &layout.floating)?; + shared.validate().map_err(|error| error.to_string())?; + check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let proposed = decode(shared)?; + encode(&proposed.root, &proposed.floating)?; + *layout = proposed; + Ok(()) +} + +pub(crate) fn apply_tree(root: &mut DockNode, action: Action) -> bool { + let Ok(mut shared) = encode(root, &[]) else { return false }; + if shared.apply(action).is_err() { + return false; + } + let Ok(layout) = decode(shared) else { return false }; + if encode(&layout.root, &[]).is_err() { + return false; + } + *root = layout.root; + true +} + +/// Keep one docked destination available for dragging floating panels back into the workspace. +pub(crate) fn check_last_docked(layout: &SharedLayout, action: &Action) -> Result<(), String> { + let panel = match action { + Action::Float { panel, .. } => Some(panel), + Action::Move { panel, anchor, .. } if layout.floating.iter().any(|group| group.panels.contains(anchor)) => Some(panel), + _ => None, + }; + if let Some(panel) = panel { + let docked = SharedLayout { root: layout.root.clone(), floating: vec![] }; + if docked.contains(panel) && docked.panels().len() <= 1 { + return Err("the last docked panel must remain in the workspace".into()); + } + } + Ok(()) +} diff --git a/crates/ui-egui/src/dock_ui.rs b/crates/ui-egui/src/dock_ui.rs new file mode 100644 index 00000000..d90888ab --- /dev/null +++ b/crates/ui-egui/src/dock_ui.rs @@ -0,0 +1,394 @@ +//! FilmCraft's panel chrome and document tabs over the shared docking transactions. + +use craft_ui::docking::{Action, DropTarget, Placement, Zone, resolve_drop}; +use egui::{Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::dock::{self, DockAction, DockNode, Group, PanelKind}; +use crate::{FilmcraftApp, panels}; + +/// Single source for rendering and hit testing: saved offscreen positions remain +/// reachable, and every panel has enough room for its chrome. +fn floating_rect(group: &dock::Floating, screen: Rect) -> Rect { + let rect = Rect::from_min_size(pos2(group.rect[0], group.rect[1]), vec2(group.rect[2].max(160.0), group.rect[3].max(100.0))); + rect.translate(vec2( + (screen.min.x - rect.min.x).max(0.0) + (screen.max.x - 40.0 - rect.min.x).min(0.0), + (screen.min.y - rect.min.y).max(0.0) + (screen.max.y - 30.0 - rect.min.y).min(0.0), + )) +} + +fn drag_id() -> egui::Id { + egui::Id::new("dock-panel-drag") +} + +pub(crate) fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Value) -> Option> { + if id == "window.dockAction" { + return Some((|| { + let value = params.get("action").cloned().ok_or("missing docking action")?; + let action = serde_json::from_value(value).map_err(|error| format!("invalid docking action: {error}"))?; + app.apply_dock(action)?; + Ok(Value::Null) + })()); + } + if !matches!(id, "window.dockPanel" | "window.floatPanel" | "window.closePanel") { + return None; + } + Some((|| { + let panel = match params.get("panel") { + None => app.ui.focused, + Some(value) => value.as_str().and_then(PanelKind::from_name).ok_or("unknown panel")?, + }; + let action = match id { + "window.closePanel" => Action::Close { panel }, + "window.floatPanel" => { + let rect = match params.get("rect") { + None => default_float_rect(ctx.content_rect()), + Some(value) => serde_json::from_value::<[f32; 4]>(value.clone()).map_err(|_| "rect must contain four finite numbers")?, + }; + Action::Float { panel, rect } + } + _ => { + let anchor = params.get("anchor").and_then(Value::as_str).and_then(PanelKind::from_name).ok_or("a known anchor panel is required")?; + let zone_name = match params.get("zone") { + None => "center", + Some(value) => value.as_str().ok_or("zone must be a string")?, + }; + let zone = match zone_name.to_ascii_lowercase().as_str() { + "left" => Zone::Left, + "right" => Zone::Right, + "top" => Zone::Top, + "bottom" => Zone::Bottom, + "center" | "tab" => Zone::Center, + _ => return Err("unknown docking zone".into()), + }; + let placement = match params.get("before") { + Some(value) => Placement::Tab { before: Some(value.as_str().and_then(PanelKind::from_name).ok_or("unknown before panel")?) }, + None => Placement::Split(zone), + }; + Action::Move { panel, anchor, placement } + } + }; + app.apply_dock(action)?; + Ok(json!({"panel": panel.id()})) + })()) +} + +pub(crate) fn default_float_rect(screen: Rect) -> [f32; 4] { + let center = screen.center(); + [center.x - 210.0, center.y - 160.0, 420.0, 320.0] +} + +impl FilmcraftApp { + fn dock_command(&mut self, ctx: &egui::Context, action: Action) -> Result<(), String> { + let value = serde_json::to_value(action).map_err(|error| error.to_string())?; + crate::menus::invoke(self, ctx, "window.dockAction", json!({"action": value})).map(|_| ()) + } + + pub fn apply_dock(&mut self, action: Action) -> Result<(), String> { + let relocating = matches!(&action, Action::Move { .. } | Action::Float { .. }); + let reveal = match &action { + Action::Activate { panel } => Some(*panel), + _ => None, + }; + let mut shared = crate::dock_shared::encode(&self.ui.dock, &self.ui.floating)?; + shared.validate().map_err(|error| error.to_string())?; + crate::dock_shared::check_last_docked(&shared, &action)?; + shared.apply(action).map_err(|error| error.to_string())?; + let layout = crate::dock_shared::decode(shared)?; + crate::dock_shared::encode(&layout.root, &layout.floating)?; + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + if let Some(panel) = reveal { + self.pending_panel_raise = Some(panel); + } + if relocating + || self.ui.keys.maximized.is_some_and(|panel| !self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) + { + self.ui.keys.maximized = None; + } + Ok(()) + } + + fn panel_body(&mut self, ui: &mut egui::Ui, panel: PanelKind, content: Rect) { + self.auto.add(&format!("panel.{}", panel.id()), content, panel.title()); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(content).id_salt(("panel", panel.id()))); + child.set_clip_rect(content.intersect(ui.clip_rect())); + panels::show(self, &mut child, panel, content); + } + + /// OS drag positions come from the host cursor hook, so egui pointer capture alone + /// cannot decide whether a Timeline is covered. Use the same geometry and layer + /// ordering as our renderer before any panel can dispatch an edit. + pub(crate) fn timeline_drop_target(&self, ctx: &egui::Context, pos: egui::Pos2) -> bool { + if !ctx.content_rect().contains(pos) { + return false; + } + if let Some(panel) = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))) + { + return panel == PanelKind::Timeline; + } + for index in self.floating_order(ctx).into_iter().rev() { + if let Some(group) = self.ui.floating.get(index) + && floating_rect(group, ctx.content_rect()).contains(pos) + { + return group.panels.get(group.active) == Some(&PanelKind::Timeline); + } + } + true + } + + fn floating_order(&self, ctx: &egui::Context) -> Vec { + let layers = ctx.memory(|memory| memory.layer_ids().collect::>()); + let mut order: Vec<_> = (0..self.ui.floating.len()).collect(); + order.sort_by_key(|index| { + self.ui + .floating + .get(*index) + .and_then(|group| group.panels.first()) + .and_then(|panel| { + let id = egui::Id::new(("floating-panel", panel.id())); + layers.iter().position(|layer| layer.id == id) + }) + .unwrap_or(usize::MAX) + }); + order + } + + pub(crate) fn dock_area(&mut self, ui: &mut egui::Ui, body: Rect) { + let t = self.tokens; + let ctx = ui.ctx().clone(); + if let Some(panel) = self.pending_panel_raise.take() + && let Some(owner) = self.ui.floating.iter().find(|group| group.panels.contains(&panel)).and_then(|group| group.panels.first()) + { + ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", owner.id())))); + } + let maximized = + self.ui.keys.maximized.filter(|panel| self.ui.dock.contains(*panel) || self.ui.floating.iter().any(|group| group.panels.contains(panel))); + let mut dock = match maximized { + Some(panel) => DockNode::Tabs { panels: vec![panel], active: 0 }, + None => std::mem::replace(&mut self.ui.dock, DockNode::Tabs { panels: vec![], active: 0 }), + }; + let mut groups = vec![]; + let mut layout_actions = Vec::new(); + dock::layout_with_actions(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto, &mut layout_actions); + let seqs = dock::SeqTabs { + open: self + .session + .state + .open_sequences + .iter() + .filter_map(|id| { + self.session + .project + .item(*id) + .filter(|item| matches!(item.kind, filmcraft_project::ItemKind::Sequence(_))) + .map(|item| (id.0, item.name.clone())) + }) + .collect(), + active: self.session.state.active_sequence.map(|id| id.0), + }; + let mut actions = vec![]; + let mut targets = vec![]; + for group in &groups { + let chrome = dock::draw_group_chrome_details(ui, group, self.ui.focused, &seqs, &t, &mut self.auto); + if let Some(target) = target(group, chrome.tabs, true) { + targets.push(target); + } + actions.extend(chrome.actions); + } + if maximized.is_none() { + self.ui.dock = dock; + } + for group in &groups { + if let Some(panel) = group.panels.get(group.active).copied() { + self.panel_body(ui, panel, group.content); + } + } + let floating_order = self.floating_order(&ctx); + for index in floating_order.into_iter().filter(|_| maximized.is_none()) { + if let Some(target) = self.floating_group(&ctx, index, &seqs, &mut actions) { + targets.push(target); + } + } + for action in layout_actions { + let _ = self.dock_command(&ctx, action); + } + for action in actions { + match action { + DockAction::Activate(panel) => { + let _ = self.dock_command(&ctx, Action::Activate { panel }); + } + DockAction::Close(panel) => { + let _ = self.dock_command(&ctx, Action::Close { panel }); + } + DockAction::Focus(panel) => self.ui.focused = panel, + DockAction::BeginDrag(panel) => { + ctx.data_mut(|data| data.insert_temp(drag_id(), panel)); + } + DockAction::OpenSequence(id) => { + let _ = self.dock_command(&ctx, Action::Activate { panel: PanelKind::Timeline }); + if self.session.state.active_sequence.map(|item| item.0) != Some(id) + && let Err(error) = self.session.execute("sequence.open", json!({"item": id})) + { + self.ui.status = error.to_string(); + } + } + DockAction::CloseSequence(id) => { + if let Err(error) = self.session.execute("sequence.close", json!({"item": id})) { + self.ui.status = error.to_string(); + } + } + DockAction::MoveSequence(id, index) => { + if let Err(error) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { + self.ui.status = error.to_string(); + } + } + DockAction::PanelMenu(panel, pos) => { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|data| { + data.insert_temp(egui::Id::new("panel-menu"), (panel, pos)); + data.insert_temp(egui::Id::new("panel-menu-opened"), frame); + }); + } + } + } + self.panel_drag(&ctx, &targets); + panels::panel_menu_popup(self, ui); + } + + fn floating_group(&mut self, ctx: &egui::Context, index: usize, seqs: &dock::SeqTabs, actions: &mut Vec) -> Option> { + let floating = self.ui.floating.get(index)?.clone(); + let panel = *floating.panels.first()?; + let rect = floating_rect(&floating, ctx.content_rect()); + let t = self.tokens; + let id = egui::Id::new(("floating-panel", panel.id())); + let mut output = None; + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(rect.min).constrain(false).interactable(true).show(ctx, |ui| { + let (rect, _) = ui.allocate_exact_size(rect.size(), Sense::hover()); + let compact = floating.panels.len() == 1 && panel.compact(); + let header = if compact { 8.0 } else { t.tab_h }; + let strip = Rect::from_min_size(rect.min, vec2(rect.width(), header)); + let response = ui.interact(strip, id.with("move"), Sense::drag()); + self.auto.add_response(&format!("panel.float.{index}.move"), &response, "Move floating panel"); + let mut next = if response.dragged() { rect.translate(response.drag_delta()) } else { rect }; + let group = Group { + path: format!("f{index}"), + rect, + content: Rect::from_min_max(pos2(rect.min.x, rect.min.y + header), rect.max), + panels: floating.panels.clone(), + active: floating.active, + }; + let chrome = dock::draw_group_chrome_details(ui, &group, self.ui.focused, seqs, &t, &mut self.auto); + actions.extend(chrome.actions); + if let Some(active) = group.panels.get(group.active).copied() { + self.panel_body(ui, active, group.content); + } + ui.painter().rect_stroke(rect, t.radius, Stroke::new(1.0, t.focus), StrokeKind::Outside); + let grip = Rect::from_min_max(rect.max - vec2(14.0, 14.0), rect.max); + let response = ui.interact(grip, id.with("resize"), Sense::click_and_drag()); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, response.enabled(), tl!("Resize floating panel"))); + self.auto.add_response(&format!("panel.float.{index}.resize"), &response, "Resize floating panel"); + for step in 0..3 { + let offset = 4.0 + step as f32 * 4.0; + ui.painter() + .line_segment([pos2(rect.max.x - offset, rect.max.y - 2.0), pos2(rect.max.x - 2.0, rect.max.y - offset)], Stroke::new(1.0, t.text_faint)); + } + if response.hovered() || response.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } + let delta = floating_resize_delta(ui, &response); + if response.has_focus() { + ui.painter().rect_stroke(grip, 2.0, Stroke::new(1.5, t.focus), StrokeKind::Inside); + } + if delta != egui::Vec2::ZERO { + next = Rect::from_min_size(next.min, (next.size() + delta).max(vec2(160.0, 100.0))); + } + if next != rect { + let _ = self.dock_command(ctx, Action::MoveFloating { panel, rect: [next.min.x, next.min.y, next.width(), next.height()] }); + } + output = target(&group, chrome.tabs, false); + }); + output + } + + fn panel_drag(&mut self, ctx: &egui::Context, targets: &[DropTarget]) { + let Some(panel) = ctx.data(|data| data.get_temp::(drag_id())) else { return }; + let canceled = + ctx.input(|input| input.key_pressed(egui::Key::Escape) || !input.focused || (!input.pointer.primary_down() && !input.pointer.primary_released())); + if canceled || (!self.ui.dock.contains(panel) && !self.ui.floating.iter().any(|group| group.panels.contains(&panel))) { + ctx.data_mut(|data| data.remove::(drag_id())); + return; + } + let Some(pos) = ctx.pointer_latest_pos() else { return }; + let force_float = ctx.input(|input| input.modifiers.command); + let proposal = (!force_float).then(|| resolve_drop(&panel, pos, targets)).flatten(); + let painter = ctx.layer_painter(egui::LayerId::new(egui::Order::Foreground, egui::Id::new("dock-drop"))); + let t = self.tokens; + if let Some(proposal) = &proposal { + painter.rect_filled(proposal.preview.shrink(2.0), t.radius, t.focus.gamma_multiply(0.2)); + painter.rect_stroke(proposal.preview.shrink(2.0), t.radius, Stroke::new(2.0, t.focus), StrokeKind::Inside); + if let Some(mark) = proposal.insertion { + painter.rect_filled(mark, 0.0, t.focus); + } + } + let title = painter.layout_no_wrap(panel.title().to_owned(), crate::theme::Tokens::ui(12.0), Color32::WHITE); + let ghost = Rect::from_min_size(pos + vec2(12.0, 10.0), title.size() + vec2(16.0, 8.0)); + painter.rect_filled(ghost, 4.0, t.focus.gamma_multiply(0.85)); + painter.galley(ghost.min + vec2(8.0, 4.0), title, Color32::WHITE); + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + ctx.request_repaint(); + if ctx.input(|input| input.pointer.primary_released()) { + ctx.data_mut(|data| data.remove::(drag_id())); + let action = match proposal { + Some(proposal) => Some(Action::Move { panel, anchor: proposal.anchor, placement: proposal.placement }), + None if force_float || !targets.iter().any(|target| target.rect.contains(pos)) => { + Some(Action::Float { panel, rect: [pos.x - 40.0, pos.y - 12.0, 420.0, 320.0] }) + } + None => None, + }; + if let Some(action) = action + && self.dock_command(ctx, action).is_ok() + { + self.ui.focused = panel; + } + } + } +} + +fn target(group: &Group, mut tabs: Vec<(PanelKind, Rect)>, allow_splits: bool) -> Option> { + let strip = Rect::from_min_max(group.rect.min, pos2(group.rect.max.x, group.content.min.y)); + // A compact grip still identifies the source group to the shared resolver. + for panel in &group.panels { + if !tabs.iter().any(|(candidate, _)| candidate == panel) { + tabs.push((*panel, Rect::NOTHING)); + } + } + Some(DropTarget { anchor: *group.panels.get(group.active)?, rect: group.rect, tab_strip: Some(strip), tabs, allow_splits, accepts_tabs: true }) +} + +/// A corner is a two-axis resize control; focused arrows belong to it and Tab moves on. +fn floating_resize_delta(ui: &mut egui::Ui, response: &egui::Response) -> egui::Vec2 { + if response.drag_started() { + response.request_focus(); + } + let mut delta = if response.dragged() { response.drag_delta() } else { egui::Vec2::ZERO }; + if response.has_focus() { + ui.memory_mut(|memory| { + memory.set_focus_lock_filter(response.id, egui::EventFilter { horizontal_arrows: true, vertical_arrows: true, ..Default::default() }) + }); + ui.input_mut(|input| { + for (key, amount) in [ + (egui::Key::ArrowLeft, vec2(-8.0, 0.0)), + (egui::Key::ArrowRight, vec2(8.0, 0.0)), + (egui::Key::ArrowUp, vec2(0.0, -8.0)), + (egui::Key::ArrowDown, vec2(0.0, 8.0)), + ] { + if input.consume_key(egui::Modifiers::NONE, key) { + delta += amount; + } + } + }); + } + if response.enabled() { delta } else { egui::Vec2::ZERO } +} diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 08f899ec..97ef8427 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -670,6 +670,7 @@ Extract (') Extraer (') Export Frame: use Export mode (M6) Exportar fotograma: usa el modo Exportar (M6) Button Editor Editor de botones + More transport controls Más controles de reproducción Multi-Camera Record on Grabación multicámara activada Multi-Camera Record off Grabación multicámara desactivada {first} - Merged {first} - Combinado @@ -3512,3 +3513,8 @@ Composite on the GPU when available Componer en la GPU cuando esté disponible Text to Speech Texto a voz Edit Narration Editar narración + + Dock Panel Acoplar panel + Undock Panel Desacoplar panel + Docking Action Acción de acoplamiento + Resize floating panel Cambiar tamaño del panel flotante diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 731067f9..403baf62 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3494,3 +3494,6 @@ Edit Narration 编辑旁白 Clear Transition 清除过渡 Set Transition Duration 设置过渡持续时间 + Resize floating panel 调整浮动面板大小 + More transport controls 更多播放控制 + Undock Panel 取消面板停靠 diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 857f42a5..fea18adb 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod control; pub mod crash; pub mod credits; pub mod dock; +mod dock_shared; +mod dock_ui; pub mod frames; pub mod header; pub mod i18n; @@ -250,6 +252,14 @@ pub struct FilmcraftApp { /// Named textures (monitors, thumbnails) with the key they show. textures: HashMap, control_rx: Option>, + /// One explicit panel reveal; consumed by the custom renderer, never repeated per frame. + pub(crate) pending_panel_raise: Option, + /// Explicit automation mode: native widget input and native menu commands are excluded. + pub(crate) control_input_only: bool, + /// Backend focus before synthetic input changes egui's effective focus for this frame. + pub(crate) native_window_focused: Option, + /// Modifiers owned by an in-flight control gesture, carried between its input frames. + synthetic_modifiers: egui::Modifiers, /// Requests waiting for the UI to show an element: (request, give-up time). deferred: Vec<(ControlRequest, f64)>, last_ui_time: f64, @@ -458,6 +468,10 @@ impl FilmcraftApp { auto: Default::default(), textures: HashMap::new(), control_rx: None, + pending_panel_raise: None, + control_input_only: false, + native_window_focused: None, + synthetic_modifiers: egui::Modifiers::NONE, deferred: Vec::new(), last_ui_time: 0.0, synthetic: Vec::new(), @@ -503,6 +517,13 @@ impl FilmcraftApp { self } + /// Isolate an automation-owned app from native keyboard, pointer, drop and menu input. + /// Window close/quit and screenshot delivery remain available. + pub fn with_control_input_only(mut self, rx: Receiver) -> Self { + self.control_input_only = true; + self.with_control(rx) + } + pub fn services(&self) -> Arc { self.session.services.clone() } @@ -694,7 +715,11 @@ impl FilmcraftApp { pub fn set_workspace(&mut self, name: &str) { self.ui.workspace = name.to_string(); - self.ui.dock = dock::saved_layout(&self.workspaces, name); + let layout = dock::saved_docking(&self.workspaces, name); + self.ui.dock = layout.root; + self.ui.floating = layout.floating; + self.pending_panel_raise = None; + self.ui.keys.maximized = None; if self.workspaces.current != name { let mut next = self.workspaces.clone(); next.current = name.to_string(); @@ -708,10 +733,10 @@ impl FilmcraftApp { } pub fn show_panel(&mut self, p: PanelKind) { - if p == PanelKind::Timeline { + if p == PanelKind::Timeline && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { self.ui.dock.restore_timeline(); } - if !self.ui.dock.contains(p) { + if !self.ui.dock.contains(p) && !self.ui.floating.iter().any(|group| group.panels.contains(&p)) { let near = match p { // a side panel beside Properties (or Essential Sound), never over the Program monitor PanelKind::TextToSpeech => [PanelKind::Properties, PanelKind::EssentialSound, PanelKind::EssentialGraphics] @@ -732,7 +757,7 @@ impl FilmcraftApp { }; self.ui.dock.open_near(p, near); } - self.ui.dock.activate(p); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: p }); self.ui.focused = p; } @@ -1451,11 +1476,13 @@ impl FilmcraftApp { filmcraft_engine::Event::OpenSequence(_) => { // show the sequence with its own view (or fitted, the first time) self.timeline_view_of = None; - self.ui.dock.restore_timeline(); - self.ui.dock.activate(PanelKind::Timeline); + if !self.ui.floating.iter().any(|group| group.panels.contains(&PanelKind::Timeline)) { + self.ui.dock.restore_timeline(); + } + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Timeline }); } filmcraft_engine::Event::OpenSource(_) => { - self.ui.dock.activate(PanelKind::Source); + let _ = self.apply_dock(craft_ui::docking::Action::Activate { panel: PanelKind::Source }); } filmcraft_engine::Event::RevealInProject(item) => self.reveal_in_project(item), filmcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))), @@ -1466,6 +1493,9 @@ impl FilmcraftApp { self.handle_drops(&ctx); if let Some(rx) = self.command_inbox.take() { while let Ok(id) = rx.try_recv() { + if self.control_input_only { + continue; + } if let Err(e) = menus::invoke(self, &ctx, &id, json!({})) { self.ui.status = e; } @@ -1609,79 +1639,6 @@ impl FilmcraftApp { } .to_string() } - - fn dock_area(&mut self, ui: &mut egui::Ui, body: egui::Rect) { - let t = self.tokens; - // Maximize or Restore Frame (` / Shift+`): the maximized panel fills the dock area. - let maximized = self.ui.keys.maximized.filter(|p| self.ui.dock.contains(*p)); - let mut dock = match maximized { - Some(p) => dock::DockNode::Tabs { panels: vec![p], active: 0 }, - None => std::mem::replace(&mut self.ui.dock, dock::DockNode::Tabs { panels: vec![], active: 0 }), - }; - let mut groups = Vec::new(); - dock::layout(ui, &mut dock, body, &t, "", &mut groups, &mut self.auto); - let mut actions = Vec::new(); - let seqs = dock::SeqTabs { - open: self - .session - .state - .open_sequences - .iter() - .filter_map(|id| { - self.session.project.item(*id).filter(|i| matches!(i.kind, filmcraft_project::ItemKind::Sequence(_))).map(|i| (id.0, i.name.clone())) - }) - .collect(), - active: self.session.state.active_sequence.map(|i| i.0), - }; - for g in &groups { - actions.extend(dock::draw_group_chrome(ui, g, self.ui.focused, &seqs, &t, &mut self.auto)); - } - if maximized.is_none() { - self.ui.dock = dock; - } - for g in &groups { - let Some(p) = g.panels.get(g.active).copied() else { continue }; - self.auto.add(&format!("panel.{}", p.id()), g.content, p.title()); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(g.content).id_salt(("panel", p.id()))); - child.set_clip_rect(g.content); - panels::show(self, &mut child, p, g.content); - } - for a in actions { - match a { - dock::DockAction::Activate(p) => { - self.ui.dock.activate(p); - } - dock::DockAction::Focus(p) => self.ui.focused = p, - dock::DockAction::Close(p) => self.ui.dock.close(p), - dock::DockAction::OpenSequence(id) => { - if self.session.state.active_sequence.map(|i| i.0) != Some(id) - && let Err(e) = self.session.execute("sequence.open", json!({"item": id})) - { - self.ui.status = e.to_string(); - } - } - dock::DockAction::CloseSequence(id) => { - if let Err(e) = self.session.execute("sequence.close", json!({"item": id})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::MoveSequence(id, index) => { - if let Err(e) = self.session.execute("sequence.moveTab", json!({"item": id, "index": index})) { - self.ui.status = e.to_string(); - } - } - dock::DockAction::PanelMenu(p, pos) => { - // (with the frame it opened in: the click that opens it is not a click elsewhere) - let frame = ui.ctx().cumulative_frame_nr(); - ui.ctx().data_mut(|d| { - d.insert_temp(egui::Id::new("panel-menu"), (p, pos)); - d.insert_temp(egui::Id::new("panel-menu-opened"), frame); - }); - } - } - } - panels::panel_menu_popup(self, ui); - } } impl FilmcraftApp { @@ -1723,6 +1680,21 @@ impl FilmcraftApp { impl eframe::App for FilmcraftApp { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw_input: &mut egui::RawInput) { + self.native_window_focused = Some(raw_input.focused); + if self.control_input_only { + // Preserve screenshot responses and all viewport lifecycle/geometry metadata. + raw_input.events.retain(|event| matches!(event, egui::Event::Screenshot { .. })); + raw_input.hovered_files.clear(); + raw_input.dropped_files.clear(); + } + if self.control_input_only || !self.synthetic.is_empty() { + // Native modifiers cannot replace those requested by a control gesture. A queued + // reset runs in the frame after release, so clicked() still sees Shift/Alt. + raw_input.events.push(egui::Event::ModifiersChanged(self.synthetic_modifiers)); + // Widget keyboard handling requires effective egui focus. Control input must work + // in a background window without activating it or taking the user's OS keyboard. + raw_input.focused = true; + } if !self.synthetic.is_empty() { // Pointer events go one per frame so egui sees press → moves → release as a real drag // (all in one frame reads as a click); key/text runs go together up to a key release. @@ -1735,7 +1707,12 @@ impl eframe::App for FilmcraftApp { .position(|e| pointer(e) || matches!(e, egui::Event::Key { pressed: false, .. })) .map_or(self.synthetic.len(), |i| if pointer(&self.synthetic[i]) { i.max(1) } else { i + 1 }) }; - raw_input.events.extend(self.synthetic.drain(..n)); + for event in self.synthetic.drain(..n) { + if let egui::Event::ModifiersChanged(modifiers) = event { + self.synthetic_modifiers = modifiers; + } + raw_input.events.push(event); + } } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..fbd509f7 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -22,6 +22,10 @@ macro_rules! uic { } pub const UI_COMMANDS: &[UiCommand] = &[ + uic!("window.dockAction", "Docking Action", [], None), + uic!("window.dockPanel", "Dock Panel", [], None), + uic!("window.floatPanel", "Undock Panel", [], None), + uic!("window.closePanel", "Close Panel", [], None), uic!("app.language.english", "English", ["Edit", "Language"], None), uic!("app.language.japanese", "日本語", ["Edit", "Language"], None), uic!("app.language.spanish", "Español", ["Edit", "Language"], None), @@ -225,6 +229,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: // the engine's counters plus playback, frame workers and UI timings return Ok(crate::perf::stats(app)); } + if let Some(result) = crate::dock_ui::route(app, ctx, id, ¶ms) { + return result; + } if let Some(rest) = id.strip_prefix("window.panel.") { let p = PanelKind::from_name(rest).ok_or_else(|| format!("unknown panel `{rest}`"))?; app.show_panel(p); diff --git a/crates/ui-egui/src/panels/keyboard.rs b/crates/ui-egui/src/panels/keyboard.rs index 674769d6..66474061 100644 --- a/crates/ui-egui/src/panels/keyboard.rs +++ b/crates/ui-egui/src/panels/keyboard.rs @@ -235,7 +235,7 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { app.ui.keys.maximized = if app.ui.keys.maximized.is_some() { None } else { Some(p) }; if let Some(p) = app.ui.keys.maximized { - app.ui.dock.activate(p); + let _ = app.apply_dock(craft_ui::docking::Action::Activate { panel: p }); app.ui.focused = p; } json!({"maximized": app.ui.keys.maximized.map(|p| p.title())}) @@ -244,7 +244,7 @@ fn toggle_maximize(app: &mut FilmcraftApp, p: PanelKind) -> Value { /// The visible panel under the pointer (from last frame's automation rects). fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option { let pos = ctx.input(|i| i.pointer.latest_pos())?; - app.auto.query("panel.").into_iter().find_map(|e| { + app.auto.query("panel.").into_iter().rev().find_map(|e| { let r = egui::Rect::from_min_size(egui::pos2(e.rect[0], e.rect[1]), egui::vec2(e.rect[2], e.rect[3])); r.contains(pos).then(|| PanelKind::from_name(&e.id["panel.".len()..])).flatten() }) @@ -254,7 +254,9 @@ fn panel_under_pointer(app: &FilmcraftApp, ctx: &egui::Context) -> Option Vec { let mut all = Vec::new(); app.ui.dock.panels(&mut all); - all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect() + let mut visible: Vec<_> = all.into_iter().filter(|p| app.ui.dock.is_visible(*p) && !p.compact()).collect(); + visible.extend(app.ui.floating.iter().filter_map(|group| group.panels.get(group.active).copied()).filter(|panel| !panel.compact())); + visible } fn cycle_panel(app: &mut FilmcraftApp, next: bool) -> Value { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index bcb386fb..d3186d22 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -224,54 +224,64 @@ pub fn panel_menu_popup(app: &mut FilmcraftApp, ui: &mut egui::Ui) { let mut close = false; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(190.0); - // the Timeline's tabs are its open sequences: Close Panel closes the active one and - // keeps the panel (Premiere's wording and behaviour) - if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { - let r = ui.button(tl!("Close Panel")); - app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); - if r.clicked() { - let _ = app.session.execute("sequence.close", serde_json::json!({})); - close = true; - } - let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); - app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); - if r.clicked() { - let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); - close = true; - } - } else if ui.button(tl!("Close Panel")).clicked() { - app.ui.dock.close(p); - close = true; - } - if ui.button(tl!("Maximize Frame")).clicked() { - app.ui.dock = crate::dock::DockNode::Tabs { panels: vec![p], active: 0 }; - close = true; - } - if ui.button(tl!("Restore Workspace")).clicked() { - let w = app.ui.workspace.clone(); - app.set_workspace(&w); - close = true; - } - if p == PanelKind::Timeline { - ui.separator(); - let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); - app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); - if r.clicked() { - let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); - close = true; - } - ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); - ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); - } - if p == PanelKind::Project { - ui.separator(); - close |= project::panel_menu(app, ui); - } - if p == PanelKind::MediaBrowser { - ui.separator(); - close |= media_browser::panel_menu(app, ui); - } + let scroll = + egui::ScrollArea::vertical().id_salt("panel-menu-scroll").max_height((ui.ctx().content_rect().height() - 16.0).max(0.0)).show(ui, |ui| { + ui.set_min_width(190.0); + // the Timeline's tabs are its open sequences: Close Panel closes the active one and + // keeps the panel (Premiere's wording and behaviour) + if p == PanelKind::Timeline && app.session.state.active_sequence.is_some() { + let r = ui.button(tl!("Close Panel")); + app.auto.add("panel.menu.Timeline.close", r.rect, "Close Panel"); + if r.clicked() { + let _ = app.session.execute("sequence.close", serde_json::json!({})); + close = true; + } + let r = ui.add_enabled(app.session.state.open_sequences.len() > 1, egui::Button::new(tl!("Close Other Timeline Panels"))); + app.auto.add("panel.menu.Timeline.closeOthers", r.rect, "Close Other Timeline Panels"); + if r.clicked() { + let _ = app.session.execute("sequence.closeOthers", serde_json::json!({})); + close = true; + } + } else if ui.button(tl!("Close Panel")).clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.closePanel", serde_json::json!({"panel": p.id()})); + close = true; + } + let undock = ui.button(tl!("Undock Panel")); + app.auto.add_response(&format!("panel.menu.{}.undock", p.id()), &undock, "Undock Panel"); + if undock.clicked() { + let _ = crate::menus::invoke(app, ui.ctx(), "window.floatPanel", serde_json::json!({"panel": p.id()})); + close = true; + } + if ui.button(tl!("Maximize Frame")).clicked() { + app.ui.keys.maximized = if app.ui.keys.maximized == Some(p) { None } else { Some(p) }; + close = true; + } + if ui.button(tl!("Restore Workspace")).clicked() { + let w = app.ui.workspace.clone(); + app.set_workspace(&w); + close = true; + } + if p == PanelKind::Timeline { + ui.separator(); + let r = ui.add_enabled(app.session.state.active_sequence.is_some(), egui::Button::new(tl!("Reveal Sequence in Project"))); + app.auto.add("panel.menu.Timeline.revealSequence", r.rect, "Reveal Sequence in Project"); + if r.clicked() { + let _ = app.session.execute("sequence.revealInProject", serde_json::json!({})); + close = true; + } + ui.checkbox(&mut app.ui.timeline.show_thumbnails, tl!("Video Thumbnails")); + ui.checkbox(&mut app.ui.timeline.show_waveforms, tl!("Audio Waveforms")); + } + if p == PanelKind::Project { + ui.separator(); + close |= project::panel_menu(app, ui); + } + if p == PanelKind::MediaBrowser { + ui.separator(); + close |= media_browser::panel_menu(app, ui); + } + }); + app.auto.add("panel.menu.scroll", scroll.inner_rect, "Panel menu"); }); }); // A click elsewhere or Escape closes the menu. The click that opened it is over the tab, not diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index 11f4bc2d..3acc7aea 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -28,7 +28,10 @@ pub enum Which { pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) { let t = app.tokens; let ctx = ui.ctx().clone(); - let controls_h = 28.0 + 24.0 + 36.0; + let stacked_controls = rect.width() < 540.0; + let separate_source_drag = which == Which::Source && stacked_controls && rect.width() < 310.0; + let source_drag_h = if separate_source_drag { 26.0 } else { 0.0 }; + let controls_h = 28.0 + 24.0 + 36.0 + if stacked_controls { 26.0 } else { 0.0 } + source_drag_h; let video_area = Rect::from_min_max(rect.min + vec2(4.0, 4.0), pos2(rect.max.x - 4.0, rect.max.y - controls_h)); // the Source monitor's time ruler starts at `origin` (a subclip restricted to its range) let mut origin = Tick::ZERO; @@ -323,8 +326,16 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.auto.add(&format!("{prefix}.timecode"), tc_rect, &tc); let dur_tc = format_time(mark_out.map(|o| o + rate.frame_duration()).unwrap_or(duration) - range_start, rate, drop_frame, TimeDisplay::Timecode, 48000); ui.painter().text(pos2(row1.max.x, row1.center().y), Align2::RIGHT_CENTER, &dur_tc, Tokens::timecode(), t.text_dim); - // zoom + resolution dropdowns centred-ish - let zr = Rect::from_min_size(pos2(row1.min.x + 116.0, row1.min.y), vec2(70.0, 24.0)); + // At compact widths, timecodes and view settings need separate rows. + let settings_y = row1.min.y + if stacked_controls { 26.0 } else { 0.0 }; + let zoom_x = if stacked_controls && which == Which::Source && !separate_source_drag { + row1.min.x + } else if stacked_controls { + row1.center().x - 83.0 + } else { + row1.min.x + 116.0 + }; + let zr = Rect::from_min_size(pos2(zoom_x, settings_y), vec2(70.0, 24.0)); let zoom_label = match mv.zoom { None => tl!("Fit").to_string(), Some(z) => format!("{}%", (z * 100.0).round() as i32), @@ -333,9 +344,19 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) app.auto.add(&format!("{prefix}.zoom"), zr, "Select Zoom Level"); monitor_view::zoom_menu(app, &zresp, which); let res = mv.res; - let rr = Rect::from_min_size(pos2(row1.max.x - 196.0, row1.min.y), vec2(62.0, 24.0)); + let resolution_x = if stacked_controls && which == Which::Source && !separate_source_drag { + row1.max.x - 90.0 + } else if stacked_controls { + zr.max.x + 6.0 + } else { + row1.max.x - 196.0 + }; + let rr = Rect::from_min_size(pos2(resolution_x, settings_y), vec2(62.0, 24.0)); if which == Which::Source && rr.min.x > zr.max.x + 12.0 { - crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, row1.min.y), pos2(rr.min.x - 6.0, row1.min.y + 24.0))); + crate::panels::source_drag::controls(app, ui, Rect::from_min_max(pos2(zr.max.x + 6.0, settings_y), pos2(rr.min.x - 6.0, settings_y + 24.0))); + } + if separate_source_drag { + crate::panels::source_drag::controls(app, ui, Rect::from_min_size(pos2(row1.center().x - 52.0, settings_y + 26.0), vec2(104.0, 24.0))); } let rresp = crate::widgets::dropdown_text(ui, rr, crate::i18n::t(res.label()), &t, egui::Id::new((prefix, "res"))); app.auto.add(&format!("{prefix}.resolution"), rr, "Select Playback Resolution"); @@ -346,7 +367,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } } }); - let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, row1.min.y + 1.0), vec2(22.0, 22.0)); + let wr = Rect::from_min_size(pos2(rr.max.x + 6.0, settings_y + 1.0), vec2(22.0, 22.0)); let wresp = ui.interact(wr, egui::Id::new((prefix, "wrench")), Sense::click()); icons::paint(ui.painter(), wr.shrink(4.0), Icon::Wrench, if wresp.hovered() { t.tab_text_active } else { t.icon }); app.auto.add(&format!("{prefix}.settings"), wr, "Settings"); @@ -402,7 +423,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) } }); // ---- mini timeline / scrub bar - let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, row1.max.y + 2.0), vec2(rect.width() - 28.0, 22.0)); + let bar = Rect::from_min_size(pos2(rect.min.x + 14.0, settings_y + 28.0 + source_drag_h), vec2(rect.width() - 28.0, 22.0)); mini_timeline(app, ui, bar, which, origin, time, duration, rate, mark_in, mark_out); // ---- transport buttons @@ -726,51 +747,107 @@ fn transport(app: &mut FilmcraftApp, ui: &mut egui::Ui, row: Rect, which: Which) (Icon::Proxy, "media.toggleProxies", tl!("Toggle Proxies")), ] }; - let bw = 30.0; - let total = buttons.len() as f32 * bw; - let mut x = row.center().x - total / 2.0; - let ctx = ui.ctx().clone(); let prefix = if src { "source" } else { "program" }; - for (icon, cmd, tip) in buttons { - let r = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(bw - 2.0, 26.0)); - let resp = ui.interact(r, egui::Id::new((prefix, cmd)), Sense::click()).on_hover_text(tip); - app.auto.add(&format!("{prefix}.transport.{cmd}"), r, tip); - let is_play = cmd.ends_with("play") || cmd == "playback.toggle"; - if resp.hovered() { - ui.painter().rect_filled(r, 4.0, t.hover); + let available = (row.width() - 16.0).max(0.0); + let widths = vec![28.0; buttons.len()]; + let Some(fit) = craft_ui::layout::fit_toolbar(&widths, available, 2.0, 28.0) else { + ui.scope_builder(egui::UiBuilder::new().max_rect(row), |ui| { + egui::ScrollArea::horizontal().id_salt((prefix, "transport-scroll")).show(ui, |ui| { + ui.horizontal(|ui| { + for (icon, command, label) in &buttons { + let (rect, _) = ui.allocate_exact_size(vec2(28.0, 26.0), Sense::hover()); + if transport_button(app, ui, rect, prefix, *icon, command, label) { + invoke_transport(app, ui.ctx(), which, command); + } + } + }); + }); + }); + return; + }; + let total = fit.shown.len() as f32 * 30.0 - 2.0 + if fit.overflow.is_empty() { 0.0 } else { 30.0 }; + let mut x = row.center().x - total.max(28.0) / 2.0; + for (index, _) in &fit.shown { + let Some((icon, command, label)) = buttons.get(*index) else { continue }; + let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0)); + if transport_button(app, ui, rect, prefix, *icon, command, label) { + invoke_transport(app, ui.ctx(), which, command); } - let sz = if is_play { 16.0 } else { 14.0 }; - let on = cmd == "media.toggleProxies" && app.session.media.use_proxies(); - let col = if on { - t.accent - } else if resp.hovered() { - t.tab_text_active - } else { - t.icon - }; - icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(sz, sz)), icon, col); - if resp.clicked() { - let r = match cmd { - "src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|e| e.to_string()), - "src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|e| e.to_string()), - "src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => { - source_nav(app, cmd); - Ok(serde_json::Value::Null) - } - - "exportFrame" => crate::menus::invoke(app, &ctx, "file.exportFrame", json!({"monitor": if src { "source" } else { "program" }})), - c => crate::menus::invoke(app, &ctx, c, json!({"monitor": if src { "source" } else { "program" }})), - }; - if let Err(e) = r { - app.ui.status = e; - } - } - x += bw; + x += 30.0; + } + if !fit.overflow.is_empty() { + let rect = Rect::from_min_size(pos2(x, row.min.y + 2.0), vec2(28.0, 26.0)); + let response = ui.interact(rect, egui::Id::new((prefix, "transport-more")), Sense::click()).on_hover_text(tl!("More transport controls")); + response.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, tl!("More transport controls"))); + if response.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + for offset in [-5.0, 0.0, 5.0] { + ui.painter().circle_filled(rect.center() + vec2(offset, 0.0), 1.5, t.icon); + } + if response.has_focus() { + ui.painter().rect_stroke(rect.shrink(2.0), 2.0, Stroke::new(1.0, t.focus), StrokeKind::Inside); + } + app.auto.add_response(&format!("{prefix}.transport.more"), &response, "More transport controls"); + egui::Popup::menu(&response).show(|ui| { + for index in &fit.overflow { + let Some((_, command, label)) = buttons.get(*index) else { continue }; + let selected = match *command { + "media.toggleProxies" => Some(app.session.media.use_proxies()), + "playback.toggle" => Some(app.playback.playing), + "source.playback.toggle" => Some(app.source_playback.clock.playing), + _ => None, + }; + let response = if let Some(selected) = selected { ui.selectable_label(selected, *label) } else { ui.button(*label) }; + app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); + if response.clicked() { + invoke_transport(app, ui.ctx(), which, command); + ui.close(); + } + } + }); + } else if row.right() - x >= 30.0 { + // Keep the existing button-editor affordance only when it has its own space. + let rect = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); + let response = ui.interact(rect, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); + icons::paint(ui.painter(), rect.shrink(5.0), Icon::Plus, if response.hovered() { t.tab_text_active } else { t.text_dim }); + } +} + +fn transport_button(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, prefix: &str, icon: Icon, command: &str, label: &str) -> bool { + let t = app.tokens; + let response = ui.interact(rect, egui::Id::new((prefix, command)), Sense::click()).on_hover_text(label); + app.auto.add_response(&format!("{prefix}.transport.{command}"), &response, label); + if response.hovered() { + ui.painter().rect_filled(rect, 4.0, t.hover); + } + let size = if matches!(command, "playback.toggle" | "source.playback.toggle") { 16.0 } else { 14.0 }; + let color = if command == "media.toggleProxies" && app.session.media.use_proxies() { + t.accent + } else if response.hovered() { + t.tab_text_active + } else { + t.icon + }; + icons::paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(size, size)), icon, color); + response.clicked() +} + +fn invoke_transport(app: &mut FilmcraftApp, ctx: &egui::Context, which: Which, command: &str) { + let source = which == Which::Source; + let result = match command { + "src.markIn" => app.session.execute("markers.markIn", json!({"target": "source"})).map_err(|error| error.to_string()), + "src.markOut" => app.session.execute("markers.markOut", json!({"target": "source"})).map_err(|error| error.to_string()), + "src.goIn" | "src.goOut" | "src.stepBack" | "src.stepFwd" => { + source_nav(app, command); + Ok(serde_json::Value::Null) + } + "exportFrame" => crate::menus::invoke(app, ctx, "file.exportFrame", json!({"monitor": if source { "source" } else { "program" }})), + command => crate::menus::invoke(app, ctx, command, json!({"monitor": if source { "source" } else { "program" }})), + }; + if let Err(error) = result { + app.ui.status = error; } - // button editor "+" - let r = Rect::from_min_size(pos2(row.max.x - 30.0, row.min.y + 4.0), vec2(22.0, 22.0)); - let resp = ui.interact(r, egui::Id::new((prefix, "btn-editor")), Sense::click()).on_hover_text(tl!("Button Editor")); - icons::paint(ui.painter(), r.shrink(5.0), Icon::Plus, if resp.hovered() { t.tab_text_active } else { t.text_dim }); } fn source_nav(app: &mut FilmcraftApp, cmd: &str) { diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index d5997839..60732fc1 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -566,6 +566,7 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // accept drops from the project panel if let Some(item) = crate::panels::dragged_project_item(ui) && ui.rect_contains_pointer(rect) + && ui.input(|i| i.pointer.hover_pos()).is_some_and(|pos| app.timeline_drop_target(ui.ctx(), pos)) && ui.input(|i| i.pointer.any_released()) { let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); @@ -2286,6 +2287,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(item) = crate::panels::dragged_project_item(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let source = crate::panels::dragged_source(ui); let row = layout.row_at(p.y).cloned().filter(|r| { @@ -2333,6 +2335,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(template) = crate::panels::dragged_template(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let t = snap(app, seq, layout, rate.snap_nearest(layout.tick_at(p.x).max(Tick::ZERO)), &[]); let row = layout.row_at(p.y).cloned().filter(|r| r.kind == TrackKind::Video); @@ -2354,6 +2357,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & if let Some(effect) = crate::panels::dragged_effect(ui) && let Some(p) = ctx.pointer_hover_pos() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) && let Hit::Clip { clip, .. } = hit(seq, layout, p) && let Some((tid, it)) = seq.find_item(clip) && let Some(row) = layout.rows.iter().find(|r| r.track == tid) @@ -2408,6 +2412,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // ---- OS file drops imported with "drop imports to the timeline": placed at the drop point, one after another if let Some((items, p)) = app.pending_timeline_drop.clone() && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { app.pending_timeline_drop = None; let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned()); @@ -2436,6 +2441,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & // preview of OS files being dragged over the Timeline: a placeholder where the drop would start if let Some((count, p)) = app.file_drag_hover && layout.content.contains(p) + && app.timeline_drop_target(ui.ctx(), p) { let row = layout.row_at(p.y).cloned().or_else(|| layout.rows.iter().find(|r| r.kind == TrackKind::Video).cloned()); if let Some(row) = row { @@ -2444,6 +2450,7 @@ fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence, layout: & let r = Rect::from_min_max(pos2(x, row.rect.min.y + 1.0), pos2((x + 120.0).min(layout.content.max.x), row.rect.max.y - 1.0)); ui.painter().rect_filled(r, 3.0, Color32::from_white_alpha(40)); ui.painter().rect_stroke(r, 3.0, Stroke::new(1.5, Color32::WHITE), StrokeKind::Inside); + app.auto.add("timeline.fileDropPreview", r, "tracks"); let label = if count == 1 { tl!("1 file").to_string() } else { tlf!("{n} files", n = count) }; ui.painter().text(r.left_top() + vec2(4.0, 2.0), Align2::LEFT_TOP, label, Tokens::ui(10.0), Color32::WHITE); } diff --git a/crates/ui-egui/src/panels/tools.rs b/crates/ui-egui/src/panels/tools.rs index 35afefc9..15228a50 100644 --- a/crates/ui-egui/src/panels/tools.rs +++ b/crates/ui-egui/src/panels/tools.rs @@ -24,8 +24,14 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { format!("{} ({})", app.ui.language.tr(current.label()), current.shortcut()) }; let resp = ui.interact(r, egui::Id::new(("tool", format!("{:?}", group[0]))), Sense::click()).on_hover_text(tip); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, resp.enabled(), app.ui.tool == current, current.label())); for tl in &group { - app.auto.add(&format!("tools.{tl:?}"), r, tl.label()); + if *tl == current { + app.auto.add_response(&format!("tools.{tl:?}"), &resp, tl.label()); + } else { + // Hidden group alternatives remain click aliases, not separate focus targets. + app.auto.add(&format!("tools.{tl:?}"), r, tl.label()); + } } let active = group.contains(&app.ui.tool); if active { @@ -34,6 +40,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.painter().rect_filled(r, 4.0, t.hover); } icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(17.0, 17.0)), current.icon(), if active { egui::Color32::WHITE } else { t.icon }); + if resp.has_focus() { + ui.painter().rect_stroke(r.shrink(2.0), 4.0, egui::Stroke::new(1.5, t.focus), egui::StrokeKind::Inside); + } if group.len() > 1 { let c = r.right_bottom() - vec2(3.0, 3.0); ui.painter().add(egui::Shape::convex_polygon(vec![c, c - vec2(5.0, 0.0), c - vec2(0.0, 5.0)], t.text_dim, egui::Stroke::NONE)); @@ -64,7 +73,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Popup::menu(&resp).open_memory(command).close_behavior(close_behavior).show(|ui| { for tl in &group { let row = ui.selectable_label(app.ui.tool == *tl, format!("{} {}", app.ui.language.tr(tl.label()), tl.shortcut())); - app.auto.add(&format!("tools.select.{tl:?}"), row.rect, tl.label()); + app.auto.add_response(&format!("tools.select.{tl:?}"), &row, tl.label()); if row.clicked() { app.ui.tool = *tl; ui.close(); diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 69d52944..0923316c 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -56,12 +56,17 @@ pub fn route(app: &mut FilmcraftApp, id: &str, params: &Value) -> Option Result<(), String> { + crate::dock::Layout { root: app.ui.dock.clone(), floating: app.ui.floating.clone() }.validate()?; let layout = serde_json::to_value(&app.ui.dock).map_err(|e| e.to_string())?; + let floating = if app.ui.floating.is_empty() { serde_json::Value::Null } else { serde_json::to_value(&app.ui.floating).map_err(|e| e.to_string())? }; let mut next = app.workspaces.clone(); let saved = &mut next.saved; match saved.iter_mut().find(|s| s.name == name) { - Some(s) => s.layout = layout, - None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout }), + Some(s) => { + s.layout = layout; + s.floating = floating; + } + None => saved.push(dock::SavedWorkspace { name: name.to_string(), layout, floating }), } next.current = name.to_string(); app.set_workspaces(next) diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index aacea877..b02fae25 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -376,6 +376,8 @@ pub struct UiState { pub mode: Mode, pub workspace: String, pub dock: DockNode, + #[serde(default)] + pub floating: Vec, /// Focused panel (blue outline, receives shortcuts). pub focused: PanelKind, pub timeline: TimelineView, @@ -886,6 +888,7 @@ impl Default for UiState { tool: Tool::Selection, mode: Mode::Edit, workspace: "Editing".into(), + floating: vec![], dock: crate::dock::workspace("Editing"), focused: PanelKind::Timeline, timeline: TimelineView::default(), diff --git a/crates/ui-egui/tests/monitor_geometry.rs b/crates/ui-egui/tests/monitor_geometry.rs new file mode 100644 index 00000000..bb671196 --- /dev/null +++ b/crates/ui-egui/tests/monitor_geometry.rs @@ -0,0 +1,185 @@ +//! Geometry and reachability checks against the production monitor renderer. + +use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{self, DockNode, PanelKind, SeqTabs}, + panels::monitor::{self, Which}, + theme::{self, ThemeKind}, +}; +use serde_json::json; + +fn harness(width: f32, kind: ThemeKind, which: Which) -> Harness<'static, FilmcraftApp> { + harness_at_scale(width, kind, which, 1.0) +} + +fn harness_at_scale(width: f32, kind: ThemeKind, which: Which, scale: f32) -> Harness<'static, FilmcraftApp> { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + session.execute("source.open", json!({"item":5})).unwrap(); + let mut h = Harness::builder().with_size(vec2(width, 340.0)).with_pixels_per_point(scale).build_ui_state( + move |ui, app: &mut FilmcraftApp| { + app.set_theme(ui.ctx(), kind); + app.auto.begin_frame(); + let panel = if which == Which::Source { PanelKind::Source } else { PanelKind::Program }; + let mut node = DockNode::Tabs { panels: vec![panel], active: 0 }; + let mut groups = Vec::new(); + dock::layout(ui, &mut node, ui.max_rect(), &app.tokens, "monitor", &mut groups, &mut app.auto); + for group in groups { + // Exercise the actual parent chrome, including its themed panel background. + dock::draw_group_chrome(ui, &group, panel, &SeqTabs::default(), &app.tokens, &mut app.auto); + monitor::show(app, ui, group.content, which); + } + let source = filmcraft_ui_egui::panels::dragged_source(ui); + ui.ctx().data_mut(|data| data.insert_temp(egui::Id::new("observed-source-drag"), source)); + }, + FilmcraftApp::new(session), + ); + theme::install(&h.ctx, &h.state().tokens); + h.run_steps(4); + h +} + +fn element(h: &Harness<'_, FilmcraftApp>, id: &str) -> Rect { + let element = h.state().auto.elements.iter().find(|element| element.id == id).unwrap_or_else(|| panic!("missing {id}")); + let [x, y, w, h] = element.rect; + Rect::from_min_size(pos2(x, y), vec2(w, h)) +} + +fn click(h: &mut Harness<'_, FilmcraftApp>, id: &str) { + let pos = element(h, id).center(); + h.event(Event::PointerMoved(pos)); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + h.step(); + } + h.run_steps(3); +} + +fn overlaps(first: Rect, second: Rect) -> bool { + let intersection = first.intersect(second); + intersection.width() > 0.0 && intersection.height() > 0.0 +} + +#[test] +fn monitor_readouts_and_dropdowns_do_not_overlap() { + let mut failures = Vec::new(); + for (which, prefix) in [(Which::Source, "source"), (Which::Program, "program")] { + for kind in [ThemeKind::Dark, ThemeKind::Medium, ThemeKind::Light] { + for width in [720.0, 500.0, 300.0, 250.0] { + for scale in [1.0, 2.0] { + let mut h = harness_at_scale(width, kind, which, scale); + if let Ok(directory) = std::env::var("CRAFT_UI_VISUAL_DIR") { + std::fs::create_dir_all(&directory).unwrap(); + h.render().unwrap().save(format!("{directory}/film-{prefix}-{kind:?}-{width}-{scale}x.png")).unwrap(); + } + let controls = ["timecode", "zoom", "resolution", "settings"].map(|name| element(&h, &format!("{prefix}.{name}"))); + let timecodes: Vec<_> = h + .output() + .shapes + .iter() + .filter_map(|shape| match &shape.shape { + egui::Shape::Text(text) if text.galley.text().matches(':').count() == 3 => { + Some(text.visual_bounding_rect().intersect(shape.clip_rect)) + } + _ => None, + }) + .collect(); + assert_eq!(timecodes.len(), 2, "both actual timecode galleys must be painted"); + if overlaps(timecodes[0], timecodes[1]) { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: actual timecode text overlaps: {timecodes:?}")); + } + for (i, first) in controls.iter().enumerate() { + if first.left() < 0.0 || first.right() > width { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: outside monitor: {first:?}")); + } + for second in controls.iter().skip(i + 1) { + if overlaps(*first, *second) { + failures.push(format!("{prefix} {kind:?} {width} {scale}x: {first:?} overlaps {second:?}")); + } + } + } + } + } + } + } + assert!(failures.is_empty(), "{}", failures.join("\n")); +} + +#[test] +fn compact_transport_buttons_stay_inside_monitor() { + for width in [720.0, 500.0, 300.0, 250.0] { + let h = harness(width, ThemeKind::Dark, Which::Program); + for e in &h.state().auto.elements { + if e.id.starts_with("program.transport.") { + let r = element(&h, &e.id); + assert!(r.left() >= 0.0 && r.right() <= width, "{width}: {} {r:?}", e.id); + } + } + } +} + +#[test] +fn compact_transport_more_runs_the_existing_proxy_command() { + let mut h = harness(250.0, ThemeKind::Light, Which::Program); + let before = h.state().session.media.use_proxies(); + click(&mut h, "program.transport.more"); + assert_eq!( + h.get_by_label("Toggle Proxies").accesskit_node().toggled(), + Some(if before { egui::accesskit::Toggled::True } else { egui::accesskit::Toggled::False }) + ); + click(&mut h, "program.transport.media.toggleProxies"); + assert_eq!(h.state().session.media.use_proxies(), !before); + click(&mut h, "program.transport.more"); + assert_eq!( + h.get_by_label("Toggle Proxies").accesskit_node().toggled(), + Some(if before { egui::accesskit::Toggled::False } else { egui::accesskit::Toggled::True }) + ); +} + +#[test] +fn compact_source_overflow_keeps_source_navigation_routing() { + let mut h = harness(250.0, ThemeKind::Dark, Which::Source); + let before = h.state().session.state.source_playhead; + let program = h.state().session.playhead(); + click(&mut h, "source.transport.more"); + click(&mut h, "source.transport.src.goOut"); + assert!(h.state().session.state.source_playhead > before); + assert_eq!(h.state().session.playhead(), program); +} + +fn source_drag_choices(width: f32) { + for (control, video, audio) in [("video", true, false), ("audio", false, true), ("both", true, true)] { + let mut h = harness(width, ThemeKind::Dark, Which::Source); + let rect = element(&h, &format!("source.drag.{control}")); + assert!(rect.left() >= 0.0 && rect.right() <= width); + let point = rect.center(); + h.event(Event::PointerMoved(point)); + h.step(); + h.event(Event::PointerButton { pos: point, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.step(); + h.event(Event::PointerMoved(point + vec2(0.0, 10.0))); + h.run_steps(2); + let source = h + .ctx + .data(|data| data.get_temp::>(egui::Id::new("observed-source-drag"))) + .flatten() + .expect("drag must create the production Source payload"); + assert_eq!((source.video, source.audio), (video, audio)); + assert_eq!(source.item.0, 5); + assert!(source.range.duration.0 > 0); + } +} + +#[test] +fn source_drag_choices_remain_reachable_at_500_pixels() { + source_drag_choices(500.0); +} + +#[test] +fn source_drag_choices_remain_reachable_at_250_pixels() { + source_drag_choices(250.0); +} diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index bb504f32..b8aab49d 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -322,8 +322,21 @@ fn freeform_view_places_and_stacks_cards() { assert!(d.has(&format!("project.stack.{}", items[0])), "×2 badge"); // Freeform View Options… from the panel menu d.click("panel.menu.Project"); + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); + let item_rect = d.rect("project.menu.freeformOptions"); + let ctx = d.harness.ctx.clone(); + let at = egui::pos2(item_rect[0] + item_rect[2] / 2.0, item_rect[1] + item_rect[3] / 2.0); + assert!(ctx.content_rect().contains(at), "bottom menu action must be reachable: {item_rect:?}"); + d.snapshot("project-freeform-menu", None); d.click("project.menu.freeformOptions"); - assert!(d.has("projectDialog.snap")); + let app = d.app(); + let diagnostic = format!( + "dialog={:?}, controls={:?}", + app.ui.project_panel.dialog, + app.auto.elements.iter().filter(|element| element.id.starts_with("projectDialog.")).map(|element| (&element.id, element.rect)).collect::>() + ); + assert!(d.has("projectDialog.snap"), "{diagnostic}"); d.click("projectDialog.snap"); d.click("projectDialog.ok"); assert!(d.app().session.prefs.project_panel.freeform.snap); @@ -358,6 +371,8 @@ fn metadata_display_dialog_and_view_presets() { ] { assert!(d.has(id), "{id}"); } + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); d.click("project.menu.metadataDisplay"); d.frames(4); assert!(matches!(d.app().ui.project_panel.dialog, Some(filmcraft_ui_egui::panels::project::ProjectDialog::MetadataDisplay { .. }))); @@ -573,6 +588,8 @@ fn media_browser_navigation_import_and_columns() { assert!(d.label("mediaBrowser.count").starts_with("2 of 2")); d.snapshot("media-browser-list", Some("panel.MediaBrowser")); d.click("panel.menu.MediaBrowser"); + d.ok("ui.scroll", json!({"id": "panel.menu.scroll", "dy": -1200})); + d.frames(4); d.click("mediaBrowser.menu.import"); assert_eq!(d.app().session.project.items.len(), 2); // Shift+O opens the selection in the Source monitor (already imported: no duplicate) diff --git a/crates/ui-egui/tests/shared_docking_ui.rs b/crates/ui-egui/tests/shared_docking_ui.rs new file mode 100644 index 00000000..fcabbdd0 --- /dev/null +++ b/crates/ui-egui/tests/shared_docking_ui.rs @@ -0,0 +1,515 @@ +//! Shared docking preserves FilmCraft document state and legacy workspace files. + +use craft_ui::docking::{Action, Placement, Zone}; +use egui::{Event, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{self, DockNode, Layout, PanelKind, SplitSize}, + menus, +}; +use serde_json::json; + +fn layout() -> Layout { + Layout { + root: DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info, PanelKind::History], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Source, PanelKind::Program], active: 1 }), + }, + floating: vec![], + } +} + +#[test] +fn shared_transactions_keep_selection_and_reject_invalid_moves_atomically() { + let mut layout = layout(); + layout.apply(Action::Close { panel: PanelKind::Project }).unwrap(); + assert!(layout.root.is_visible(PanelKind::Info)); + let before = layout.clone(); + for action in [ + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Tools, placement: Placement::Split(Zone::Left) }, + Action::Float { panel: PanelKind::Info, rect: [0.0, 0.0, f32::NAN, 100.0] }, + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Tab { before: Some(PanelKind::Tools) } }, + ] { + assert!(layout.apply(action).is_err()); + assert_eq!(layout, before); + } + layout.apply(Action::Float { panel: PanelKind::Info, rect: [50.0, 50.0, 400.0, 300.0] }).unwrap(); + layout.apply(Action::Move { panel: PanelKind::Info, anchor: PanelKind::Program, placement: Placement::Split(Zone::Bottom) }).unwrap(); + assert!(layout.floating.is_empty()); + assert!(layout.root.is_visible(PanelKind::Info)); +} + +#[test] +fn builtin_and_legacy_workspace_shapes_roundtrip_and_save_floating() { + for name in dock::WORKSPACES { + let root = dock::workspace(name); + let before = serde_json::to_value(&root).unwrap(); + let prefs: dock::WorkspacePrefs = serde_json::from_value(json!({"saved": [{"name": name, "layout": before}], "current": name})).unwrap(); + let loaded = dock::saved_docking(&prefs, name); + loaded.validate().unwrap(); + assert_eq!(loaded.root, root); + assert!(loaded.floating.is_empty()); + assert!(serde_json::to_value(&prefs).unwrap()["saved"][0].get("floating").is_none()); + } + let dir = std::env::temp_dir().join(format!("filmcraft-shared-dock-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut session = Session::default(); + session.prefs_path = Some(dir.join("preferences.json")); + let mut app = FilmcraftApp::new(session); + let ctx = egui::Context::default(); + app.ui.dock = layout().root; + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Info", "rect": [45, 65, 420, 320]})).unwrap(); + menus::invoke(&mut app, &ctx, "window.workspace.saveAs", json!({"name": "Floating test"})).unwrap(); + let prefs = dock::WorkspacePrefs::load(&dir.join(dock::WORKSPACES_FILE)); + let restored = dock::saved_docking(&prefs, "Floating test"); + assert_eq!(restored.floating, app.ui.floating); + assert_eq!(restored.root, app.ui.dock); + let mut restarted_session = Session::default(); + restarted_session.prefs_path = Some(dir.join("preferences.json")); + let restarted = FilmcraftApp::new(restarted_session); + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| restarted); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(h.state().ui.dock, restored.root); + assert_eq!(h.state().ui.floating, restored.floating); + std::fs::remove_dir_all(dir).unwrap(); +} + +#[test] +fn legacy_effects_workspace_drops_only_the_known_inactive_duplicate() { + let expected = dock::workspace("Effects"); + let mut legacy = expected.clone(); + fn duplicate(node: &mut DockNode) { + match node { + DockNode::Tabs { panels, active } if panels.as_slice() == [PanelKind::EssentialGraphics] => { + panels.insert(0, PanelKind::EffectControls); + *active = 1; + } + DockNode::Split { a, b, .. } => { + duplicate(a); + duplicate(b); + } + DockNode::Tabs { .. } => {} + } + } + duplicate(&mut legacy); + let prefs: dock::WorkspacePrefs = + serde_json::from_value(json!({"saved": [{"name": "Historical Effects", "layout": legacy}], "current": "Historical Effects"})).unwrap(); + assert_eq!(dock::saved_docking(&prefs, "Historical Effects").root, expected); +} + +fn step(h: &mut Harness<'_, FilmcraftApp>) { + let ctx = h.ctx.clone(); + let mut input = std::mem::take(h.input_mut()); + eframe::App::raw_input_hook(h.state_mut(), &ctx, &mut input); + *h.input_mut() = input; + h.step(); +} +fn rect(h: &Harness<'_, FilmcraftApp>, id: &str) -> Rect { + let element = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")); + Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])) +} +fn click(h: &mut Harness<'_, FilmcraftApp>, id: &str) { + let pos = rect(h, id).center(); + h.event(Event::PointerMoved(pos)); + step(h); + for pressed in [true, false] { + h.event(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }); + step(h); + } + for _ in 0..3 { + step(h); + } +} +fn drag(h: &mut Harness<'_, FilmcraftApp>, from: egui::Pos2, to: egui::Pos2, modifiers: Modifiers) { + h.event(Event::ModifiersChanged(modifiers)); + h.event(Event::PointerMoved(from)); + step(h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers }); + step(h); + for fraction in [0.05, 0.25, 0.5, 0.75, 1.0] { + h.event(Event::PointerMoved(from.lerp(to, fraction))); + step(h); + } + h.event(Event::PointerButton { pos: to, button: PointerButton::Primary, pressed: false, modifiers }); + step(h); + h.event(Event::ModifiersChanged(Modifiers::NONE)); + for _ in 0..3 { + step(h); + } +} + +#[test] +fn real_panel_drag_merges_splits_floats_and_redocks_without_editing_documents() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + let snapshots = std::env::var_os("CRAFT_UI_DOCKING_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut builder = Harness::builder().with_size(vec2(1200.0, 760.0)); + if snapshots.is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + h.state_mut().ui.dock = DockNode::Split { + vertical: true, + size: SplitSize::Ratio(0.62), + a: Box::new(layout().root), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + for _ in 0..3 { + step(&mut h); + } + let project = h.state().session.project.clone(); + let mut action_log = vec![action_state(&h, "start")]; + let playheads = h.state().session.state.playheads.clone(); + let from = rect(&h, "panel.tab.Info").center(); + let to = rect(&h, "panel.Program").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.dock.is_visible(PanelKind::Info)); + assert!(same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Program)); + action_log.push(action_state(&h, "merged")); + let from = rect(&h, "panel.tab.Info").center(); + let before = rect(&h, "panel.tab.Source"); + drag(&mut h, from, pos2(before.left() + 2.0, before.center().y), Modifiers::NONE); + assert_eq!(tabs_containing(&h.state().ui.dock, PanelKind::Info).first(), Some(&PanelKind::Info)); + action_log.push(action_state(&h, "reordered")); + + let from = rect(&h, "panel.tab.Info").center(); + let left = rect(&h, "panel.History"); + // Activate a visible sibling after the source left its original group. + let to = pos2(left.left() + 8.0, left.center().y); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.dock.is_visible(PanelKind::Info)); + assert!(!same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::History)); + assert!(!same_group(&h.state().ui.dock, PanelKind::Info, PanelKind::Program)); + action_log.push(action_state(&h, "split")); + click(&mut h, "panel.tab.Program"); + assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip."))); + assert!(h.state().auto.find("program.transport.playhead.stepForward").is_some()); + let from = rect(&h, "panel.tab.Info").center(); + drag(&mut h, from, pos2(700.0, 400.0), Modifiers::COMMAND); + assert_eq!(h.state().ui.floating.len(), 1); + assert_eq!(h.get_by_label("Resize floating panel").accesskit_node().role(), egui::accesskit::Role::Button); + action_log.push(action_state(&h, "floated")); + let original = h.state().ui.floating[0].rect; + let grip = rect(&h, "panel.float.0.resize").center(); + drag(&mut h, grip, grip + vec2(36.0, 24.0), Modifiers::NONE); + assert!(h.state().ui.floating[0].rect[2] > original[2] + 25.0); + assert!(h.state().ui.floating[0].rect[3] > original[3] + 15.0); + action_log.push(action_state(&h, "resized")); + let grip_id = egui::Id::new(("floating-panel", PanelKind::Info.id())).with("resize"); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); + let before_keys = h.state().ui.floating[0].rect; + for key in [egui::Key::ArrowRight, egui::Key::ArrowDown] { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + } + } + assert_eq!(h.state().ui.floating[0].rect[2], before_keys[2] + 8.0); + assert_eq!(h.state().ui.floating[0].rect[3], before_keys[3] + 8.0); + h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + h.event(Event::Key { key: egui::Key::Tab, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + for _ in 0..3 { + step(&mut h); + } + assert_ne!(h.ctx.memory(|memory| memory.focused()), Some(grip_id)); + action_log.push(action_state(&h, "keyboard-resized")); + let resized = h.state().ui.floating[0].rect; + let header = rect(&h, "panel.float.0.move"); + let from = pos2(header.right() - 24.0, header.center().y); + drag(&mut h, from, from - vec2(24.0, 18.0), Modifiers::NONE); + assert!(h.state().ui.floating[0].rect[0] < resized[0] - 15.0); + assert!(h.state().ui.floating[0].rect[1] < resized[1] - 10.0); + action_log.push(action_state(&h, "moved")); + let saved_root = h.state().ui.dock.clone(); + let saved_floating = h.state().ui.floating.clone(); + let ctx = h.ctx.clone(); + menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + assert!(rect(&h, "panel.Info").width() > 1000.0); + action_log.push(action_state(&h, "maximized")); + assert_eq!(h.state().ui.dock, saved_root); + assert_eq!(h.state().ui.floating, saved_floating); + menus::invoke(h.state_mut(), &ctx, "window.maximizeFrame", json!({})).unwrap(); + for _ in 0..3 { + step(&mut h); + } + assert_eq!(h.state().ui.floating, saved_floating); + + if let Some(directory) = &snapshots { + std::fs::create_dir_all(directory).unwrap(); + h.render().unwrap().save(directory.join("filmcraft-floating.png")).unwrap(); + } + let from = rect(&h, "panel.tab.Info").center(); + let to = rect(&h, "panel.History").center(); + drag(&mut h, from, to, Modifiers::NONE); + assert!(h.state().ui.floating.is_empty()); + action_log.push(action_state(&h, "redocked")); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + assert_eq!(playheads, h.state().session.state.playheads); + let playhead = h.state().session.playhead(); + click(&mut h, "program.transport.playhead.stepForward"); + assert!(h.state().session.playhead() > playhead); + assert!(h.state().auto.elements.iter().any(|element| element.id.starts_with("timeline.clip."))); + action_log.push(action_state(&h, "program-step-forward")); + if let Some(directory) = &snapshots { + std::fs::write(directory.join("filmcraft-actions.json"), serde_json::to_vec_pretty(&action_log).unwrap()).unwrap(); + } + if let Some(directory) = &snapshots { + h.render().unwrap().save(directory.join("filmcraft-redocked.png")).unwrap(); + } + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + assert!(h.state().ui_error.is_none()); +} + +fn same_group(root: &DockNode, first: PanelKind, second: PanelKind) -> bool { + match root { + DockNode::Tabs { panels, .. } => panels.contains(&first) && panels.contains(&second), + DockNode::Split { a, b, .. } => same_group(a, first, second) || same_group(b, first, second), + } +} + +fn tabs_containing(root: &DockNode, panel: PanelKind) -> Vec { + match root { + DockNode::Tabs { panels, .. } if panels.contains(&panel) => panels.clone(), + DockNode::Split { a, b, .. } => { + let first = tabs_containing(a, panel); + if first.is_empty() { tabs_containing(b, panel) } else { first } + } + _ => vec![], + } +} + +#[test] +fn docking_commands_reject_bad_geometry_and_targets_without_partial_changes() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = layout().root; + let ctx = egui::Context::default(); + let before = app.ui.dock.clone(); + for (id, params) in [ + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300]})), + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 300, 200, 7]})), + ("window.floatPanel", json!({"panel": "Info", "rect": [1, 2, 1e100, 200]})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Tools"})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "zone": 42})), + ("window.dockPanel", json!({"panel": "Info", "anchor": "Program", "before": "Tools"})), + ] { + assert!(menus::invoke(&mut app, &ctx, id, params.clone()).is_err(), "{id} accepted {params}"); + assert_eq!(app.ui.dock, before); + assert!(app.ui.floating.is_empty()); + } + app.ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + assert!(menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "Project", "rect": [1, 2, 300, 200]})).is_err()); +} + +fn action_state(h: &Harness<'_, FilmcraftApp>, action: &str) -> serde_json::Value { + json!({"action": action, "dock": h.state().ui.dock, "floating": h.state().ui.floating, "focused": h.state().ui.focused}) +} + +#[test] +fn catalog_docking_action_supports_resize_reorder_and_floating_geometry() { + let mut app = FilmcraftApp::new(Session::default()); + app.ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(300.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Info], active: 1 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Program], active: 0 }), + }; + let ctx = egui::Context::default(); + for action in [ + Action::ResizeSplit { path: vec![], size: craft_ui::layout::SplitSize::FixedFirst(360.0) }, + Action::Move { panel: PanelKind::Info, anchor: PanelKind::Project, placement: Placement::Tab { before: Some(PanelKind::Project) } }, + Action::Float { panel: PanelKind::Info, rect: [20.0, 40.0, 300.0, 200.0] }, + Action::MoveFloating { panel: PanelKind::Info, rect: [45.0, 65.0, 330.0, 240.0] }, + ] { + filmcraft_ui_egui::menus::invoke(&mut app, &ctx, "window.dockAction", json!({"action": action})).unwrap(); + } + assert_eq!(app.ui.floating[0].rect, [45.0, 65.0, 330.0, 240.0]); + let DockNode::Split { size, .. } = &app.ui.dock else { panic!("split lost") }; + assert_eq!(*size, SplitSize::FixedA(360.0)); + let before = json!({"dock": app.ui.dock, "floating": app.ui.floating}); + assert!( + filmcraft_ui_egui::menus::invoke( + &mut app, + &ctx, + "window.dockAction", + json!({"action": { + "ResizeSplit": {"path": [true, true], "size": {"Ratio": 0.5}} + }}) + ) + .is_err() + ); + assert_eq!(json!({"dock": app.ui.dock, "floating": app.ui.floating}), before); +} + +#[test] +fn unreadable_floating_workspace_does_not_erase_valid_workspaces_or_current_name() { + let root = DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }; + let valid = json!({"name": "Valid", "layout": root, "floating": [{ + "panels": ["TextToSpeech", "Timeline"], "active": 0, "rect": [80, 90, 420, 320] + }]}); + for (index, bad) in [ + json!([{"panels": ["FuturePanel"], "active": 0, "rect": [80, 90, 420, 320]}]), + json!([{"panels": ["Info"], "active": "invalid", "rect": [80, 90, 420, 320]}]), + json!({"malformed": true}), + ] + .into_iter() + .enumerate() + { + let path = std::env::temp_dir().join(format!("filmcraft-floating-isolation-{}-{index}.json", std::process::id())); + let prefs = json!({"saved": [valid.clone(), {"name": "Broken", "layout": root, "floating": bad}], "current": "Valid"}); + std::fs::write(&path, serde_json::to_vec(&prefs).unwrap()).unwrap(); + let loaded = dock::WorkspacePrefs::load(&path); + assert_eq!(loaded.current, "Valid"); + assert_eq!(loaded.saved.len(), 2); + let restored = dock::saved_docking(&loaded, "Valid"); + restored.validate().unwrap(); + assert_eq!(restored.root, root); + assert_eq!(restored.floating[0].panels, vec![PanelKind::TextToSpeech, PanelKind::Timeline]); + assert_eq!(dock::saved_docking(&loaded, "Broken").root, dock::workspace("Broken")); + loaded.save(&path).unwrap(); + assert_eq!(dock::WorkspacePrefs::load(&path), loaded, "saving preserves the unreadable entry for a future version"); + std::fs::remove_file(path).unwrap(); + } +} + +#[test] +fn floating_tts_and_timeline_reveal_keeps_one_owner_and_draft_without_project_edits() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let mut app = FilmcraftApp::new(session); + app.ui.tts.text = "Synthetic retained narration".into(); + app.show_panel(PanelKind::TextToSpeech); + let project = app.session.project.clone(); + let state = serde_json::to_value(&app.session.state).unwrap(); + let draft = serde_json::to_value(&app.ui.tts).unwrap(); + let ctx = egui::Context::default(); + menus::invoke(&mut app, &ctx, "window.floatPanel", json!({"panel": "TextToSpeech", "rect": [80, 90, 420, 320]})).unwrap(); + menus::invoke( + &mut app, + &ctx, + "window.dockAction", + json!({"action": Action::Move { + panel: PanelKind::Timeline, anchor: PanelKind::TextToSpeech, placement: Placement::Tab { before: None } + }}), + ) + .unwrap(); + app.show_panel(PanelKind::TextToSpeech); + app.show_panel(PanelKind::Timeline); + assert!(!app.ui.dock.contains(PanelKind::TextToSpeech)); + assert!(!app.ui.dock.contains(PanelKind::Timeline)); + assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert_eq!(app.ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::Timeline).count(), 1); + assert_eq!(serde_json::to_value(&app.ui.tts).unwrap(), draft); + assert!(std::sync::Arc::ptr_eq(&project, &app.session.project)); + assert_eq!(serde_json::to_value(&app.session.state).unwrap(), state); +} + +#[test] +fn narration_selection_reveals_covered_floating_tts_once_without_editing_the_project() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let clip = session + .active_sequence() + .unwrap() + .audio_tracks + .iter() + .flat_map(|track| &track.items) + .find(|clip| { + session.project.item(clip.item).and_then(|item| item.as_media()).is_some_and(|media| media.info.kind == filmcraft_media::MediaKind::AudioOnly) + }) + .unwrap() + .clone(); + let narration = filmcraft_project::Narration { + text: "Synthetic narration behind an overlapping pane".into(), + language: "en-US".into(), + voice: filmcraft_tts::default_voice_id().into(), + pitch: filmcraft_project::VocalPitch::Default, + pace: 1.2, + speech_duration: filmcraft_time::Tick::ZERO, + }; + std::sync::Arc::make_mut(&mut session.project).narrations.insert(clip.item, narration.clone()); + let project = session.project.clone(); + let mut app = FilmcraftApp::new(session); + app.show_panel(PanelKind::TextToSpeech); + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_eframe(move |_| app); + for _ in 0..4 { + step(&mut h); + } + let ctx = h.ctx.clone(); + for panel in ["TextToSpeech", "Info"] { + menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": [400, 220, 450, 360]})).unwrap(); + for _ in 0..4 { + step(&mut h); + } + } + let layer = |panel: PanelKind| egui::LayerId::new(egui::Order::Middle, egui::Id::new(("floating-panel", panel.id()))); + let covered = rect(&h, "panel.TextToSpeech").center(); + assert!(rect(&h, "panel.Info").contains(covered)); + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..3 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info)), "precondition: Info really covers TTS"); + h.state_mut().session.state.selection = vec![clip.id]; + for _ in 0..4 { + step(&mut h); + } + let draft = h.state().ui.tts.clone(); + assert_eq!(draft.text, narration.text); + assert_eq!(draft.pace, narration.pace); + assert_eq!(draft.loaded_from, Some((clip.id.0, clip.item.0))); + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech)), "narration selection must bring its floating editor forward"); + assert_eq!(h.state().ui.focused, PanelKind::TextToSpeech); + assert!(!h.state().ui.dock.contains(PanelKind::TextToSpeech)); + assert_eq!(h.state().ui.floating.iter().flat_map(|group| &group.panels).filter(|panel| **panel == PanelKind::TextToSpeech).count(), 1); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + // A reveal is one-shot: drawing the selected narration must not steal stacking + // from another pane that the user subsequently brings forward. + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(h.state().ui.tts, draft); + menus::invoke(h.state_mut(), &ctx, "window.panel.TextToSpeech", json!({})).unwrap(); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::TextToSpeech))); + assert_eq!(h.state().ui.tts, draft); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); + // Replacing the workspace cancels a queued reveal before the next render, + // even when the destination happens to contain the same floating panel IDs. + ctx.move_to_top(layer(PanelKind::Info)); + for _ in 0..3 { + step(&mut h); + } + let saved = dock::SavedWorkspace { + name: "Reveal cancellation".into(), + layout: serde_json::to_value(&h.state().ui.dock).unwrap(), + floating: serde_json::to_value(&h.state().ui.floating).unwrap(), + }; + h.state_mut().workspaces.saved.push(saved); + h.state_mut().show_panel(PanelKind::TextToSpeech); + h.state_mut().set_workspace("Reveal cancellation"); + for _ in 0..4 { + step(&mut h); + } + assert_eq!(ctx.layer_id_at(covered), Some(layer(PanelKind::Info))); + assert_eq!(h.state().ui.tts, draft); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project)); +} diff --git a/crates/ui-egui/tests/timecode_ui.rs b/crates/ui-egui/tests/timecode_ui.rs index badc3536..d0b84928 100644 --- a/crates/ui-egui/tests/timecode_ui.rs +++ b/crates/ui-egui/tests/timecode_ui.rs @@ -174,3 +174,37 @@ fn the_program_and_source_timecodes_move_their_own_playheads() { assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap(), "1 s at the clip's own rate"); assert_eq!(d.harness.state().session.playhead(), program); } + +#[test] +fn compact_floating_monitor_timecodes_keep_drag_typing_escape_and_caret_routing() { + for scale in [1.0, 2.0] { + let mut d = Driver::demo(); + d.harness.ctx.set_pixels_per_point(scale); + d.frames(4); + for (panel, x) in [("Source", 70), ("Program", 360)] { + d.ok("engine.execute", json!({"command": "window.floatPanel", "params": {"panel": panel, "rect": [x, 100, 250, 360]}})); + } + let project = d.harness.state().session.project.clone(); + d.set_frame(30); + d.drag_by("program.timecode", 20.0); + assert_eq!(d.frame(), 50); + let program = d.harness.state().session.playhead(); + let source = d.source_frame(); + d.drag_by("source.timecode", 20.0); + assert_eq!(d.source_frame(), source + 20); + assert_eq!(d.harness.state().session.playhead(), program); + d.type_into("program.timecode", "1000", "Escape"); + assert_eq!(d.harness.state().session.playhead(), program); + d.type_into("source.timecode", "100", "Enter"); + assert_eq!(d.source_frame(), parse_timecode("100", d.source_rate(), false, 0).unwrap()); + assert_eq!(d.harness.state().session.playhead(), program); + d.ok("ui.click", json!({"id": "program.timecode"})); + d.ok("ui.key", json!({"key": "Left"})); + assert_eq!(d.harness.state().session.playhead(), program, "caret arrows must not seek"); + d.ok("ui.key", json!({"key": "Escape"})); + d.type_into("program.timecode", "200", "Enter"); + let s = &d.harness.state().session; + assert_eq!(d.frame(), parse_timecode("200", s.sequence_rate(), s.active_sequence().unwrap().settings.drop_frame, 0).unwrap()); + assert!(std::sync::Arc::ptr_eq(&project, &s.project)); + } +} diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9f..514b2a03 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -152,3 +152,117 @@ fn with_drop_import_off_the_file_is_only_imported() { assert_eq!(h.state().session.project.items.len(), items_before + 1, "the still is imported to the bin"); assert_eq!(clip_counts(&h), (0, 0), "status: {}", h.state().ui.status); } + +fn float_panel(h: &mut Harness<'static, FilmcraftApp>, panel: &str, rect: [f32; 4]) { + let ctx = h.ctx.clone(); + filmcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "window.floatPanel", json!({"panel": panel, "rect": rect})).unwrap(); + for _ in 0..4 { + step(h); + } +} + +fn drop_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2, path: &Path) { + pointer_to(h, pos); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf()))); + step(h); + h.input_mut().dropped_files.clear(); + step(h); +} + +fn hover_at(h: &mut Harness<'static, FilmcraftApp>, pos: egui::Pos2) -> bool { + pointer_to(h, pos); + h.input_mut().hovered_files.push(Default::default()); + step(h); + let preview = h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview"); + h.input_mut().hovered_files.clear(); + step(h); + preview +} + +#[test] +fn floating_panel_blocks_os_drop_and_hover_but_uncovered_timeline_still_accepts() { + for scale in [1.0, 2.0] { + let mut h = harness(); + h.ctx.set_pixels_per_point(scale); + for _ in 0..4 { + step(&mut h); + } + let at = point_on_row(&h, "timeline.track.V1.target"); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + let project = h.state().session.project.clone(); + let covered_preview = hover_at(&mut h, at); + assert!(std::sync::Arc::ptr_eq(&project, &h.state().session.project), "hover does not edit the project"); + let items_before = h.state().session.project.items.len(); + drop_at(&mut h, at, &still(&format!("drop_covered_{scale}.png"))); + assert_eq!(h.state().session.project.items.len(), items_before + 1); + assert_eq!(clip_counts(&h), (0, 0), "a covered Timeline must not be edited"); + assert!(!covered_preview, "a covered Timeline must not preview a placement"); + float_panel(&mut h, "Info", [5.0, 5.0, 320.0, 200.0]); + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still(&format!("drop_uncovered_{scale}.png"))); + assert_eq!(clip_counts(&h), (1, 0)); + } +} + +#[test] +fn floating_timeline_accepts_once_and_another_float_can_occlude_it() { + let mut h = harness(); + float_panel(&mut h, "Timeline", [300.0, 180.0, 950.0, 640.0]); + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_floating_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + assert!(!hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_occluded_floating_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); +} + +#[test] +fn offscreen_float_uses_rendered_clamp_and_maximized_timeline_ignores_hidden_floats() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + // The very wide offscreen float is clamped back to x=0, over this row. + float_panel(&mut h, "Info", [-5000.0, at.y - 60.0, 1500.0, 200.0]); + let visible = control_rect(&h, "panel.Info"); + assert!(visible.contains(at), "rendered={visible:?}, drop={at:?}, floats={:?}", h.state().ui.floating); + assert!(!hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_clamped_occlusion.png")); + assert_eq!(clip_counts(&h), (0, 0)); + h.state_mut().ui.keys.maximized = Some(filmcraft_ui_egui::dock::PanelKind::Timeline); + for _ in 0..4 { + step(&mut h); + } + let at = point_on_row(&h, "timeline.track.V1.target"); + assert!(hover_at(&mut h, at)); + drop_at(&mut h, at, &still("drop_maximized_timeline.png")); + assert_eq!(clip_counts(&h), (1, 0)); +} + +#[test] +fn os_cursor_hook_selects_destination_when_egui_pointer_has_no_motion() { + let mut h = harness(); + let at = point_on_row(&h, "timeline.track.V1.target"); + float_panel(&mut h, "Info", [at.x - 20.0, at.y - 60.0, 320.0, 200.0]); + // Physical pixels at 1x, with the synthetic viewport origin fixed at zero. + h.state_mut().hooks.cursor_screen_position = Some(Box::new(move || Some((at.x.round() as i32, at.y.round() as i32)))); + if let Some(viewport) = h.input_mut().viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.inner_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1600.0, 980.0))); + } + h.input_mut().hovered_files.push(Default::default()); + step(&mut h); + assert!(!h.state().auto.elements.iter().any(|element| element.id == "timeline.fileDropPreview")); + h.input_mut().hovered_files.clear(); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_covered.png")))); + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (0, 0)); + h.state_mut().ui.floating.clear(); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(still("drop_os_hook_visible.png")))); + step(&mut h); + h.input_mut().dropped_files.clear(); + step(&mut h); + assert_eq!(clip_counts(&h), (1, 0)); +} diff --git a/crates/ui-egui/tests/ui_control_input.rs b/crates/ui-egui/tests/ui_control_input.rs new file mode 100644 index 00000000..773b451d --- /dev/null +++ b/crates/ui-egui/tests/ui_control_input.rs @@ -0,0 +1,277 @@ +//! Control-only validation must not share keyboard ownership with native computer use. + +use std::sync::{ + Arc, + mpsc::{Sender, channel}, +}; + +use egui::{Event, Id, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + control::ControlRequest, + dock::{DockNode, PanelKind, SplitSize}, +}; +use serde_json::{Value, json}; + +struct Driver { + h: Harness<'static, FilmcraftApp>, + tx: Sender, +} + +impl Driver { + fn new(isolated: bool) -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session); + let app = if isolated { app.with_control_input_only(rx) } else { app.with_control(rx) }; + let mut driver = Self { h: Harness::builder().with_size(vec2(1400.0, 900.0)).build_eframe(move |_| app), tx }; + driver.frames(4, true); + driver + } + + fn frames(&mut self, count: usize, native_focused: bool) { + for _ in 0..count { + let ctx = self.h.ctx.clone(); + let mut raw = std::mem::take(self.h.input_mut()); + raw.focused = native_focused; + if let Some(viewport) = raw.viewports.get_mut(&egui::ViewportId::ROOT) { + viewport.focused = Some(native_focused); + } + eframe::App::raw_input_hook(self.h.state_mut(), &ctx, &mut raw); + *self.h.input_mut() = raw; + self.h.step(); + assert!(self.h.state().ui_error.is_none()); + } + } + + fn ok(&mut self, method: &str, params: Value, native_focused: bool) -> Value { + let (request, reply) = ControlRequest::new(method, params); + self.tx.send(request).unwrap(); + for _ in 0..60 { + self.frames(1, native_focused); + if let Ok(result) = reply.try_recv() { + assert_eq!(result["ok"], true, "{result}"); + self.frames(2, native_focused); + return result["result"].clone(); + } + } + panic!("no control reply to {method}"); + } +} + +fn native_key(h: &mut Harness<'_, FilmcraftApp>, key: Key) { + for pressed in [true, false] { + // The backend supplies native events before raw_input_hook. Harness::event would + // instead append them after that hook and bypass the input boundary under test. + h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE }); + } +} + +#[test] +fn native_j_cannot_start_reverse_playback_during_control_only_editing() { + let mut d = Driver::new(true); + d.ok("engine.execute", json!({"command":"playhead.set","params":{"seconds":1}}), true); + let before = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true); + let (request, reply) = ControlRequest::new("ui.key", json!({"key":"K","command":true})); + d.tx.send(request).unwrap(); + d.frames(1, true); // queue the requested command; the native key arrives in its input frame + native_key(&mut d.h, Key::J); + d.frames(4, true); + assert_eq!(reply.try_recv().unwrap()["ok"], true); + assert!(!d.h.state().playback.playing, "native J must not contaminate the automated edit"); + let after = d.ok("engine.execute", json!({"command":"sequence.inspect","params":{}}), true); + assert_eq!(after["playhead"], before["playhead"]); + assert_eq!(after["video"][0]["items"].as_array().unwrap().len(), before["video"][0]["items"].as_array().unwrap().len() + 1); +} + +#[test] +fn background_control_input_resizes_the_focused_divider_without_seeking() { + background_divider(true); +} + +#[test] +fn ordinary_control_input_also_resizes_a_background_divider() { + background_divider(false); +} + +fn background_divider(isolated: bool) { + let mut d = Driver::new(isolated); + d.h.state_mut().ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + d.frames(3, true); + let before_project = d.h.state().session.project.clone(); + let before_playheads = d.h.state().session.state.playheads.clone(); + let divider = Id::new(("dock-gutter", String::new())); + d.h.ctx.memory_mut(|memory| memory.request_focus(divider)); + d.frames(2, true); + d.h.input_mut().events.push(Event::WindowFocused(false)); + d.ok("ui.key", json!({"key":"ArrowRight"}), false); + assert!(matches!(d.h.state().ui.dock, DockNode::Split { size: SplitSize::FixedA(408.0), .. }), "control input must work without native activation"); + assert_eq!(d.h.state().session.state.playheads, before_playheads); + assert!(Arc::ptr_eq(&d.h.state().session.project, &before_project)); + assert!(!d.h.state().playback.playing); +} + +#[test] +fn interactive_control_keeps_native_keyboard_and_menu_input() { + let mut d = Driver::new(false); + native_key(&mut d.h, Key::J); + d.frames(2, true); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); + d.ok("ui.playback", json!({"action":"stop"}), true); + let (tx, rx) = channel(); + d.h.state_mut().command_inbox = Some(rx); + tx.send("playback.reverse".into()).unwrap(); + d.frames(2, true); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); +} + +#[test] +fn native_menu_commands_cannot_contaminate_control_only_validation() { + let mut d = Driver::new(true); + let (tx, rx) = channel(); + d.h.state_mut().command_inbox = Some(rx); + tx.send("playback.reverse".into()).unwrap(); + d.frames(2, true); + assert!(!d.h.state().playback.playing); + // Intentional playback remains reachable through the control command path. + d.ok("engine.execute", json!({"command":"playback.reverse","params":{}}), false); + assert!(d.h.state().playback.playing); + assert_eq!(d.h.state().playback.speed, -1.0); +} + +#[test] +fn isolation_preserves_close_and_screenshot_delivery_without_claiming_os_focus() { + let mut d = Driver::new(true); + let mut raw = egui::RawInput { focused: false, ..Default::default() }; + let viewport = raw.viewports.get_mut(&egui::ViewportId::ROOT).unwrap(); + viewport.focused = Some(false); + viewport.events.push(egui::ViewportEvent::Close); + raw.events = vec![ + Event::Text("unrelated typing".into()), + Event::WindowFocused(false), + Event::Screenshot { + viewport_id: egui::ViewportId::ROOT, + user_data: Default::default(), + image: Arc::new(egui::ColorImage::new([1, 1], vec![egui::Color32::WHITE])), + }, + ]; + raw.hovered_files.push(Default::default()); + #[derive(Debug)] + struct UnrelatedFile; + impl egui::DroppedFile for UnrelatedFile { + fn path(&self) -> &std::path::Path { + std::path::Path::new("/unrelated-input.txt") + } + #[cfg(not(target_arch = "wasm32"))] + fn bytes(&self) -> Result, String> { + Err("must not read unrelated native input".into()) + } + #[cfg(target_arch = "wasm32")] + fn bytes_async(&self) -> std::pin::Pin, String>> + '_>> { + Box::pin(async { Err("must not read unrelated native input".into()) }) + } + } + raw.dropped_files.push(Arc::new(UnrelatedFile)); + let ctx = d.h.ctx.clone(); + eframe::App::raw_input_hook(d.h.state_mut(), &ctx, &mut raw); + assert!(raw.focused); + assert_eq!(raw.viewports[&egui::ViewportId::ROOT].focused, Some(false)); + assert_eq!(raw.viewports[&egui::ViewportId::ROOT].events, vec![egui::ViewportEvent::Close]); + assert!(matches!(raw.events.as_slice(), [Event::Screenshot { .. }, Event::ModifiersChanged(Modifiers::NONE)])); + assert!(raw.hovered_files.is_empty() && raw.dropped_files.is_empty()); + let state = filmcraft_ui_egui::control::inspect(d.h.state(), &ctx); + assert_eq!(state["input"]["nativeWindowFocused"], false); +} + +#[test] +fn focus_reports_real_widget_ownership_and_rejects_bad_targets_without_changing_it() { + let mut d = Driver::new(true); + let focus = d.ok("ui.focus", json!({"id":"tools.Selection"}), false); + assert_eq!(focus["focusRequested"], "tools.Selection"); + let state = d.ok("ui.inspect", json!({}), false); + assert_eq!(state["keyboardFocus"]["widgetId"], focus["widgetId"]); + assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.Selection"])); + assert_eq!(state["keyboardFocus"]["active"], true); + assert_eq!(state["input"]["nativeWindowFocused"], false); + assert_eq!(state["input"]["effectiveWindowFocused"], true); + let focused = d.h.ctx.memory(|memory| memory.focused()); + for params in [ + json!({"id":7}), + json!({"id":"missing"}), + json!({"id":"panel.Tools"}), + json!({"id":"tools.Rolling"}), + json!({"id":"tools.Selection","activate":true}), + json!([]), + ] { + let (request, _) = ControlRequest::new("ui.focus", params); + let ctx = d.h.ctx.clone(); + let filmcraft_ui_egui::control::Outcome::Done(result) = filmcraft_ui_egui::control::handle(d.h.state_mut(), &ctx, &request) else { + panic!("bad focus target must fail immediately"); + }; + assert_eq!(result["ok"], false, "{result}"); + assert_eq!(d.h.ctx.memory(|memory| memory.focused()), focused); + } + d.ok("ui.key", json!({"key":"Tab"}), false); + let state = d.ok("ui.inspect", json!({}), false); + assert_eq!(state["keyboardFocus"]["elementIds"], json!(["tools.TrackSelectForward"])); + assert_ne!(state["keyboardFocus"]["widgetId"], focus["widgetId"]); + assert!(!d.h.state().playback.playing); +} + +#[test] +fn modified_control_click_adds_to_timeline_selection() { + for isolated in [true, false] { + let mut d = Driver::new(isolated); + d.ok("ui.set", json!({"timeline":{"fit":true}}), false); + d.frames(30, false); + let seq = d.h.state().session.active_sequence().unwrap(); + let first = seq.video_tracks[0].items[0].id; + let second = seq.video_tracks[0].items[1].id; + let project = d.h.state().session.project.clone(); + d.ok("engine.execute", json!({"command":"timeline.select","params":{"clips":[first.0]}}), false); + let position = d.ok("ui.timeline.locate", json!({"clip":second.0}), false); + d.ok("ui.click", json!({"x":position["x"],"y":position["y"],"shift":true}), false); + assert!(d.h.state().session.state.selection.contains(&first), "Shift-click must retain the first selected clip (isolated={isolated})"); + assert!(d.h.state().session.state.selection.contains(&second)); + assert!(Arc::ptr_eq(&d.h.state().session.project, &project)); + assert!(!d.h.state().playback.playing); + assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE); + } +} + +#[test] +fn modified_control_drag_keeps_shift_until_release_and_clears_it_afterward() { + let mut d = Driver::new(true); + let (request, reply) = ControlRequest::new( + "ui.drag", + json!({ + "from":{"id":"dock.gutter."},"to":{"id":"dock.gutter.","fy":3.0},"steps":4,"shift":true, + }), + ); + d.tx.send(request).unwrap(); + let mut held_frames = 0; + for _ in 0..60 { + d.frames(1, false); + if d.h.ctx.input(|input| input.pointer.any_down() || input.pointer.any_released()) { + held_frames += 1; + assert!(d.h.ctx.input(|input| input.modifiers.shift), "the synthetic gesture owns Shift throughout press, movement and release"); + } + if let Ok(result) = reply.try_recv() { + assert_eq!(result["ok"], true); + break; + } + } + assert!(held_frames >= 4); + d.frames(3, false); + assert_eq!(d.h.ctx.input(|input| input.modifiers), Modifiers::NONE); +} diff --git a/crates/ui-egui/tests/ui_dock_resize.rs b/crates/ui-egui/tests/ui_dock_resize.rs new file mode 100644 index 00000000..12129c95 --- /dev/null +++ b/crates/ui-egui/tests/ui_dock_resize.rs @@ -0,0 +1,95 @@ +//! Shared pane resizing in the full application, including application shortcut ownership. + +use egui::{Event, Id, Key, Modifiers, PointerButton, Rect, pos2, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::dock::{DockNode, PanelKind, SplitSize}; +use filmcraft_ui_egui::theme::ThemeKind; +use serde_json::json; + +fn step(h: &mut Harness<'_, FilmcraftApp>) { + h.step(); + assert!(h.state().ui_error.is_none(), "the app caught a UI panic: {:?}", h.state().ui_error); +} + +fn frames(h: &mut Harness<'_, FilmcraftApp>, count: usize) { + for _ in 0..count { + step(h); + } +} + +fn snapshots(h: &mut Harness<'_, FilmcraftApp>, directory: Option<&std::path::Path>, phase: &str) { + let Some(directory) = directory else { return }; + std::fs::create_dir_all(directory).unwrap(); + for (name, theme) in [("dark", ThemeKind::Dark), ("medium", ThemeKind::Medium), ("light", ThemeKind::Light)] { + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, theme); + frames(h, 3); + h.render().unwrap().save(directory.join(format!("filmcraft-{phase}-{name}.png"))).unwrap(); + } + let ctx = h.ctx.clone(); + h.state_mut().set_theme(&ctx, ThemeKind::Dark); + frames(h, 3); +} + +fn first_size(h: &Harness<'_, FilmcraftApp>) -> f32 { + match h.state().ui.dock { + DockNode::Split { size: SplitSize::FixedA(value), .. } => value, + ref other => panic!("fixed-size mode changed: {other:?}"), + } +} + +#[test] +fn focused_dock_resize_owns_arrows_and_keeps_pointer_dragging() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let app = FilmcraftApp::new(session); + // Opt in to GPU snapshots while the ordinary test remains headless and renderer-independent. + let directory = std::env::var_os("CRAFT_UI_PANES_SNAPSHOT_DIR").map(std::path::PathBuf::from); + let mut builder = Harness::builder().with_size(vec2(1400.0, 900.0)).with_max_steps(10_000); + if directory.is_some() { + builder = builder.wgpu(); + } + let mut h = builder.build_eframe(move |_| app); + frames(&mut h, 4); + snapshots(&mut h, directory.as_deref(), "workspace"); + h.state_mut().ui.focused = PanelKind::Timeline; + h.state_mut().ui.dock = DockNode::Split { + vertical: false, + size: SplitSize::FixedA(400.0), + a: Box::new(DockNode::Tabs { panels: vec![PanelKind::Project], active: 0 }), + b: Box::new(DockNode::Tabs { panels: vec![PanelKind::Timeline], active: 0 }), + }; + frames(&mut h, 3); + let before_project = h.state().session.project.clone(); + let before_playheads = h.state().session.state.playheads.clone(); + let id = Id::new(("dock-gutter", String::new())); + h.ctx.memory_mut(|memory| memory.request_focus(id)); + frames(&mut h, 2); + assert_eq!(h.ctx.memory(|memory| memory.focused()), Some(id)); + snapshots(&mut h, directory.as_deref(), "focused-resize"); + h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + h.event(Event::Key { key: Key::ArrowRight, physical_key: None, pressed: false, repeat: false, modifiers: Modifiers::NONE }); + step(&mut h); + assert_eq!(first_size(&h), 408.0); + assert_eq!(h.state().session.state.playheads, before_playheads, "the Timeline shortcut must not seek"); + assert!(std::sync::Arc::ptr_eq(&h.state().session.project, &before_project)); + + frames(&mut h, 2); + let element = h.state().auto.find("dock.gutter.").unwrap(); + let from = Rect::from_min_size(pos2(element.rect[0], element.rect[1]), vec2(element.rect[2], element.rect[3])).center(); + h.event(Event::PointerMoved(from)); + step(&mut h); + h.event(Event::PointerButton { pos: from, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + step(&mut h); + for offset in [5.0, 10.0, 15.0, 20.0] { + h.event(Event::PointerMoved(from + vec2(offset, 0.0))); + step(&mut h); + } + h.event(Event::PointerButton { pos: from + vec2(20.0, 0.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + step(&mut h); + assert_eq!(first_size(&h), 428.0); + assert_eq!(h.state().session.state.playheads, before_playheads); +} diff --git a/crates/ui-egui/tests/ui_keyboard_focus.rs b/crates/ui-egui/tests/ui_keyboard_focus.rs new file mode 100644 index 00000000..8685d493 --- /dev/null +++ b/crates/ui-egui/tests/ui_keyboard_focus.rs @@ -0,0 +1,107 @@ +//! Keyboard traversal reaches actual controls and preserves document/playback state. + +use std::sync::Arc; + +use egui::{Event, Id, Key, Modifiers, vec2}; +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::{ + FilmcraftApp, + dock::{DockNode, PanelKind}, + state::Tool, +}; +use serde_json::json; + +fn key(h: &mut Harness<'_, FilmcraftApp>, key: Key, modifiers: Modifiers) { + for pressed in [true, false] { + h.event(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }); + h.step(); + } + h.run_steps(2); + assert!(h.state().ui_error.is_none()); +} + +#[test] +fn tab_traversal_and_space_select_only_the_focused_tool_with_visible_focus() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let project = session.project.clone(); + let playheads = session.state.playheads.clone(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app); + h.run_steps(4); + h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Tools], active: 0 }; + h.run_steps(3); + let first = Id::new(("tool", "Selection".to_string())); + let second = Id::new(("tool", "TrackSelectForward".to_string())); + let third = Id::new(("tool", "Ripple".to_string())); + for _ in 0..128 { + key(&mut h, Key::Tab, Modifiers::NONE); + if h.ctx.memory(|m| m.focused()) == Some(first) { + break; + } + } + assert_eq!(h.ctx.memory(|m| m.focused()), Some(first), "Tab must reach the first tool"); + key(&mut h, Key::Tab, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + key(&mut h, Key::Tab, Modifiers::NONE); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(third)); + key(&mut h, Key::Tab, Modifiers::SHIFT); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + assert_eq!(h.state().ui.tool, Tool::Selection, "traversal does not activate controls"); + key(&mut h, Key::Space, Modifiers::NONE); + assert_eq!(h.state().ui.tool, Tool::TrackSelectForward); + assert!(!h.state().playback.playing, "Space belongs to the focused tool"); + assert_eq!(h.state().session.state.playheads, playheads); + assert!(Arc::ptr_eq(&h.state().session.project, &project)); + let response = h.ctx.read_response(second).unwrap(); + assert!( + h.output().shapes.iter().any(|shape| matches!(&shape.shape, + egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0) + && r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)), + "the focused custom-painted tool needs a visible focus outline" + ); +} + +#[test] +fn dock_tab_traversal_and_enter_change_the_visible_panel_without_editing() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let project = session.project.clone(); + let playheads = session.state.playheads.clone(); + let app = FilmcraftApp::new(session); + let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(move |_| app); + h.run_steps(4); + h.state_mut().ui.show_header = false; + h.state_mut().ui.show_status_bar = false; + h.state_mut().ui.dock = DockNode::Tabs { panels: vec![PanelKind::Project, PanelKind::Effects], active: 0 }; + h.run_steps(3); + let first = Id::new(("tab", String::new(), 0usize)); + let second = Id::new(("tab", String::new(), 1usize)); + for _ in 0..128 { + key(&mut h, Key::Tab, Modifiers::NONE); + if h.ctx.memory(|m| m.focused()) == Some(second) { + break; + } + } + assert_eq!(h.ctx.memory(|m| m.focused()), Some(second)); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + let response = h.ctx.read_response(second).unwrap(); + assert!( + h.output().shapes.iter().any(|shape| matches!(&shape.shape, + egui::Shape::Rect(r) if r.rect == response.rect.shrink(2.0) + && r.stroke.color == h.state().tokens.focus && r.stroke.width > 0.0)), + "the focused dock tab needs a visible focus outline" + ); + key(&mut h, Key::Space, Modifiers::NONE); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 1, .. })); + assert_eq!(h.state().ui.focused, PanelKind::Effects); + key(&mut h, Key::Tab, Modifiers::SHIFT); + assert_eq!(h.ctx.memory(|m| m.focused()), Some(first)); + key(&mut h, Key::Enter, Modifiers::NONE); + assert!(matches!(h.state().ui.dock, DockNode::Tabs { active: 0, .. })); + assert_eq!(h.state().ui.focused, PanelKind::Project); + assert!(Arc::ptr_eq(&h.state().session.project, &project)); + assert_eq!(h.state().session.state.playheads, playheads); + assert!(!h.state().playback.playing); +} diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9cf58cf6..16946c0a 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -38,7 +38,8 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | -| `ui.resize`, `ui.focus`, `app.quit` | | `ui.focus` is the only request that activates the app and takes keyboard focus | +| `ui.resize`, `app.quit` | `{width,height}` / `{}` | resize or close the native window | +| `ui.focus` | `{}` / `{id}` | empty parameters activate the native window; a widget ID requests egui focus without OS activation | | `perf.stats` | – | performance counters (also the command `perf.stats`, so `engine.execute` and MCP `command_run` reach it; headless sessions return the engine part). `decode`: GOP-cache requests / hits / `cacheHitRate`, decoder seeks, samples decoded and skipped while catching up, `draftFrames` (decoded in draft mode), `h264Threads` (frame-threading workers per H.264 decoder), `liveDecoders` (sources holding a decoder now), `cacheMB` / `cacheBudgetMB` (decoded frames all sources hold, and the shared budget), `planePoolMB` / `planesReused` (idle buffers of the frame pool, the planes of evicted frames and the compositor's float images, which a standalone export frees when it ends; and buffers taken from it instead of allocated), decoder ms (total and per sample), `framesDecoded`, `hardware` (Settings ▸ Playback ▸ Hardware decoding: `enabled`, pictures from hardware decoders `frames` vs `softwareFrames`, `sessions`, `declined` streams, mid-stream `fallbacks`); `playback`: playing, shown / dropped frames and `dropRate` of the current or last play, resolution, `draftDecode` (Settings ▸ Playback ▸ Draft decoding), `audio` (desktop: sound is mixed 200 ms ahead of the device; device `callbacks`, `underruns` and `missingFrames` played as silence because the mixer fell behind, `minLeadMs` the least sound left buffered); `frames`: frame-worker jobs, cancelled, `requestHitRate`, `decodeMs` / `renderMs` (mean, p50, p95 of the last 256 jobs), queue, render-cost estimate, cache use; `monitor`: the `frame` and `revision` of the picture the Program monitor shows against `playheadFrame` and `projectRevision` (how far a drag or a scrub is ahead of the picture); `ui`: fps and frame ms; `process`: CPU seconds; `media`, `jobs`. Counters are cumulative: diff two readings to measure an interval ([performance.md](performance.md)) | **Selection or explicit targets.** A command that acts on the selection is "not available right now" when @@ -50,7 +51,22 @@ still apply. `engine.commands` and the menus always show the selection's enablem **Focus.** Driving the app never steals the user's keyboard. Started with `--control`, the app opens without activating itself. UI requests and screenshots that need a rendered frame bring the window forward without making it key (macOS `orderFrontRegardless`), so the user's typing keeps going to -the app they are in. Only an explicit `ui.focus` activates FilmCraft. +the app they are in. Only an explicit `ui.focus {}` activates FilmCraft. Injected keys work on +focused widgets even when the native window is in the background. + +`ui.focus {id}` targets an enabled, visible widget registered with a real egui response. It rejects +unknown IDs, non-widget regions and hidden tool aliases. The reply acknowledges the request; +`ui.inspect.keyboardFocus` reports the actual widget ID, matching element IDs, label and active +focus. This is separate from `ui.focused`, which identifies the active panel. Tools, dock tabs, +tab menus and divider handles expose widget IDs through `ui.elements`. Tab/Shift-Tab move widget +focus; Space/Enter activate focused buttons and tabs, with visible focus outlines. + +For unattended validation, start with `--control --control-input-only`. This opt-in mode +accepts widget input through control requests and excludes native keyboard, pointer, file-drop +and menu commands. Window close/quit and screenshots remain available. Ordinary launches and +`--control` alone keep native input enabled. `ui.inspect.input` distinguishes `mode`, the observed +`nativeWindowFocused`, and egui's `effectiveWindowFocused`; effective focus does not activate the +OS window or prove physical-keyboard delivery. Element ids are stable, e.g. `timeline.clip.`, `timeline.track.V1.lock`, `tools.Razor`, `project.item.`, `effects.item.gaussian_blur`, `panel.tab.Timeline`, `program.transport.playback.toggle`. @@ -147,3 +163,26 @@ Annotations, argument checking, errors and export progress / cancellation: [agents.md § Conventions](agents.md#conventions). Time is in ticks (254 016 000 000 per second); commands also accept `seconds`, `frame` or `timecode`. + +## Panel docking commands + +`ui.menu.invoke` and `engine.execute` route these frontend commands: + +| Command | Parameters | Result | +| --- | --- | --- | +| `window.floatPanel` | `{panel?, rect?: [x,y,width,height]}` | Move an existing panel into a floating group. Omitted panel uses the focused panel; omitted rect uses a centered 420×320 group. | +| `window.dockPanel` | `{panel?, anchor, zone?: "center"\|"left"\|"right"\|"top"\|"bottom", before?}` | Move into the anchor's tab group or a side split; `before` names an existing destination tab for precise ordering. Floating destinations accept tabs only. | +| `window.closePanel` | `{panel?}` | Close a docked or floating panel without closing its project documents. | + +Invalid source/target identities and non-finite or out-of-range geometry return an error with +the layout unchanged. `ui.inspect.ui.floating` reports floating groups and their logical-point +rectangles. `panel.drag.` locates each panel's header grip; `panel.float..move` +and `.resize` locate floating geometry handles. Named workspace saves include these groups. + +`window.dockAction` accepts `{action}` using the shared docking action's JSON form. It +covers tab moves and ordering, split and floating geometry, activation, and closing. +For example, `{"action":{"ResizeSplit":{"path":[],"size":{"FixedFirst":360}}}}` +resizes the root split; path entries use `false` for the first child and `true` for +the second. `{"action":{"MoveFloating":{"panel":"Info","rect":[45,65,420,320]}}}` +repositions and resizes the floating group containing Info. Invalid actions leave the +layout unchanged. Pointer gestures dispatch through this same catalog command. diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 631678d8..07623219 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -99,7 +99,7 @@ const SAME_LAYER: &[(&str, &str)] = &[ ]; /// Crates that must not appear below L5 (UI toolkits, windowing, OS audio/menus). -const UI_ONLY: &[&str] = &["egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"]; +const UI_ONLY: &[&str] = &["craft-ui", "egui", "eframe", "egui-wgpu", "winit", "rfd", "cpal", "muda"]; fn short(name: &str) -> &str { name.strip_prefix("filmcraft-").unwrap_or(name) From 62358d8c4ee4b98caace78aad0369dbd84b4e043 Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:21:23 +0200 Subject: [PATCH 038/203] text: the CJK system-font search no longer keeps every probed font file in memory (#769) --- crates/text/src/fonts.rs | 34 ++++++++++++++++++++++++++++++++++ crates/ui-egui/src/cjk.rs | 2 +- crates/ui-egui/src/i18n/mod.rs | 2 +- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 6cceff10..a6c3f444 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -215,6 +215,18 @@ impl Face { pub fn has_char(&self, c: char) -> bool { self.glyph(c).is_some() } + /// Whether the face covers every character of `text`. Unlike [`Face::has_char`], a face whose + /// file is not loaded yet is read into a temporary buffer that is dropped afterwards, so + /// probing many system faces does not keep every font file in memory. + pub fn covers_text(&self, text: &str) -> bool { + if let (FaceData::File(p), None) = (&self.info.data, self.bytes.get()) { + let Ok(data) = std::fs::read(p) else { return false }; + let Ok(font) = FontRef::from_index(&data, self.info.index) else { return false }; + let charmap = font.charmap(); + return text.chars().all(|c| charmap.map(c).is_some_and(|g| g.to_u32() != 0)); + } + text.chars().all(|c| self.has_char(c)) + } /// Height of the glyph's vertical origin above its baseline, in pixels. /// OpenType VORG takes precedence; TrueType uses the ink top plus vmtx's top bearing. pub(crate) fn vertical_origin(&self, gid: u32, px: f32) -> Option { @@ -569,4 +581,26 @@ mod tests { assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew"); assert!(f.has_feature(b"liga") || f.has_feature(b"calt")); } + + #[test] + fn covers_text_does_not_keep_file_bytes() { + let path = std::env::temp_dir().join(format!("filmcraft-covers-text-{}.ttf", std::process::id())); + std::fs::write(&path, INTER_REGULAR).expect("write temp font"); + let info = FaceInfo { + family: "Inter".into(), + style: "Regular".into(), + weight: 400, + italic: false, + index: 0, + data: FaceData::File(path.clone()), + origin: "system", + }; + let f = Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() }; + assert!(f.covers_text("A\u{416}")); + assert!(!f.covers_text("A\u{5d0}")); + assert!(f.bytes.get().is_none(), "probing coverage must not cache the file"); + assert!(f.data().is_some()); + assert!(f.bytes.get().is_some()); + let _ = std::fs::remove_file(&path); + } } diff --git a/crates/ui-egui/src/cjk.rs b/crates/ui-egui/src/cjk.rs index 789ae78b..1ebdf010 100644 --- a/crates/ui-egui/src/cjk.rs +++ b/crates/ui-egui/src/cjk.rs @@ -33,7 +33,7 @@ fn system_font() -> Option> { } filmcraft_text::fonts::scan_system(); let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); - let covers = |f: &filmcraft_text::fonts::Face| SAMPLE.chars().all(|c| f.has_char(c)); + let covers = |f: &filmcraft_text::fonts::Face| f.covers_text(SAMPLE); let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); let preferred = PREFERRED.iter().find_map(|name| faces.iter().filter(|f| f.info.family.eq_ignore_ascii_case(name) && covers(f)).min_by_key(by_weight)); let face = preferred.or_else(|| faces.iter().filter(|f| covers(f)).min_by_key(by_weight))?; diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index db2be192..1cbf5b05 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -247,7 +247,7 @@ pub fn system_japanese_font() -> Option> { FONT.get_or_init(|| { filmcraft_text::fonts::scan_system(); let faces: Vec<_> = filmcraft_text::fonts::all_faces().into_iter().filter(|f| f.info.origin == "system" && !f.info.italic).collect(); - let covers = |f: &filmcraft_text::fonts::Face| JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)); + let covers = |f: &filmcraft_text::fonts::Face| f.covers_text(JAPANESE_SAMPLE); // within a family, the face closest to regular weight let by_weight = |f: &&Arc| f.info.weight.abs_diff(400); let preferred = From 8417e46090d6b39a2ad28dfd8f5ca42f7a7f1abc Mon Sep 17 00:00:00 2001 From: MarekSpodBiedry <73122968+MarekSpodBiedry@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:22:52 +0200 Subject: [PATCH 039/203] Timeline: draw each audio track's own waveform, not track 1's (#770) --- crates/ui-egui/src/panels/menu_dialogs.rs | 3 +- crates/ui-egui/src/panels/monitor_view.rs | 2 +- crates/ui-egui/src/panels/timeline.rs | 128 ++++++++++++++++------ crates/ui-egui/tests/nest_rendering_ui.rs | 2 +- 4 files changed, 96 insertions(+), 39 deletions(-) diff --git a/crates/ui-egui/src/panels/menu_dialogs.rs b/crates/ui-egui/src/panels/menu_dialogs.rs index e3431398..1d35c21e 100644 --- a/crates/ui-egui/src/panels/menu_dialogs.rs +++ b/crates/ui-egui/src/panels/menu_dialogs.rs @@ -199,7 +199,8 @@ pub fn route(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: &Val if let Ok(v) = &r { let mut peaks = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()); for i in v["items"].as_array().cloned().unwrap_or_default() { - peaks.remove(&filmcraft_engine::project::ItemId(i.as_u64().unwrap_or_default())); + let item = filmcraft_engine::project::ItemId(i.as_u64().unwrap_or_default()); + peaks.retain(|(it, _), _| *it != item); } } return Some(r); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index dd3db365..15e6ab6f 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -575,7 +575,7 @@ pub fn waveform( } let mid = area.center().y; p.line_segment([pos2(area.min.x, mid), pos2(area.max.x, mid)], Stroke::new(1.0, Color32::from_white_alpha(30))); - match crate::panels::timeline::request_peaks(app, item) { + match crate::panels::timeline::request_peaks(app, item, 0) { Some(peaks) if !peaks.is_empty() => { let half = area.height() * 0.45; let spp = 256.0 * TICKS_PER_SAMPLE_48K; diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 3bac089b..99dcccc7 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -34,6 +34,9 @@ const MASTER_H: f32 = 34.0; const GAP_FILL: Color32 = Color32::from_gray(224); const SNAP_PX: f32 = 9.0; +/// Waveform peaks are kept per audio stream: (item, index into the media's `audio_streams`). +pub type PeakKey = (ItemId, usize); + /// Transient interaction state. #[derive(Default)] pub struct TlState { @@ -44,8 +47,8 @@ pub struct TlState { pub layout: Option, /// Snap indicator x (screen) this frame. snap_x: Option, - pub peaks: Arc>>>>, - peaks_pending: Arc>>, + pub peaks: Arc>>>>, + peaks_pending: Arc>>, /// Nested sequences have waveforms too, of their mix. Unlike media a sequence changes: this is /// the content key (see [`sequence_audio_key`]) the cached peaks of each sequence were made /// from, and the key worked out for the session revision last seen. @@ -53,7 +56,7 @@ pub struct TlState { seq_keys_seen: HashMap, /// Source peak of each cached peak list (keyed by the list's address), for the waveform /// display gain: scanning the whole source per clip per frame cost more than drawing. - peak_max: HashMap, + peak_max: HashMap, zoom_anchor: Option<(f64, f32)>, } @@ -791,7 +794,8 @@ fn draw_clip( } fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &TrackItem, col: Color32) { - let Some(peaks) = request_peaks(app, it.item) else { return }; + let Some(peaks) = request_peaks(app, it.item, it.audio_stream) else { return }; + let pkey = (it.item, it.audio_stream); let spp = 256.0; let sr = 48_000.0; let zone_h = (body.height() * 0.42).max(8.0); @@ -801,11 +805,11 @@ fn draw_waveform(app: &mut FilmcraftApp, p: &egui::Painter, body: Rect, it: &Tra return; } let key = Arc::as_ptr(&peaks) as usize; - let peak = match app.tl.peak_max.get(&it.item) { + let peak = match app.tl.peak_max.get(&pkey) { Some(&(k, v)) if k == key => v, _ => { let v = peaks.iter().fold(0f32, |m, (a, b)| m.max(a.abs()).max(b.abs())); - app.tl.peak_max.insert(it.item, (key, v)); + app.tl.peak_max.insert(pkey, (key, v)); v } }; @@ -881,17 +885,18 @@ fn request_sequence_peaks(app: &mut FilmcraftApp, item: ItemId) -> Option Option Option Option>> { +/// Min/max peaks (one per 256 samples at 48 kHz) of audio stream `stream` of `src`, `dur` long. +fn source_peaks(src: &dyn filmcraft_media::MediaSource, stream: usize, dur: Tick) -> Vec<(f32, f32)> { + let sr = 48_000u32; + let total = dur.to_units_floor(sr as i64).max(0) as usize; + let mut out = Vec::with_capacity(total / 256 + 1); + let chunk = 48_000 * 4; + let mut s = 0usize; + while s < total { + let n = chunk.min(total - s); + match src.audio_stream(stream, s as i64, n, sr) { + Ok(buf) => { + let ch = buf.channels.first().cloned().unwrap_or_default(); + for c in ch.chunks(256) { + let (lo, hi) = c.iter().fold((0f32, 0f32), |(l, h), v| (l.min(*v), h.max(*v))); + out.push((lo, hi)); + } + } + Err(_) => break, + } + s += n; + } + out +} + +/// Waveform peaks of audio stream `stream` of `item` (a clip's `audio_stream`). +pub(crate) fn request_peaks(app: &mut FilmcraftApp, item: ItemId, stream: usize) -> Option>> { if app.session.project.sequence(item).is_some() { return request_sequence_peaks(app, item); } - if let Some(p) = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()).get(&item) { + let pkey = (item, stream); + if let Some(p) = app.tl.peaks.lock().unwrap_or_else(|e| e.into_inner()).get(&pkey) { return Some(p.clone()); } // No source: try again on a later frame instead of leaving the item pending forever. let src = app.session.source(item)?; { let mut pend = app.tl.peaks_pending.lock().unwrap_or_else(|e| e.into_inner()); - if pend.contains(&item) { + if pend.contains(&pkey) { return None; } - pend.push(item); + pend.push(pkey); } let peaks = app.tl.peaks.clone(); let pending = app.tl.peaks_pending.clone(); let dur = src.info().duration; let run = move || { - let sr = 48_000u32; - let total = dur.to_units_floor(sr as i64).max(0) as usize; - let mut out = Vec::with_capacity(total / 256 + 1); - let chunk = 48_000 * 4; - let mut s = 0usize; - while s < total { - let n = chunk.min(total - s); - match src.audio(s as i64, n, sr) { - Ok(buf) => { - let ch = buf.channels.first().cloned().unwrap_or_default(); - for c in ch.chunks(256) { - let (lo, hi) = c.iter().fold((0f32, 0f32), |(l, h), v| (l.min(*v), h.max(*v))); - out.push((lo, hi)); - } - } - Err(_) => break, - } - s += n; - } - peaks.lock().unwrap_or_else(|e| e.into_inner()).insert(item, Arc::new(out)); - pending.lock().unwrap_or_else(|e| e.into_inner()).retain(|i| *i != item); + let out = source_peaks(&*src, stream, dur); + peaks.lock().unwrap_or_else(|e| e.into_inner()).insert(pkey, Arc::new(out)); + pending.lock().unwrap_or_else(|e| e.into_inner()).retain(|i| *i != pkey); }; #[cfg(not(target_arch = "wasm32"))] std::thread::spawn(run); @@ -2697,6 +2710,49 @@ mod trim_preview_tests { } } + /// A source whose audio streams are different constants (0.1 for stream 0, 0.5 for stream 1). + struct TwoStreams(filmcraft_media::MediaInfo); + + impl filmcraft_media::MediaSource for TwoStreams { + fn info(&self) -> &filmcraft_media::MediaInfo { + &self.0 + } + fn video_frame(&self, _req: filmcraft_media::FrameRequest) -> filmcraft_media::Result> { + Err(filmcraft_media::MediaError::NoStream("video")) + } + fn audio(&self, _start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![vec![0.1; frames]] }) + } + fn audio_stream(&self, stream: usize, start: i64, frames: usize, sample_rate: u32) -> filmcraft_media::Result { + match stream { + 0 => self.audio(start, frames, sample_rate), + 1 => Ok(filmcraft_frame::AudioBuffer { sample_rate, channels: vec![vec![0.5; frames]] }), + _ => Err(filmcraft_media::MediaError::NoStream("audio")), + } + } + } + + #[test] + fn each_audio_stream_gets_its_own_waveform_peaks() { + let info = filmcraft_media::MediaInfo { + name: "two.mkv".into(), + kind: filmcraft_media::MediaKind::Movie, + duration: Tick(TICKS_PER_SECOND), + video: None, + audio_streams: Vec::new(), + container: "mkv".into(), + start_timecode: None, + file_size: None, + }; + let src = TwoStreams(info); + let first = source_peaks(&src, 0, Tick(TICKS_PER_SECOND)); + let second = source_peaks(&src, 1, Tick(TICKS_PER_SECOND)); + assert!(!first.is_empty() && first.len() == second.len()); + assert!(first.iter().all(|&(_, hi)| (hi - 0.1).abs() < 1e-6), "stream 0 is the quiet one"); + assert!(second.iter().all(|&(_, hi)| (hi - 0.5).abs() < 1e-6), "stream 1 is its own audio, not stream 0 again"); + assert!(source_peaks(&src, 2, Tick(TICKS_PER_SECOND)).is_empty(), "a missing stream has no peaks"); + } + #[test] fn trimming_the_music_keeps_its_waveform_in_place() { let (mut app, seq) = demo(); diff --git a/crates/ui-egui/tests/nest_rendering_ui.rs b/crates/ui-egui/tests/nest_rendering_ui.rs index 6ddfe6ce..e75837f7 100644 --- a/crates/ui-egui/tests/nest_rendering_ui.rs +++ b/crates/ui-egui/tests/nest_rendering_ui.rs @@ -71,7 +71,7 @@ impl Driver { fn peaks_when(&mut self, item: u64, ready: impl Fn(&[(f32, f32)]) -> bool) -> Vec<(f32, f32)> { for _ in 0..400 { self.frames(1); - let got = self.app().tl.peaks.lock().unwrap().get(&ItemId(item)).cloned(); + let got = self.app().tl.peaks.lock().unwrap().get(&(ItemId(item), 0)).cloned(); if let Some(p) = got.filter(|p| ready(p)) { return p.to_vec(); } From 6aca7c1798a9b7bd2c724f532c2ae46a6b698c3a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:39:57 +0200 Subject: [PATCH 040/203] #781: cargo xtask parity measures menu coverage Problem: the menu parity figure in docs/target-app-parity.md came from an ad hoc label-matching script over string literals; nothing repeatable measured the real menu bar (docs/gaps.md G7). Fix: `cargo xtask parity` dumps the shipped menu bar (new `menus::default_menu_items`, deterministic: English, default prefs, built-in workspaces; printed by `examples/menu_tree.rs`) and compares it with a reference list of menu paths, by default the local, never-committed `plan/premiere/menus.json` (any JSON of paths via --reference; parsing is depth- and size-capped). Presence and placement are reported separately (same path, elsewhere, missing); app/Help/recent/account/macOS items are out of scope. Writes a markdown checklist (target/parity/ or --out) and --min fails below a presence threshold. Without a reference it prints our inventory only. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/examples/menu_tree.rs | 26 ++ crates/ui-egui/src/menus.rs | 32 ++ crates/ui-egui/src/panels/workspaces.rs | 18 +- docs/gaps.md | 17 +- docs/target-app-parity.md | 4 +- xtask/src/main.rs | 10 +- xtask/src/parity.rs | 507 ++++++++++++++++++++++++ 7 files changed, 602 insertions(+), 12 deletions(-) create mode 100644 crates/ui-egui/examples/menu_tree.rs create mode 100644 xtask/src/parity.rs diff --git a/crates/ui-egui/examples/menu_tree.rs b/crates/ui-egui/examples/menu_tree.rs new file mode 100644 index 00000000..62610ad1 --- /dev/null +++ b/crates/ui-egui/examples/menu_tree.rs @@ -0,0 +1,26 @@ +//! Print FilmCraft's menu bar as it ships (English, default preferences) as JSON: one object per +//! item with its `id`, `label` and menu `path`. `cargo xtask parity` reads this to measure menu +//! coverage against a reference list. +//! +//! ```sh +//! cargo run -p filmcraft-ui-egui --example menu_tree +//! ``` + +use std::process::ExitCode; + +use serde_json::json; + +fn main() -> ExitCode { + let items: Vec = + filmcraft_ui_egui::menus::default_menu_items().into_iter().map(|it| json!({"id": it.id, "label": it.label, "path": it.path})).collect(); + match serde_json::to_string_pretty(&items) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("menu_tree: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 358a6530..c0095c6e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -636,6 +636,16 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { v } +/// The menu bar as it ships, without a running app: English labels, default preferences, the +/// built-in workspaces, nothing checked. Deterministic, so `cargo xtask parity` can measure it +/// (`examples/menu_tree.rs` prints it as JSON). +pub fn default_menu_items() -> Vec { + let session = filmcraft_engine::Session::default(); + let mut v = menu_items_for(&session); + crate::panels::workspaces::default_menu(&mut v, &session); + v +} + /// Menu entries with their live shortcuts and enablement. pub fn menu_items_for(session: &filmcraft_engine::Session) -> Vec { let mut out = Vec::new(); @@ -869,3 +879,25 @@ mod parse_shortcut_tests { assert_eq!(parse_shortcut("+").map(|(_, k)| k), Some(egui::Key::Plus)); } } + +#[cfg(test)] +mod tests { + use super::*; + + /// What `cargo xtask parity` measures: every item sits under a top-level menu, and Window ▸ + /// Workspaces lists the built-in workspaces before the layout commands, none checked. + #[test] + fn default_menu_items_are_the_shipped_menu_bar() { + let items = default_menu_items(); + assert!(items.len() > 300, "{} items", items.len()); + assert!(items.iter().all(|it| it.path.first().is_some_and(|top| MENUS.contains(&top.as_str())))); + let ws: Vec<&MenuItem> = items.iter().filter(|it| it.path == ["Window", "Workspaces"]).collect(); + for name in crate::dock::WORKSPACES { + assert!(ws.iter().any(|it| it.label == name && it.checked.is_none()), "no workspace {name}"); + } + let editing = ws.iter().position(|it| it.label == "Editing"); + let reset = ws.iter().position(|it| it.id == "window.workspace.reset"); + assert!(matches!((editing, reset), (Some(e), Some(r)) if e < r), "workspaces come before the layout commands"); + assert!(items.iter().any(|it| it.id == "file.save" && it.path == ["File"])); + } +} diff --git a/crates/ui-egui/src/panels/workspaces.rs b/crates/ui-egui/src/panels/workspaces.rs index 69d52944..502e3091 100644 --- a/crates/ui-egui/src/panels/workspaces.rs +++ b/crates/ui-egui/src/panels/workspaces.rs @@ -129,21 +129,31 @@ fn delete(app: &mut FilmcraftApp, name: &str) -> Result { /// Rebuild the Window ▸ Workspaces submenu: every workspace (the current one checked), then the /// layout commands in table order. pub fn menu(app: &FilmcraftApp, items: &mut Vec) { + splice(items, &app.workspaces, &app.session, Some(&app.ui.workspace), |n| app.ui.language.tr(n).to_string()); +} + +/// The same submenu without a running app: the built-in workspaces in English, none checked +/// (`menus::default_menu_items`). +pub fn default_menu(items: &mut Vec, session: &filmcraft_engine::Session) { + splice(items, &dock::WorkspacePrefs::default(), session, None, str::to_string); +} + +fn splice(items: &mut Vec, prefs: &dock::WorkspacePrefs, session: &filmcraft_engine::Session, current: Option<&str>, tr: impl Fn(&str) -> String) { let is_ws = |it: &MenuItem| it.path.len() == 2 && it.path[0] == "Window" && it.path[1] == "Workspaces"; let Some(at) = items.iter().position(is_ws) else { return }; let actions: Vec = items.iter().filter(|it| is_ws(it) && it.id.strip_prefix("window.workspace.").is_some_and(|k| ACTIONS.contains(&k))).cloned().collect(); items.retain(|it| !is_ws(it)); - let mut sub: Vec = dock::names(&app.workspaces) + let mut sub: Vec = dock::names(prefs) .into_iter() .map(|n| { let id = format!("window.workspace.{}", slug(&n)); MenuItem { - label: app.ui.language.tr(&n).to_string(), + label: tr(&n), path: vec!["Window".into(), "Workspaces".into()], - shortcut: app.session.shortcuts.primary(&id), + shortcut: session.shortcuts.primary(&id), enabled: true, - checked: Some(n == app.ui.workspace), + checked: current.map(|c| n == c), id, } }) diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..c57e02b6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -122,12 +122,17 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G7. No measured parity tool in CI -- **Missing:** `cargo xtask parity` (menus, commands, effects, transitions, panels, preferences, - formats against `plan/premiere/` snapshots), presence and fidelity reported separately (exact, - approximate, stub), generating `docs/parity-checklist.md`. -- **Evidence:** the 92% menu figure in this pass came from an ad hoc label-matching script. -- **Estimate:** 10–20 h (the menu snapshot is local-only, so the tool needs a committed, Adobe-free - list of menu paths or runs only where `plan/` exists). +- **Done:** `cargo xtask parity` measures menus (#781): our shipped menu bar (the tree the app + builds, `examples/menu_tree.rs`) against a reference list of menu paths, by default the local + `plan/premiere/menus.json`; presence and placement reported separately (same path, elsewhere, + missing), app / Help / recent / account / macOS items out of scope, a markdown checklist, and + `--min` to fail below a presence threshold. Without a reference it prints our inventory only. +- **Missing:** the other areas (commands, effects, transitions, panels, preferences, formats), + fidelity tiers (exact, approximate, stub), a CI job, and a committed `docs/parity-checklist.md`. +- **Evidence:** the 92% menu figure in this pass came from an ad hoc label-matching script; it has + not yet been re-measured with the tool. +- **Estimate:** 8–17 h left (the menu snapshot is local-only, so CI needs a committed, Adobe-free + list of menu paths for `--reference`, or the check runs only where `plan/` exists). ## G8. UI fidelity: monitor handles, Effect Controls, docking diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 29b2f706..2e3f7fb8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -45,7 +45,9 @@ issues now document. That is new evidence, not drift: see [Calibration](#calibra Audio Clip / Track Effect Editor, Sequence Index, AI Models and Collaboration settings, UXP / Exchange plug-in entries. A label match is presence only, and can over-count (a label that exists in a different place); it is a lower bound on misses, not proof of behaviour. The script - is ad hoc; turning it into `cargo xtask parity` is gap [G7](gaps.md#g7-no-measured-parity-tool-in-ci). + was ad hoc; `cargo xtask parity` now measures the shipped menu bar against the snapshot, + reporting presence and placement separately (not yet re-run for this figure); the other areas + are gap [G7](gaps.md#g7-no-measured-parity-tool-in-ci). - **Effects (measured by tests).** `premiere_26_video_effects_catalogue`, `premiere_audio_set_is_complete_and_foldered` and `premiere_26_transition_tree` check the catalogue against Premiere's Effects panel: video effects 93/93 (+Legacy, Obsolete), audio diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 648f217b..1ea276af 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -11,10 +11,14 @@ //! - `fixtures [crate…]`: pre-generate the ffmpeg fixture matrix of the oracle tests (runs each //! crate's ignored `generate_fixtures` test, i.e. the same generators the tests use) and print //! what was made, reused or skipped. +//! - `parity [--reference FILE] [--out FILE] [--min PERCENT]`: measured menu parity of our shipped +//! menu bar against a reference list of menu paths (default: the local `plan/premiere/menus.json`), +//! presence and placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). //! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. mod ico; +mod parity; mod version; use std::process::{Command, ExitCode}; @@ -487,12 +491,16 @@ fn main() -> ExitCode { let rest: Vec = std::env::args().skip(2).collect(); version::run(&root(), &rest.iter().map(String::as_str).collect::>()) } + "parity" => { + let rest: Vec = std::env::args().skip(2).collect(); + parity::run(&root(), &rest.iter().map(String::as_str).collect::>()) + } "ico" => { let rest: Vec = std::env::args().skip(2).collect(); ico::run(&rest.iter().map(String::as_str).collect::>()) } _ => Err( - "usage: cargo xtask " + "usage: cargo xtask " .into(), ), }; diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs new file mode 100644 index 00000000..e11a570f --- /dev/null +++ b/xtask/src/parity.rs @@ -0,0 +1,507 @@ +//! `cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT]`: measured +//! menu parity (docs/gaps.md G7). +//! +//! Compares FilmCraft's shipped menu bar (`crates/ui-egui/examples/menu_tree.rs`, or a saved dump +//! of it given with `--ours`) with a reference list of menu paths, and reports presence and +//! placement separately: a reference item is *same place* when we have its label at the same menu +//! path, *elsewhere* when we have the label under another path, and *missing* otherwise. A label +//! match is presence only, not behaviour. +//! +//! The reference defaults to the maintainers' local menu snapshot `plan/premiere/menus.json` +//! (gitignored, never committed). Without one the tool prints our own menu inventory and measures +//! nothing. The reference may be any JSON of menu paths: an array of paths (`["File", "New", +//! "Sequence…"]`) or of `"File > New > Sequence…"` strings, a tree of nodes (`{"name": …, +//! "children" | "items" | "submenu": […]}`), or nested objects keyed by label. App, Apple and Help +//! menus, recent-file lists, account / cloud items and macOS system items are counted out of scope. +//! +//! The report goes to `target/parity/parity-checklist.md` (or `--out`); `--min` fails when presence +//! is below the given percentage. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use serde_json::Value; + +/// Deepest menu nesting accepted from a reference file. +const MAX_DEPTH: usize = 16; +/// Most menu items accepted from a reference file. +const MAX_ITEMS: usize = 20_000; +/// Keys holding a node's label, and its children. +const LABEL_KEYS: [&str; 3] = ["name", "title", "label"]; +const CHILD_KEYS: [&str; 6] = ["children", "items", "submenu", "menu", "menuItems", "menu_items"]; +/// Keys of a wrapper object around the whole menu bar. +const WRAPPER_KEYS: [&str; 5] = ["menus", "menuBar", "menu_bar", "menubar", "items"]; + +struct Args { + reference: Option, + ours: Option, + out: Option, + min: Option, +} + +fn parse_args(args: &[&str]) -> Result { + let mut a = Args { reference: None, ours: None, out: None, min: None }; + let mut it = args.iter(); + while let Some(flag) = it.next() { + let mut value = || it.next().map(|v| v.to_string()).ok_or_else(|| format!("{flag} needs a value")); + match *flag { + "--reference" => a.reference = Some(value()?.into()), + "--ours" => a.ours = Some(value()?.into()), + "--out" => a.out = Some(value()?.into()), + "--min" => { + let v = value()?; + let pct: f64 = v.parse().map_err(|_| format!("--min {v}: not a number"))?; + if !(0.0..=100.0).contains(&pct) { + return Err(format!("--min {v}: must be 0–100")); + } + a.min = Some(pct); + } + other => { + return Err(format!( + "parity: unknown argument {other} (usage: cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" + )); + } + } + } + Ok(a) +} + +pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { + let args = parse_args(args)?; + let ours_json = match &args.ours { + Some(p) => std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?, + None => dump_ours()?, + }; + let ours = our_items(&serde_json::from_str(&ours_json).map_err(|e| format!("our menu dump: {e}"))?)?; + let default_ref = root.join("plan/premiere/menus.json"); + let reference = match &args.reference { + Some(p) => Some(p.clone()), + None => default_ref.exists().then_some(default_ref), + }; + let Some(reference) = reference else { + print!("{}", inventory(&ours)); + println!("\nno reference menu list (plan/premiere/menus.json, or --reference FILE): menu parity not measured"); + return match args.min { + Some(_) => Err("--min needs a reference menu list".into()), + None => Ok(()), + }; + }; + let text = std::fs::read_to_string(&reference).map_err(|e| format!("{}: {e}", reference.display()))?; + let value: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", reference.display()))?; + let paths = reference_paths(&value)?; + let name = reference.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let report = compare(&paths, &ours); + let out = args.out.unwrap_or_else(|| crate::target_dir().join("parity/parity-checklist.md")); + if let Some(dir) = out.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + std::fs::write(&out, report.markdown(&name, ours.len())).map_err(|e| format!("{}: {e}", out.display()))?; + println!("{}", report.summary()); + println!("report: {}", out.display()); + match args.min { + Some(min) if report.presence() < min => Err(format!("menu presence {:.1}% is below --min {min}%", report.presence())), + _ => Ok(()), + } +} + +/// Our menu bar as JSON, from the `menu_tree` example. +fn dump_ours() -> Result { + let mut cmd = Command::new(env!("CARGO")); + cmd.args(["run", "-q", "-p", "filmcraft-ui-egui", "--example", "menu_tree"]).stderr(Stdio::inherit()); + let out = cmd.output().map_err(|e| format!("menu_tree: {e}"))?; + if !out.status.success() { + return Err(format!("menu_tree failed: {}", out.status)); + } + String::from_utf8(out.stdout).map_err(|e| format!("menu_tree: {e}")) +} + +/// Full paths (menu path + label) of our menu items, from the `menu_tree` dump. +fn our_items(v: &Value) -> Result>, String> { + let items = v.as_array().ok_or("our menu dump is not an array")?; + Ok(items + .iter() + .filter_map(|it| { + let label = it.get("label")?.as_str()?; + let mut path: Vec = it.get("path")?.as_array()?.iter().filter_map(|p| p.as_str().map(str::to_string)).collect(); + path.push(label.to_string()); + (path.len() >= 2).then_some(path) + }) + .collect()) +} + +/// Item counts per top-level menu, for runs without a reference. +fn inventory(ours: &[Vec]) -> String { + let mut per: BTreeMap<&str, usize> = BTreeMap::new(); + for p in ours { + if let Some(top) = p.first() { + *per.entry(top.as_str()).or_default() += 1; + } + } + let mut s = format!("FilmCraft menu bar: {} items\n", ours.len()); + for (top, n) in per { + s.push_str(&format!(" {top:<22} {n}\n")); + } + s +} + +/// Leaf menu paths (top-level menu first, item label last) of a reference file. +fn reference_paths(v: &Value) -> Result>, String> { + let mut root = v; + if let Value::Object(o) = v + && label_of(o).is_none() + && let Some(inner) = WRAPPER_KEYS.iter().find_map(|k| o.get(*k).filter(|c| c.is_array() || c.is_object())) + { + root = inner; + } + let mut out = Vec::new(); + walk(root, &[], 0, &mut out)?; + Ok(out) +} + +fn label_of(o: &serde_json::Map) -> Option<&str> { + LABEL_KEYS.iter().find_map(|k| o.get(*k)?.as_str()) +} + +/// A separator or placeholder rather than a menu item. +fn is_separator(label: &str) -> bool { + let l = label.trim(); + l.is_empty() || l.chars().all(|c| matches!(c, '-' | '—' | '–' | '_')) || l.eq_ignore_ascii_case("missing value") || l.eq_ignore_ascii_case("separator") +} + +fn push_leaf(prefix: &[String], label: &str, out: &mut Vec>) -> Result<(), String> { + if is_separator(label) || prefix.iter().any(|p| is_separator(p)) { + return Ok(()); + } + if out.len() >= MAX_ITEMS { + return Err(format!("reference has more than {MAX_ITEMS} menu items")); + } + let mut path = prefix.to_vec(); + path.push(label.trim().to_string()); + if path.len() >= 2 { + out.push(path); + } + Ok(()) +} + +fn split_path(s: &str) -> Option> { + let parts: Vec = s.replace('\u{a0}', " ").split(['>', '▸']).map(|p| p.trim().to_string()).collect(); + (parts.len() >= 2 && parts.iter().all(|p| !p.is_empty())).then_some(parts) +} + +fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) -> Result<(), String> { + if depth > MAX_DEPTH { + return Err(format!("reference menus are nested deeper than {MAX_DEPTH} levels")); + } + let with = |label: &str| { + let mut p = prefix.to_vec(); + p.push(label.trim().to_string()); + p + }; + match v { + Value::String(s) => match split_path(s).filter(|_| prefix.is_empty()) { + Some(parts) => match parts.split_last() { + Some((label, menus)) => push_leaf(menus, label, out), + None => Ok(()), + }, + None => push_leaf(prefix, s, out), + }, + Value::Array(a) => { + for el in a { + match el { + Value::Array(path) if !path.is_empty() && path.iter().all(Value::is_string) => { + let parts: Vec = prefix.iter().cloned().chain(path.iter().filter_map(|p| p.as_str().map(str::to_string))).collect(); + if let Some((label, menus)) = parts.split_last() { + push_leaf(menus, label, out)?; + } + } + _ => walk(el, prefix, depth + 1, out)?, + } + } + Ok(()) + } + Value::Object(o) => match label_of(o) { + Some(label) => { + let children = CHILD_KEYS.iter().find_map(|k| o.get(*k).filter(|c| has_entries(c))); + match children { + Some(c) if !is_separator(label) => walk(c, &with(label), depth + 1, out), + Some(_) => Ok(()), + None => push_leaf(prefix, label, out), + } + } + None => { + for (k, val) in o { + if has_entries(val) { + walk(val, &with(k), depth + 1, out)?; + } else { + push_leaf(prefix, k, out)?; + } + } + Ok(()) + } + }, + _ => Ok(()), + } +} + +fn has_entries(v: &Value) -> bool { + match v { + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + _ => false, + } +} + +/// A label compared case-insensitively, without ellipsis, with `&` as "and" and one space. +fn norm(label: &str) -> String { + let l = label.to_lowercase().replace('…', "").replace("...", "").replace(['’', '‘'], "'").replace('&', " and "); + l.split_whitespace().collect::>().join(" ") +} + +/// Why a reference item is not counted, if it isn't. +fn out_of_scope(path: &[String]) -> Option<&'static str> { + let (top, label) = (norm(path.first()?), norm(path.last()?)); + if top == "apple" || top == "help" || top.contains("premiere") { + return Some("app, Apple and Help menus"); + } + let parents = path.get(..path.len().saturating_sub(1)).unwrap_or_default(); + if parents.iter().skip(1).any(|p| norm(p).starts_with("open recent") || norm(p).starts_with("recent")) { + return Some("recent-file lists"); + } + if ["adobe", "creative cloud", "sign in", "sign out", "log in", "log out"].iter().any(|w| label.contains(w)) { + return Some("account and cloud items"); + } + if ["autofill", "start dictation", "emoji and symbols", "writing tools", "services"].iter().any(|w| label.starts_with(w)) { + return Some("macOS system items"); + } + None +} + +enum Placement { + Same, + Elsewhere(Vec), + Missing, +} + +struct Report { + /// (reference path, where it is in FilmCraft). + items: Vec<(Vec, Placement)>, + out_of_scope: BTreeMap<&'static str, usize>, + ours_only: Vec>, +} + +fn compare(reference: &[Vec], ours: &[Vec]) -> Report { + let key = |p: &[String]| p.iter().map(|s| norm(s)).collect::>(); + let our_paths: HashSet> = ours.iter().map(|p| key(p)).collect(); + let mut by_label: HashMap> = HashMap::new(); + for p in ours { + if let Some(l) = p.last() { + by_label.entry(norm(l)).or_insert(p); + } + } + let mut report = Report { items: Vec::new(), out_of_scope: BTreeMap::new(), ours_only: Vec::new() }; + let mut seen = HashSet::new(); + for p in reference { + if !seen.insert(key(p)) { + continue; + } + if let Some(why) = out_of_scope(p) { + *report.out_of_scope.entry(why).or_default() += 1; + continue; + } + let label = p.last().map(|l| norm(l)).unwrap_or_default(); + let placement = if our_paths.contains(&key(p)) { + Placement::Same + } else if let Some(at) = by_label.get(&label) { + Placement::Elsewhere((*at).clone()) + } else { + Placement::Missing + }; + report.items.push((p.clone(), placement)); + } + let ref_labels: HashSet = reference.iter().filter_map(|p| p.last().map(|l| norm(l))).collect(); + report.ours_only = ours.iter().filter(|p| p.last().is_some_and(|l| !ref_labels.contains(&norm(l)))).cloned().collect(); + report +} + +fn pct(n: usize, of: usize) -> f64 { + if of == 0 { 0.0 } else { n as f64 * 100.0 / of as f64 } +} + +fn show(p: &[String]) -> String { + p.join(" ▸ ") +} + +impl Report { + fn counts(&self) -> (usize, usize, usize) { + let same = self.items.iter().filter(|(_, pl)| matches!(pl, Placement::Same)).count(); + let elsewhere = self.items.iter().filter(|(_, pl)| matches!(pl, Placement::Elsewhere(_))).count(); + (same, elsewhere, self.items.len().saturating_sub(same + elsewhere)) + } + + /// Reference items whose label we have anywhere, in percent of the items in scope. + fn presence(&self) -> f64 { + let (same, elsewhere, _) = self.counts(); + pct(same + elsewhere, self.items.len()) + } + + fn summary(&self) -> String { + let (same, elsewhere, missing) = self.counts(); + let n = self.items.len(); + format!( + "menus: presence {}/{n} ({:.1}%), same place {same}/{n} ({:.1}%), elsewhere {elsewhere}, missing {missing}, out of scope {}", + same + elsewhere, + self.presence(), + pct(same, n), + self.out_of_scope.values().sum::() + ) + } + + fn markdown(&self, reference: &str, ours: usize) -> String { + let (same, elsewhere, missing) = self.counts(); + let n = self.items.len(); + let mut s = String::from("# Menu parity checklist\n\n"); + s.push_str(&format!( + "Generated by `cargo xtask parity` from `{reference}` ({n} items in scope, {} out of scope) against FilmCraft's \ + shipped menu bar ({ours} items). *Same place*: we have the label at the same menu path; *elsewhere*: under \ + another path; *missing*: nowhere. A label match is presence only, not behaviour.\n\n", + self.out_of_scope.values().sum::() + )); + s.push_str("| Menu | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n"); + let mut per: Vec<(&str, [usize; 3])> = Vec::new(); + for (p, pl) in &self.items { + let top = p.first().map(String::as_str).unwrap_or_default(); + let i = match per.iter().position(|(t, _)| *t == top) { + Some(i) => i, + None => { + per.push((top, [0; 3])); + per.len() - 1 + } + }; + let col = match pl { + Placement::Same => 0, + Placement::Elsewhere(_) => 1, + Placement::Missing => 2, + }; + if let Some(c) = per.get_mut(i).and_then(|(_, c)| c.get_mut(col)) { + *c += 1; + } + } + for (top, [a, b, c]) in &per { + let total = a + b + c; + s.push_str(&format!("| {top} | {total} | {a} | {b} | {c} | {:.1}% |\n", pct(a + b, total))); + } + s.push_str(&format!("| **Total** | **{n}** | **{same}** | **{elsewhere}** | **{missing}** | **{:.1}%** |\n", self.presence())); + s.push_str(&format!("\n## Missing ({missing})\n\n")); + for (p, _) in self.items.iter().filter(|(_, pl)| matches!(pl, Placement::Missing)) { + s.push_str(&format!("- [ ] {}\n", show(p))); + } + s.push_str(&format!("\n## Elsewhere in FilmCraft ({elsewhere})\n\n")); + for (p, pl) in &self.items { + if let Placement::Elsewhere(at) = pl { + s.push_str(&format!("- {} → {}\n", show(p), show(at))); + } + } + s.push_str("\n## Out of scope\n\n"); + for (why, k) in &self.out_of_scope { + s.push_str(&format!("- {why}: {k}\n")); + } + s.push_str(&format!("\n## FilmCraft only ({})\n\n", self.ours_only.len())); + for p in &self.ours_only { + s.push_str(&format!("- {}\n", show(p))); + } + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn paths(v: &[&[&str]]) -> Vec> { + v.iter().map(|p| p.iter().map(|s| s.to_string()).collect()).collect() + } + + #[test] + fn reference_formats_give_the_same_paths() { + let want = paths(&[&["File", "New", "Sequence…"], &["File", "Save"], &["Edit", "Undo"]]); + let forms = [ + json!([["File", "New", "Sequence…"], ["File", "Save"], ["Edit", "Undo"]]), + json!(["File > New > Sequence…", "File ▸ Save", "Edit > Undo"]), + json!({"menus": [ + {"name": "File", "children": [ + {"name": "New", "items": [{"name": "Sequence…", "shortcut": "Cmd+N"}]}, + {"name": "missing value"}, + {"title": "Save"} + ]}, + {"name": "Edit", "submenu": [{"label": "Undo"}]} + ]}), + json!({"Edit": {"Undo": "Cmd+Z"}, "File": {"New": ["Sequence…"], "-": null, "Save": null}}), + ]; + for f in forms { + let mut got = reference_paths(&f).unwrap(); + got.sort(); + let mut w = want.clone(); + w.sort(); + assert_eq!(got, w, "{f}"); + } + } + + #[test] + fn hostile_reference_is_refused_not_overflowed() { + let mut v = json!("leaf"); + for _ in 0..200 { + v = json!({ "name": "m", "children": [v] }); + } + assert!(reference_paths(&v).unwrap_err().contains("nested deeper")); + let many: Vec = (0..MAX_ITEMS + 1).map(|i| format!("File > Item {i}")).collect(); + assert!(reference_paths(&json!(many)).unwrap_err().contains("more than")); + // odd values are skipped, not trusted + assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]])).unwrap(), Vec::>::new()); + } + + #[test] + fn presence_and_placement_are_reported_separately() { + let reference = paths(&[ + &["File", "New", "Sequence..."], + &["File", "Export Frame"], + &["Edit", "Find & Replace"], + &["Edit", "Spelling"], + &["Help", "Premiere Pro Help…"], + &["File", "Open Recent", "clip.prproj"], + &["Edit", "AutoFill"], + &["Edit", "Spelling"], + ]); + let ours = paths(&[&["File", "New", "Sequence…"], &["File", "Export", "Export Frame"], &["Edit", "Find and Replace…"], &["Clip", "Nest…"]]); + let r = compare(&reference, &ours); + assert_eq!(r.counts(), (2, 1, 1), "duplicates count once; out-of-scope items are not counted"); + assert!((r.presence() - 75.0).abs() < 1e-9); + assert_eq!(r.out_of_scope.values().sum::(), 3); + assert_eq!(r.ours_only, paths(&[&["Clip", "Nest…"]])); + let md = r.markdown("menus.json", ours.len()); + assert!(md.contains("| **Total** | **4** | **2** | **1** | **1** | **75.0%** |"), "{md}"); + assert!(md.contains("- [ ] Edit ▸ Spelling")); + assert!(md.contains("- File ▸ Export Frame → File ▸ Export ▸ Export Frame")); + assert!(r.summary().contains("presence 3/4 (75.0%), same place 2/4 (50.0%)")); + } + + #[test] + fn empty_reference_measures_zero_without_dividing_by_zero() { + let r = compare(&[], &paths(&[&["File", "Save"]])); + assert_eq!(r.presence(), 0.0); + assert!(r.summary().contains("presence 0/0 (0.0%)")); + } + + #[test] + fn our_dump_and_arguments_are_checked() { + let ours = our_items(&json!([{"id": "file.save", "label": "Save", "path": ["File"]}, {"id": "x", "label": "Orphan", "path": []}, {"bad": 1}])).unwrap(); + assert_eq!(ours, paths(&[&["File", "Save"]])); + assert!(our_items(&json!({})).is_err()); + assert!(parse_args(&["--min", "101"]).is_err()); + assert!(parse_args(&["--min", "x"]).is_err()); + assert!(parse_args(&["--out"]).is_err()); + assert!(parse_args(&["--bogus"]).is_err()); + assert!(parse_args(&["--min", "90", "--reference", "r.json"]).is_ok_and(|a| a.min == Some(90.0) && a.reference.is_some())); + } +} From c635d9af9e11690172466c4604bbf6de183e0b98 Mon Sep 17 00:00:00 2001 From: SilentYeti Date: Fri, 9 Oct 2026 23:48:21 -0700 Subject: [PATCH 041/203] Linux: H.265 export through NVENC The NVENC HEVC encoder (Main 8-bit SDR and Main 10 HDR PQ / HLG) was gated to Windows. It now also runs on 64-bit Linux, on the CUDA device the H.264 encoder already uses: the H.265 format and HDR probes are registered there and the HEVC tests run on both systems. NVENC registration on Linux now happens after the VA-API probe. Started beside it, the background HEVC probe's first cuInit could deadlock with libva-nvidia-driver, which calls cuInit from its library constructor under the loader lock. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +-- crates/export/src/settings.rs | 2 +- crates/platform/README.md | 24 +++++------ crates/platform/src/lib.rs | 42 +++++++++++-------- crates/platform/src/nvenc/export.rs | 5 +-- crates/platform/src/nvenc/mod.rs | 4 +- crates/platform/tests/nvenc_hevc.rs | 2 +- crates/platform/tests/nvenc_hevc_export.rs | 2 +- .../platform/tests/nvenc_hevc_hdr_export.rs | 2 +- crates/platform/tests/nvenc_hevc_main10.rs | 2 +- crates/platform/tests/nvenc_hevc_probe.rs | 2 +- crates/platform/tests/nvenc_hevc_warm.rs | 2 +- crates/platform/tests/nvenc_rgba_input.rs | 4 +- docs/codec-parity.md | 2 +- docs/target-app-parity.md | 4 +- docs/testing.md | 2 +- 16 files changed, 56 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index e74820ab..81968754 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | | **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF | -Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). +Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows and Linux, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
@@ -289,10 +289,10 @@ FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](doc The biggest gaps today: -- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). +- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on all three (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). - **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO. - **No plugins.** No VST3 / Audio Units or OpenFX hosting. -- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. +- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows and Linux: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. - **AI features.** Few so far; speech to text is optional and off by default. diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index fc220e8b..b7b0c730 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -62,7 +62,7 @@ impl H264Profile { } /// Export ▸ Hardware encoding: whether H.264 may be encoded by the system's hardware video encoder -/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows) instead of FilmCraft's own encoder. Off +/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows and Linux) instead of FilmCraft's own encoder. Off /// by default: hardware encoders make different streams, and exports are otherwise byte-identical /// from run to run and machine to machine. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/platform/README.md b/crates/platform/README.md index b78c5773..cefdfa0d 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -61,7 +61,7 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") - After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache always seeks after a flush, and a caller that continues from the middle of a GOP gets an error that `HybridDecoder` answers by replaying the run in software. -- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding, and H.265 (HEVC) on Windows** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's +- **Windows and 64-bit Linux: NVIDIA NVENC H.264 and H.265 (HEVC) encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's `nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time, so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed. @@ -76,8 +76,8 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export ends it with an error, since a hardware stream cannot be finished in software. The same session, - ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, - NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). + ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding + (NVENC)](#hardware-h265-hevc-encoding-nvenc). - **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the @@ -188,7 +188,7 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r format description's sample description extension atoms, with the VPS / SPS / PPS), so profile, level and flags are the encoder's own. If it is missing the export stops with that reason. -## Hardware H.265 (HEVC) encoding (Windows, NVENC) +## Hardware H.265 (HEVC) encoding (NVENC, Windows and Linux) One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability @@ -295,13 +295,13 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | | `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | | `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | -| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | -| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | -| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | -| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | -| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | -| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | -| `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | +| `tests/nvenc_hevc.rs` (Windows / Linux, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | +| `tests/nvenc_hevc_export.rs` (Windows / Linux, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/nvenc_rgba_input.rs` (Windows / Linux, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | +| `tests/nvenc_hevc_probe.rs` (Windows / Linux) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | +| `tests/nvenc_hevc_warm.rs` (Windows / Linux) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | +| `tests/nvenc_hevc_main10.rs` (Windows / Linux, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | +| `tests/nvenc_hevc_hdr_export.rs` (Windows / Linux, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | | `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages | | `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` | @@ -344,7 +344,7 @@ Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on mac textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from -the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders +the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows and Linux vendors, and encoders from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. ### Linux NVENC sources and limits diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 8744da0e..e42db755 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -8,7 +8,7 @@ //! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an //! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on -//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows +//! macOS and with NVENC ([`nvenc`], Windows and Linux) it also makes the H.265 export format available, and with NVENC //! (Main 10) lets HDR sequences export as HDR H.265. //! //! Hardware decoding never makes a file undecodable: @@ -68,8 +68,6 @@ pub enum Availability { /// harmless). Streams they do not take, and every stream while hardware decoding is Off, keep /// using FilmCraft's own decoders. pub fn register() -> Availability { - #[cfg(all(target_os = "linux", target_pointer_width = "64"))] - filmcraft_export::register_encoder(nvenc::export::factory); #[cfg(target_os = "macos")] { filmcraft_codecs::register_video_decoder(videotoolbox_factory); @@ -81,23 +79,9 @@ pub fn register() -> Availability { filmcraft_codecs::hw::set_hw_backend("VideoToolbox"); Availability::Available("VideoToolbox") } - // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available - // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of - // its own now rather than by the first draw of the format list. #[cfg(target_os = "windows")] { - filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); - // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere - // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before - filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); - nvenc::warm_hevc_probe(); - } - #[cfg(target_os = "windows")] - { - static ENCODERS: std::sync::Once = std::sync::Once::new(); - // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an - // export only when asked for and when NVENC can do it - ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); + register_nvenc(); filmcraft_codecs::register_video_decoder(media_foundation_factory); filmcraft_codecs::hw::set_hw_backend("Media Foundation"); Availability::Available("Media Foundation") @@ -130,6 +114,11 @@ pub fn register() -> Availability { } Err(why) => log::info!("no NVDEC hardware decoding: {why}"), } + // After the VA-API probe, never beside it: libva-nvidia-driver calls `cuInit` from its + // library constructor (under the loader lock) while a first `cuInit` on another thread + // loads libraries under CUDA's own lock, and the two wait for each other forever. + #[cfg(target_pointer_width = "64")] + register_nvenc(); available } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] @@ -138,6 +127,23 @@ pub fn register() -> Availability { } } +/// NVENC: the Export encoder factory, and what makes the H.265 format (and HDR H.265) available. +#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +fn register_nvenc() { + // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows and Linux, so the format is available + // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of + // its own now rather than by the first draw of the format list. + filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); + // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere + // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before + filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); + nvenc::warm_hevc_probe(); + static ENCODERS: std::sync::Once = std::sync::Once::new(); + // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an + // export only when asked for and when NVENC can do it + ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); +} + /// Whether [`register`] has put a hardware decoder factory in front of our decoders. pub fn registered() -> bool { #[cfg(target_os = "macos")] diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index 4344cb52..a796742f 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -211,8 +211,7 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) - /// The Export encoder factory (see the module documentation). pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { if format == Format::Hevc { - // H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only - return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None }; + return hevc_factory(w, h, rate, s); } factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default())) } @@ -261,7 +260,7 @@ fn factory_with( } } -/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle +/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows and Linux, so the Hardware encoding toggle /// does not apply and a request NVENC cannot take is an error, not a fall-through. fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { let declined = |why: &str| { diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index 4fe2357f..d15928e1 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -1,5 +1,5 @@ -//! NVIDIA NVENC hardware encoding: H.264 (Windows and 64-bit Linux) and H.265 (HEVC) Main / Main 10 -//! (Windows). +//! NVIDIA NVENC hardware encoding: H.264 and H.265 (HEVC) Main / Main 10 (Windows and +//! 64-bit Linux). //! //! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` on Windows //! (on a Direct3D 11 device) or `libnvidia-encode.so.1` on Linux (on the CUDA driver's primary diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/nvenc_hevc.rs index acd2bee9..d5f1ca9d 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/nvenc_hevc.rs @@ -2,7 +2,7 @@ //! with our own HEVC decoder to pictures close to the source, keyframes and timestamps are right, the //! samples carry no parameter sets and the `hvcC` describes the SPS it was built from. Skips //! without an NVIDIA GPU / driver with HEVC encoding. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use filmcraft_bitstream::unescape_rbsp; use filmcraft_hevc::params::Sps; diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/nvenc_hevc_export.rs index 95f074bb..1b449188 100644 --- a/crates/platform/tests/nvenc_hevc_export.rs +++ b/crates/platform/tests/nvenc_hevc_export.rs @@ -4,7 +4,7 @@ //! counters; and what NVENC declines (an error that says why, counted, never a crash). Its own test //! binary: the encoder registry and the counters are process-wide. Skips without an NVIDIA GPU with //! an HEVC encoder. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_hdr_export.rs b/crates/platform/tests/nvenc_hevc_hdr_export.rs index 8acbc1ca..157e536e 100644 --- a/crates/platform/tests/nvenc_hevc_hdr_export.rs +++ b/crates/platform/tests/nvenc_hevc_hdr_export.rs @@ -5,7 +5,7 @@ //! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same //! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder //! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/nvenc_hevc_main10.rs index 76d0c9cc..74a8e280 100644 --- a/crates/platform/tests/nvenc_hevc_main10.rs +++ b/crates/platform/tests/nvenc_hevc_main10.rs @@ -3,7 +3,7 @@ //! pictures that come out of our own HEVC decoder are 10-bit and close to them (smooth ramps keep far more //! than 256 levels, bright code values survive), the VUI and the HDR10 SEI messages are right, and //! hostile inputs are errors. Skips (with SKIPPED) only on a machine without a 10-bit HEVC encoder. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use filmcraft_bitstream::unescape_rbsp; use filmcraft_export::{ColorSignal, rgbf_to_yuv420_10}; diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/nvenc_hevc_probe.rs index e2a41cd7..28f28f34 100644 --- a/crates/platform/tests/nvenc_hevc_probe.rs +++ b/crates/platform/tests/nvenc_hevc_probe.rs @@ -1,7 +1,7 @@ //! The HEVC availability probe of the NVENC backend: it opens one small encoder session, once. Its own //! test binary so that "first call" means a fresh process (driver load, Direct3D 11 device, NVENC //! session and encoder initialisation all included). -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_hevc_warm.rs b/crates/platform/tests/nvenc_hevc_warm.rs index adc0ebb2..1a616328 100644 --- a/crates/platform/tests/nvenc_hevc_warm.rs +++ b/crates/platform/tests/nvenc_hevc_warm.rs @@ -1,7 +1,7 @@ //! `register` asks the NVENC HEVC probe on a thread of its own, so the first draw of Export's format //! list does not open an encoder session on the UI thread. Its own test binary: anything else asking //! `hevc_available` first would make the wait meaningless. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/nvenc_rgba_input.rs index 4009f3e6..62b8b9bd 100644 --- a/crates/platform/tests/nvenc_rgba_input.rs +++ b/crates/platform/tests/nvenc_rgba_input.rs @@ -1,7 +1,7 @@ -//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the +//! NVENC with packed RGBA input (Windows and Linux, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the //! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main //! and H.264. Skips without an NVIDIA GPU / driver. -#![cfg(target_os = "windows")] +#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 83416bf2..0068c37b 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -16,7 +16,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Codec | Premiere | Our decode | Our encode | Fidelity and tests | % | Est. | |---|---|---|---|---|---|---| | H.264 / AVC | decode all common profiles incl. High 10, 4:2:2 (XAVC S-I, AVC-Intra); encode 8/10-bit | own decoder, **8-bit 4:2:0 only** (Baseline / Main / High, CABAC / CAVLC); no High 10 / 4:2:2 / 4:4:4, interlaced field / MBAFF, lossless | own encoder (High / Main / Baseline, B-frames, CBR / VBR / 2-pass); 8-bit 4:2:0 only; hardware VideoToolbox / NVENC | decode bit-exact on 37+ conformance streams, 500–600 fps 1080p; encode ffprobe-checked, byte-identical across machines | 65% | 20–35 h | -| HEVC / H.265 | decode Main, Main 10, 4:2:2 10 (XAVC HS); encode 8/10-bit, HDR | own decoder Main / Main 10 / Main Still Picture, bit-exact on 41 fixtures, ~225 fps 1080p; **no 4:2:2 / RExt** | **hardware only** (VideoToolbox 8-bit; NVENC 8-bit and Main 10 HDR on Windows); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 h | +| HEVC / H.265 | decode Main, Main 10, 4:2:2 10 (XAVC HS); encode 8/10-bit, HDR | own decoder Main / Main 10 / Main Still Picture, bit-exact on 41 fixtures, ~225 fps 1080p; **no 4:2:2 / RExt** | **hardware only** (VideoToolbox 8-bit; NVENC 8-bit and Main 10 HDR on Windows and Linux); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 h | | ProRes 422 family / 4444 / XQ | decode + encode all | own decode + encode, all six profiles, alpha | yes (export UI exposes 422 flavours; 4444 / XQ request #342) | ±1 LSB, 318 fps | 85% | 3–6 h | | ProRes RAW | decode | none (#345) | — | — | 0% | 30–50 h (spec availability uncertain) | | DNxHD / DNxHR | decode + encode, MXF / MOV | own decode (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2 / 4:4:4, interlaced, alpha) and encode LB / SQ / HQ / HQX / 444 | yes, MOV + MXF | within IDCT precision of ffmpeg on 22 fixtures | 90% | 2–4 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 29b2f706..b774a538 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -137,8 +137,8 @@ Evidence for the cross-cutting rows: - **Hardware ~45%.** Hardware decode exists on all three desktop OSs (VideoToolbox; Media Foundation / D3D11 incl. VP9 and AV1; VA-API and NVDEC for H.264 / HEVC on Linux). Hardware - encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR - (Windows). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, + encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 and HEVC incl. Main 10 HDR (Windows, + Linux). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, ProRes hardware, zero-copy upload, ~48 of Premiere's accelerated effects, external video I/O, control surfaces. Detail: [hardware-parity.md](hardware-parity.md). - **Performance ~55%.** 4K H.264 / HEVC play with no dropped frames with hardware decoding (M4 diff --git a/docs/testing.md b/docs/testing.md index 856dad73..a35f48b4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -77,7 +77,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for the GPU's RGB → 4:2:0 conversion, `nvenc_hevc.rs`, `nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and -`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU +`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows and Linux) skip, printing `SKIPPED`, without an NVIDIA GPU with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says there is no 10-bit encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in `crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC From d676fdc7209278f12f928c07dbdcaf26381873d6 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:41:36 +0200 Subject: [PATCH 042/203] #424: Render In to Out renders only the frames between In and Out Problem: Render Effects In to Out and Render In to Out rendered every render-bar segment that In/Out touched, in full. Segments run between edit points, so In/Out inside one long clip rendered the whole clip (often the whole timeline). Fix: render only the marked frames of each segment as a partial preview, `--.mov`, frames counted from the segment start (so a ripple keeps it, like whole `.mov` previews). The store indexes parts per hash; the render bar is green over rendered frames only, playback reads the part holding a frame, later renders fill only the gaps, and Delete Render Files In to Out removes only files holding marked frames. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/previews.rs | 308 ++++++++++++++++++++++------ crates/engine/src/previews_tests.rs | 87 +++++++- docs/gaps.md | 8 +- 3 files changed, 325 insertions(+), 78 deletions(-) diff --git a/crates/engine/src/previews.rs b/crates/engine/src/previews.rs index 8533f655..5b75c02f 100644 --- a/crates/engine/src/previews.rs +++ b/crates/engine/src/previews.rs @@ -3,7 +3,13 @@ //! //! Segments, content hashes and the yellow/red cost estimate come from //! [`filmcraft_render::preview`]. A rendered segment is a ProRes 422 QuickTime file named -//! `.mov` in the project's preview cache directory: +//! `.mov` in the project's preview cache directory, and a rendered part of a segment (In/Out +//! inside it: Render In to Out renders only In to Out, like Premiere) is `--.mov`, +//! frames `off..off + len` counted from the segment start. Because the offsets are relative to the +//! segment, a ripple edit that moves the segment keeps its parts, as it keeps whole previews. The +//! render bar is green over the rendered frames only; later renders fill the gaps. +//! +//! The folder: //! //! * saved project `/path/Film.fcproj` → `/path/FilmCraft Previews/Film/`; //! * unsaved project → a per-process folder in the system temp dir; its files move into the @@ -19,7 +25,7 @@ use filmcraft_audio_dsp::channels::{Layout, Mixdown}; use filmcraft_render::audio::to_layout; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock}; @@ -28,7 +34,7 @@ use filmcraft_frame::{AudioBuffer, VideoFrame}; use filmcraft_media::{FrameRequest, SharedSource}; use filmcraft_project::{ItemId, Project}; use filmcraft_render::preview::{AudioSegment, Need, Segment, segment_at, video_segments}; -use filmcraft_time::{Tick, TimeRange}; +use filmcraft_time::{FrameRate, Tick, TimeRange}; use serde::Serialize; use serde_json::{Value, json}; @@ -73,9 +79,11 @@ pub struct PreviewStore { dir: RwLock>, /// Where unsaved projects' previews go (Settings ▸ Media Cache); None = the system temp dir. temp_root: RwLock>, - /// File names present: `.mov` (video) and `.wav` (audio). + /// File names present: `.mov` / `--.mov` (video) and `.wav` (audio). files: RwLock>, - /// Opened preview files (most recent last). + /// The video files of `files` by segment hash (rebuilt whenever `files` changes). + video: RwLock>>, + /// Opened preview files by file name (most recent last). sources: Mutex>, /// Loaded audio previews: interleaved stereo f32 (most recent last). audio: Mutex>)>>, @@ -90,6 +98,72 @@ pub struct PreviewStore { fn video_name(hash: &str) -> String { format!("{hash}.mov") } + +/// A rendered stretch of one segment: frames `off..off + len` counted from the segment start, in +/// file `name`. A whole-segment preview has `off` 0 and `len` [`WHOLE`]. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Part { + off: i64, + len: i64, + name: String, +} + +const WHOLE: i64 = i64::MAX; + +/// File name of the preview of frames `off..off + len` of a segment of `frames` frames. +fn part_name(hash: &str, off: i64, len: i64, frames: i64) -> String { + if off == 0 && len >= frames { video_name(hash) } else { format!("{hash}-{off}-{len}.mov") } +} + +/// `(hash, off, len)` of a video preview file name (`.mov` or `--.mov`). +fn parse_video_name(name: &str) -> Option<(&str, i64, i64)> { + let stem = name.strip_suffix(".mov")?; + if is_hash(stem) { + return Some((stem, 0, WHOLE)); + } + let mut it = stem.split('-'); + let (hash, off, len) = (it.next()?, it.next()?, it.next()?); + if it.next().is_some() || !is_hash(hash) { + return None; + } + let (off, len) = (off.parse::().ok()?, len.parse::().ok()?); + (len > 0).then_some((hash, i64::from(off), i64::from(len))) +} + +/// The parts of `[a, b)` not covered by the sorted, merged intervals `cov`. +fn gaps(cov: &[(i64, i64)], a: i64, b: i64) -> Vec<(i64, i64)> { + let mut out = Vec::new(); + let mut pos = a; + for &(x, y) in cov { + if y <= pos { + continue; + } + if x >= b { + break; + } + if x > pos { + out.push((pos, x)); + } + pos = pos.max(y); + } + if pos < b { + out.push((pos, b)); + } + out +} + +/// First frame whose start is at or after `t`. +fn frame_ceil(rate: FrameRate, t: Tick) -> i64 { + let f = rate.frame_at(t); + if rate.tick_of(f) < t { f.saturating_add(1) } else { f } +} + +/// Frames of In/Out (`range`) inside `seg`, counted from the segment start (empty when apart). +fn rel_range(seg: &Segment, rate: FrameRate, range: &TimeRange) -> (i64, i64) { + let a = rate.frame_at(range.start).saturating_sub(seg.first_frame).clamp(0, seg.frames.max(0)); + let b = frame_ceil(rate, range.end()).saturating_sub(seg.first_frame).clamp(0, seg.frames.max(0)); + (a, b.max(a)) +} fn audio_name(hash: &str) -> String { format!("{hash}.wav") } @@ -132,15 +206,14 @@ impl PreviewStore { { for e in rd.flatten() { let name = e.file_name().to_string_lossy().to_string(); - if let Some(h) = name.strip_suffix(".mov").or_else(|| name.strip_suffix(".wav")) - && is_hash(h) - { + if parse_video_name(&name).is_some() || name.strip_suffix(".wav").is_some_and(is_hash) { files.insert(name); } } } *self.dir.write().unwrap_or_else(|e| e.into_inner()) = dir; *self.files.write().unwrap_or_else(|e| e.into_inner()) = files; + self.reindex(); self.sources.lock().unwrap_or_else(|e| e.into_inner()).clear(); self.audio.lock().unwrap_or_else(|e| e.into_inner()).clear(); self.bump(); @@ -166,9 +239,47 @@ impl PreviewStore { self.set_dir(Some(dir)); } - /// Whether the video preview of segment `hash` exists. - pub fn has(&self, hash: &str) -> bool { - self.files.read().unwrap_or_else(|e| e.into_inner()).contains(&video_name(hash)) + /// Rebuild the by-hash index of the video previews from the file names. + fn reindex(&self) { + let mut video: HashMap> = HashMap::new(); + for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() { + if let Some((hash, off, len)) = parse_video_name(name) { + video.entry(hash.to_string()).or_default().push(Part { off, len, name: name.clone() }); + } + } + *self.video.write().unwrap_or_else(|e| e.into_inner()) = video; + } + + /// The rendered frames of `seg`, counted from its start: sorted, merged, within the segment. + pub fn coverage(&self, seg: &Segment) -> Vec<(i64, i64)> { + let video = self.video.read().unwrap_or_else(|e| e.into_inner()); + let Some(parts) = video.get(&seg.hash) else { return Vec::new() }; + let n = seg.frames.max(0); + let mut iv: Vec<(i64, i64)> = parts.iter().map(|p| (p.off.clamp(0, n), p.off.saturating_add(p.len).clamp(0, n))).filter(|(a, b)| a < b).collect(); + iv.sort_unstable(); + let mut out: Vec<(i64, i64)> = Vec::new(); + for (a, b) in iv { + match out.last_mut() { + Some(l) if a <= l.1 => l.1 = l.1.max(b), + _ => out.push((a, b)), + } + } + out + } + + /// Whether previews cover every frame of `seg`. + pub fn is_rendered(&self, seg: &Segment) -> bool { + matches!(self.coverage(seg).as_slice(), [(0, b)] if *b == seg.frames) + } + + /// Names of the video preview files of `seg` that hold frames of `a..b` (from the segment start). + fn parts_in(&self, seg: &Segment, a: i64, b: i64) -> Vec { + if a >= b { + return Vec::new(); + } + let video = self.video.read().unwrap_or_else(|e| e.into_inner()); + let Some(parts) = video.get(&seg.hash) else { return Vec::new() }; + parts.iter().filter(|p| p.off < b && a < p.off.saturating_add(p.len)).map(|p| p.name.clone()).collect() } /// Whether the audio preview of audio segment `hash` exists. @@ -181,17 +292,14 @@ impl PreviewStore { self.files.read().unwrap_or_else(|e| e.into_inner()).len() } - pub fn path_for(&self, hash: &str) -> Option { - self.dir().map(|d| d.join(video_name(hash))) - } - pub fn audio_path_for(&self, hash: &str) -> Option { self.dir().map(|d| d.join(audio_name(hash))) } - /// Register a finished video preview file. - pub fn add(&self, hash: &str) { - self.files.write().unwrap_or_else(|e| e.into_inner()).insert(video_name(hash)); + /// Register a finished video preview file (`.mov` or `--.mov`). + fn add_video(&self, name: &str) { + self.files.write().unwrap_or_else(|e| e.into_inner()).insert(name.to_string()); + self.reindex(); self.bump(); } @@ -204,23 +312,39 @@ impl PreviewStore { /// Delete the preview files (video and audio) of `hashes`, or every file when None. Returns /// how many files were removed. pub fn delete(&self, hashes: Option<&[String]>) -> usize { - let Some(dir) = self.dir() else { return 0 }; let victims: Vec = { let files = self.files.read().unwrap_or_else(|e| e.into_inner()); match hashes { - Some(h) => h.iter().flat_map(|h| [video_name(h), audio_name(h)]).filter(|n| files.contains(n)).collect(), + Some(h) => files + .iter() + .filter(|n| { + let owner = parse_video_name(n).map(|(hash, _, _)| hash).or_else(|| n.strip_suffix(".wav")); + owner.is_some_and(|o| h.iter().any(|h| h == o)) + }) + .cloned() + .collect(), None => files.iter().cloned().collect(), } }; - let gone = |h: &String| victims.contains(&video_name(h)) || victims.contains(&audio_name(h)); - self.sources.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h)); - self.audio.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !gone(h)); + self.delete_files(&victims) + } + + /// Delete the preview files named `names`. Returns how many were removed. + fn delete_files(&self, names: &[String]) -> usize { + let Some(dir) = self.dir() else { return 0 }; + let victims: Vec = { + let files = self.files.read().unwrap_or_else(|e| e.into_inner()); + names.iter().filter(|n| files.contains(*n)).cloned().collect() + }; + self.sources.lock().unwrap_or_else(|e| e.into_inner()).retain(|(n, _)| !victims.contains(n)); + self.audio.lock().unwrap_or_else(|e| e.into_inner()).retain(|(h, _)| !victims.contains(&audio_name(h))); let mut files = self.files.write().unwrap_or_else(|e| e.into_inner()); for n in &victims { let _ = std::fs::remove_file(dir.join(n)); files.remove(n); } drop(files); + self.reindex(); self.bump(); victims.len() } @@ -341,25 +465,41 @@ impl PreviewStore { segs } + /// Green when previews cover the whole segment, else its colour without a preview. pub fn state_of(&self, seg: &Segment) -> BarState { - if self.has(&seg.hash) { - return BarState::Green; - } - match seg.need { - Need::None => BarState::None, - Need::Realtime => BarState::Yellow, - Need::Render => BarState::Red, - } + if self.is_rendered(seg) { BarState::Green } else { need_state(seg) } } - /// The render bar: one span per segment, adjacent spans of the same colour merged. + /// The render bar: per segment, green over its rendered frames and its own colour elsewhere; + /// adjacent spans of the same colour merged. pub fn bar(&self, project: &Arc, seq: ItemId) -> Vec { + let Some(rate) = project.sequence(seq).map(|q| q.settings.frame_rate) else { return Vec::new() }; let mut out: Vec = Vec::new(); for s in self.segments(project, seq).iter() { - let state = self.state_of(s); - match out.last_mut() { - Some(l) if l.end == s.start && l.state == state => l.end = s.end, - _ => out.push(BarSpan { start: s.start, end: s.end, state }), + let need = need_state(s); + let mut spans = Vec::new(); + let mut pos = 0; + for (a, b) in self.coverage(s) { + if a > pos { + spans.push((pos, a, need)); + } + spans.push((a, b, BarState::Green)); + pos = b; + } + if pos < s.frames { + spans.push((pos, s.frames, need)); + } + let at = |f: i64| match f { + 0 => s.start, + f if f >= s.frames => s.end, + f => rate.tick_of(s.first_frame.saturating_add(f)), + }; + for (a, b, state) in spans { + let (start, end) = (at(a), at(b)); + match out.last_mut() { + Some(l) if l.end == start && l.state == state => l.end = end, + _ => out.push(BarSpan { start, end, state }), + } } } out @@ -372,29 +512,33 @@ impl PreviewStore { } let segs = self.segments(project, seq); let seg = segment_at(&segs, frame)?; - if !self.has(&seg.hash) { - return None; - } - let src = self.open(pool, &seg.hash)?; + let rel = frame.checked_sub(seg.first_frame)?; + let (name, off) = { + let video = self.video.read().unwrap_or_else(|e| e.into_inner()); + let p = video.get(&seg.hash)?.iter().find(|p| p.off <= rel && rel < p.off.saturating_add(p.len))?; + (p.name.clone(), p.off) + }; + let src = self.open(pool, &name)?; let rate = project.sequence(seq)?.settings.frame_rate; - src.video_frame(FrameRequest { time: rate.tick_of(frame - seg.first_frame), scale }).ok() + src.video_frame(FrameRequest { time: rate.tick_of(rel - off), scale }).ok() } - fn open(&self, pool: &MediaPool, hash: &str) -> Option { + /// The opened preview file `name` (`.mov` or a part). + fn open(&self, pool: &MediaPool, name: &str) -> Option { { let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(i) = g.iter().position(|(h, _)| h == hash) { + if let Some(i) = g.iter().position(|(n, _)| n == name) { let e = g.remove(i); let s = e.1.clone(); g.push(e); return Some(s); } } - let path = self.path_for(hash)?; + let path = self.dir()?.join(name); let bytes = std::fs::read(&path).ok()?; - let src = pool.open_bytes(&format!("{hash}.mov"), bytes.into()).ok()?; + let src = pool.open_bytes(name, bytes.into()).ok()?; let mut g = self.sources.lock().unwrap_or_else(|e| e.into_inner()); - g.push((hash.to_string(), src.clone())); + g.push((name.to_string(), src.clone())); if g.len() > 6 { g.remove(0); } @@ -402,6 +546,14 @@ impl PreviewStore { } } +fn need_state(seg: &Segment) -> BarState { + match seg.need { + Need::None => BarState::None, + Need::Realtime => BarState::Yellow, + Need::Render => BarState::Red, + } +} + fn is_hash(s: &str) -> bool { s.len() == 32 && s.bytes().all(|b| b.is_ascii_hexdigit()) } @@ -438,33 +590,42 @@ fn in_out(s: &Session) -> Result<(ItemId, TimeRange)> { Ok((id, TimeRange::from_bounds(a, b.max(a)))) } -fn overlaps(seg: &Segment, r: &TimeRange) -> bool { - seg.start < r.end() && r.start < seg.end +/// One preview file to render: frames `off..off + len` of segment `seg` (from its start). +struct Todo { + seg: Segment, + off: i64, + len: i64, } /// Start rendering previews as a background job. `{"wait": true}` renders synchronously. +/// +/// The In/Out modes render only the frames between In and Out (a segment that extends past them +/// gets a partial preview), and every mode renders only frames that have no preview yet. pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { let (seq, range) = in_out(s)?; + let rate = s.project.sequence(seq).ok_or(EngineError::NoSequence)?.settings.frame_rate; let store = s.previews.clone(); let dir = store.dir().ok_or_else(|| EngineError::Other("render previews are not available here (no preview folder)".into()))?; let segs = store.segments(&s.project, seq); let selection: HashSet<_> = s.state.selection.iter().copied().collect(); - let todo: Vec = segs - .iter() - .filter(|g| !store.has(&g.hash)) - .filter(|g| match mode { - RenderMode::EffectsInToOut => g.need != Need::None && overlaps(g, &range), - RenderMode::InToOut => overlaps(g, &range), - RenderMode::Selection => g.clips.iter().any(|c| selection.contains(c)), - }) - .cloned() - .collect(); + let mut todo: Vec = Vec::new(); + for g in segs.iter() { + let (a, b) = match mode { + RenderMode::EffectsInToOut if g.need == Need::None => continue, + RenderMode::EffectsInToOut | RenderMode::InToOut => rel_range(g, rate, &range), + RenderMode::Selection if g.clips.iter().any(|c| selection.contains(c)) => (0, g.frames.max(0)), + RenderMode::Selection => continue, + }; + for (off, end) in gaps(&store.coverage(g), a, b) { + todo.push(Todo { seg: g.clone(), off, len: end - off }); + } + } if todo.is_empty() { s.toast("Nothing to render: previews are up to date"); return Ok(json!({"job": null, "segments": 0})); } std::fs::create_dir_all(&dir).map_err(|e| EngineError::Other(format!("preview folder {}: {e}", dir.display())))?; - let frames: i64 = todo.iter().map(|g| g.frames).sum(); + let frames: i64 = todo.iter().map(|t| t.len).fold(0, i64::saturating_add); let id = s.jobs.len() as u64 + 1; let job = crate::Job { id, @@ -484,17 +645,19 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { let mut bytes = 0u64; let mut done_frames = 0u64; let mut outcome: std::result::Result<(), String> = Ok(()); - for (k, g) in todo.iter().enumerate() { + for (k, t) in todo.iter().enumerate() { if prog.cancel.load(Ordering::Relaxed) { outcome = Err("cancelled".into()); break; } *prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("Rendering segment {} of {nseg}", k + 1); - let part = dir.join(format!("{}.mov.part", g.hash)); + let name = part_name(&t.seg.hash, t.off, t.len, t.seg.frames); + let part = dir.join(format!("{name}.part")); + let first = t.seg.first_frame.saturating_add(t.off); let settings = filmcraft_export::ExportSettings { format: filmcraft_export::Format::ProRes, path: part.to_string_lossy().to_string(), - range: Some(TimeRange::from_bounds(g.start, g.end)), + range: Some(TimeRange::from_bounds(rate.tick_of(first), rate.tick_of(first.saturating_add(t.len)))), scale: 1.0, include_audio: false, quality: 90, @@ -510,14 +673,14 @@ pub fn render(s: &mut Session, mode: RenderMode, p: &Value) -> Result { Ok(r) => { bytes += r.bytes; done_frames += r.frames; - let fin = dir.join(format!("{}.mov", g.hash)); + let fin = dir.join(&name); if let Err(e) = std::fs::rename(&part, &fin) { outcome = Err(e.to_string()); break; } - store.add(&g.hash); + store.add_video(&name); // open it now so the first playback doesn't wait for the file read - let _ = store.open(&pool, &g.hash); + let _ = store.open(&pool, &name); } Err(e) => { let _ = std::fs::remove_file(&part); @@ -692,8 +855,18 @@ pub fn read_wav_f32(b: &[u8]) -> Option> { pub fn delete(s: &mut Session, in_to_out: bool) -> Result { let n = if in_to_out { let (seq, range) = in_out(s)?; - let hashes: Vec = s.previews.segments(&s.project, seq).iter().filter(|g| overlaps(g, &range)).map(|g| g.hash.clone()).collect(); - s.previews.delete(Some(&hashes)) + let rate = s.project.sequence(seq).ok_or(EngineError::NoSequence)?.settings.frame_rate; + // the files holding frames between In and Out (a partial preview outside them stays) + let names: Vec = s + .previews + .segments(&s.project, seq) + .iter() + .flat_map(|g| { + let (a, b) = rel_range(g, rate, &range); + s.previews.parts_in(g, a, b) + }) + .collect(); + s.previews.delete_files(&names) } else { s.previews.delete(None) }; @@ -717,6 +890,7 @@ pub fn bar_json(s: &Session) -> Result { "startSeconds": g.start.seconds(), "endSeconds": g.end.seconds(), "state": s.previews.state_of(g), + "renderedFrames": s.previews.coverage(g).iter().map(|(a, b)| b - a).sum::(), "costMs": (g.cost_ms * 10.0).round() / 10.0, "budgetMs": (rate.frame_duration().seconds() * 1000.0 * filmcraft_render::preview::REALTIME_BUDGET * 10.0).round() / 10.0, "hash": g.hash, diff --git a/crates/engine/src/previews_tests.rs b/crates/engine/src/previews_tests.rs index a64a43c8..dfc6b7a4 100644 --- a/crates/engine/src/previews_tests.rs +++ b/crates/engine/src/previews_tests.rs @@ -19,18 +19,16 @@ fn tmp(name: &str) -> std::path::PathBuf { /// A 64×36 24 fps sequence: V1 = matte with Tint (0..24) + plain matte (24..48), A1 = tone (0..48). fn session(name: &str) -> (Session, ClipId) { + session_with(name, Generator::ColorMatte { color: [0.2, 0.5, 0.8, 1.0] }) +} + +/// [`session`] with `video` in place of the matte on V1. +fn session_with(name: &str, video: Generator) -> (Session, ClipId) { let mut s = Session::default(); s.previews.set_dir(Some(tmp(name))); let r = FrameRate::FPS_24; let mut p = (*s.project).clone(); - let matte = crate::demo::add_generator( - &mut p, - &s.media, - GeneratorSource::new(Generator::ColorMatte { color: [0.2, 0.5, 0.8, 1.0] }, 64, 36, r, Tick(10 * TICKS_PER_SECOND)), - "Matte", - Label::Iris, - None, - ); + let matte = crate::demo::add_generator(&mut p, &s.media, GeneratorSource::new(video, 64, 36, r, Tick(10 * TICKS_PER_SECOND)), "Matte", Label::Iris, None); let tone = crate::demo::add_generator( &mut p, &s.media, @@ -135,6 +133,79 @@ fn render_turns_green_edit_invalidates_and_undo_restores() { let _ = std::fs::remove_dir_all(s.previews.dir().unwrap()); } +fn max_diff(a: &[u8], b: &[u8]) -> i32 { + a.iter().zip(b).map(|(a, b)| (*a as i32 - *b as i32).abs()).max().unwrap() +} + +fn set_marks(s: &mut Session, marks: Option<(i64, i64)>) { + let r = FrameRate::FPS_24; + s.edit_sequence("marks", |q, _, _| { + q.mark_in = marks.map(|m| r.tick_of(m.0)); + q.mark_out = marks.map(|m| r.tick_of(m.1)); + Ok(()) + }) + .unwrap(); +} + +/// Render-bar spans as (first frame, end frame, state). +fn spans(s: &Session) -> Vec<(i64, i64, BarState)> { + let r = FrameRate::FPS_24; + s.previews.bar(&s.project, s.state.active_sequence.unwrap()).iter().map(|b| (r.frame_at(b.start), r.frame_at(b.end), b.state)).collect() +} + +/// #424: Render Effects In to Out renders the frames between In and Out, not every segment that +/// In/Out touches; a later render fills only the gaps, and Delete Render Files In to Out removes +/// only the files holding marked frames. +#[test] +fn render_in_to_out_renders_only_the_marked_frames() { + use BarState::{Green, None as NoBar, Yellow}; + let (mut s, _) = session_with("range", Generator::CountingLeader); + let seq = s.state.active_sequence.unwrap(); + set_marks(&mut s, Some((6, 11))); + let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&s, &v); + assert_eq!(v["frames"], 6, "frames 6..=11, not the whole 24-frame segment: {v}"); + assert_eq!(spans(&s), vec![(0, 6, Yellow), (6, 12, Green), (12, 24, Yellow), (24, 48, NoBar)]); + assert_eq!(states(&s), vec!["yellow", "none"], "a partly rendered segment is not green"); + assert_eq!(crate::previews::bar_json(&s).unwrap()["segments"][0]["renderedFrames"], 6); + for f in [5, 12, 30] { + assert!(s.previews.frame(&s.media, &s.project, seq, f, 1.0).is_none(), "frame {f} has no preview"); + } + // the preview's frames line up with the sequence (the leader changes from frame to frame) + let live = |s: &Session, f: i64| { + filmcraft_render::render_sequence( + &s.project, + seq, + FrameRate::FPS_24.tick_of(f), + Default::default(), + &s.media.provider(s.project.clone(), s.services.clone()), + ) + .over_black_rgba8() + }; + let prev = s.previews.frame(&s.media, &s.project, seq, 9, 1.0).expect("frame 9 has a preview").to_rgba8(); + assert!(max_diff(&live(&s, 9), &live(&s, 3)) > 16, "the test needs frames that differ"); + let d = max_diff(&live(&s, 9), &prev); + assert!(d <= 4, "preview of frame 9 differs from the live frame 9 by {d}"); + // the files survive re-indexing the folder (as on reopening the project) + s.previews.set_dir(s.previews.dir()); + assert_eq!(spans(&s)[1], (6, 12, Green)); + // In/Out over the whole sequence: only the two gaps around the part are rendered + set_marks(&mut s, None); + let v = s.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap(); + job_ok(&s, &v); + assert_eq!((v["segments"].as_i64(), v["frames"].as_i64()), (Some(2), Some(18)), "{v}"); + assert_eq!(states(&s), vec!["green", "none"]); + assert_eq!(s.previews.count(), 3); + let prev = s.previews.frame(&s.media, &s.project, seq, 20, 1.0).expect("frame 20 has a preview").to_rgba8(); + assert!(max_diff(&live(&s, 20), &prev) <= 4); + // Delete Render Files In to Out keeps the parts outside In/Out + set_marks(&mut s, Some((7, 8))); + let v = s.execute("sequence.deleteRenderFilesInToOut", json!({})).unwrap(); + assert_eq!(v["deleted"], 1); + assert_eq!(spans(&s), vec![(0, 6, Green), (6, 12, Yellow), (12, 24, Green), (24, 48, NoBar)]); + let _ = std::fs::remove_dir_all(s.previews.dir().unwrap()); +} + #[test] fn previews_survive_save_and_open() { let (mut s, _) = session("save"); diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..dc486373 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -202,9 +202,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G16. Performance reports on 0.5.0 -- **Missing:** steady UI under load. Constant lag after 0.4 → 0.5 (#525), ~100% CPU (#523), - "Render Effects In to Out" (#424). AV1 software decode is ~4 fps at 1080p single-threaded; 8K - is not real time. +- **Missing:** steady UI under load. Constant lag after 0.4 → 0.5 (#525), ~100% CPU (#523). AV1 + software decode is ~4 fps at 1080p single-threaded; 8K is not real time. +- **Done:** Render (Effects) In to Out renders only the frames between In and Out, as partial + previews named by segment hash and relative frames, instead of every segment In/Out touches + (#424); Render Audio still renders whole audio segments. - **Estimate:** 30–60 h. Doc: [performance.md](performance.md). ## G17. Audio device and playback reliability From 2890a42616f66c71abb540c9783b04a150303394 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:48:16 +0700 Subject: [PATCH 043/203] fix(menus): show current label colours in Edit menu (#786) --- crates/ui-egui/src/menus.rs | 54 +++++++++++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 358a6530..7bb32e87 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -778,6 +778,8 @@ pub fn external_commands() -> Vec { pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { ui.ctx().data_mut(|d| d.insert_temp(egui::Id::new("interface-language"), app.ui.language)); let items = menu_items(app); + // Draw Edit ▸ Label with the user's live palette, not the baked-in label colours. + let labels = app.session.prefs.labels.clone(); let ctx = ui.ctx().clone(); let mut clicked: Option = None; egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::theme::menu_style)).ui(ui, |ui| { @@ -788,7 +790,7 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { if mine.is_empty() { ui.add_enabled(false, egui::Button::new(tl!("(empty)"))); } - menu_level(ui, &mine, 1, &mut clicked, &mut app.auto); + menu_level(ui, &mine, 1, &mut clicked, &mut app.auto, &labels); }); app.auto.add(&format!("menu.{top}"), r.response.rect, top); } @@ -800,7 +802,14 @@ pub fn menu_bar(app: &mut FilmcraftApp, ui: &mut egui::Ui) { /// One menu level: items whose path ends here, and a submenu (at its first item's position) for /// each deeper path segment, recursively (e.g. Clip ▸ Video Options ▸ Time Interpolation). -fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mut Option, auto: &mut crate::automation::Registry) { +fn menu_level( + ui: &mut egui::Ui, + items: &[&MenuItem], + depth: usize, + clicked: &mut Option, + auto: &mut crate::automation::Registry, + labels: &filmcraft_engine::settings::LabelPrefs, +) { let mut subs: Vec<&str> = Vec::new(); for it in items { if let Some(sub) = it.path.get(depth).map(String::as_str) { @@ -812,24 +821,37 @@ fn menu_level(ui: &mut egui::Ui, items: &[&MenuItem], depth: usize, clicked: &mu let language = ui.ctx().data(|d| d.get_temp::(egui::Id::new("interface-language"))).unwrap_or_default(); ui.menu_button(language.tr(sub), |ui| { ui.set_min_width(220.0); - menu_level(ui, &inner, depth + 1, clicked, auto); + menu_level(ui, &inner, depth + 1, clicked, auto, labels); }); - } else if menu_entry(ui, it, auto) { + } else if menu_entry(ui, it, auto, labels) { *clicked = Some(it.id.clone()); ui.close(); } } } -fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry) -> bool { - // checkable items leave room for a checkmark drawn at the left - let label = if it.checked.is_some() { format!(" {}", it.label) } else { it.label.clone() }; +/// Only Edit ▸ Label choices get a swatch. Other commands keep their usual menu layout. +fn label_menu_swatch(id: &str, labels: &filmcraft_engine::settings::LabelPrefs) -> Option { + let label = id.strip_prefix("edit.label.").and_then(filmcraft_project::Label::from_name)?; + let [r, g, b] = labels.rgb(label); + Some(egui::Color32::from_rgb(r, g, b)) +} + +fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Registry, labels: &filmcraft_engine::settings::LabelPrefs) -> bool { + // Checkmarks and label swatches each occupy the same left gutter. + let swatch = label_menu_swatch(&it.id, labels); + let label = if it.checked.is_some() || swatch.is_some() { format!(" {}", it.label) } else { it.label.clone() }; let mut b = egui::Button::new(label); if let Some(s) = &it.shortcut { b = b.shortcut_text(shortcut_text(s)); } let r = ui.add_enabled(it.enabled, b); auto.add(&format!("menu.{}", it.id), r.rect, &it.label); + if let Some(color) = swatch { + let rect = egui::Rect::from_center_size(r.rect.left_center() + egui::vec2(10.0, 0.0), egui::vec2(12.0, 12.0)); + ui.painter().rect_filled(rect, 2.0, color); + ui.painter().rect_stroke(rect, 2.0, egui::Stroke::new(1.0, ui.visuals().widgets.inactive.bg_stroke.color), egui::StrokeKind::Inside); + } if it.checked == Some(true) { let c = r.rect.left_center() + egui::vec2(10.0, 0.0); let col = ui.visuals().text_color(); @@ -842,7 +864,23 @@ fn menu_entry(ui: &mut egui::Ui, it: &MenuItem, auto: &mut crate::automation::Re #[cfg(test)] mod parse_shortcut_tests { - use super::parse_shortcut; + use super::{label_menu_swatch, parse_shortcut}; + + #[test] + fn edit_label_menu_swatch_uses_current_custom_palette() { + use filmcraft_engine::settings::{LabelPrefs, label_id}; + use filmcraft_project::Label; + + let mut labels = LabelPrefs::default(); + let label = Label::Iris; + let key = label_id(label); + labels.colors.get_mut(&key).unwrap().color = "#12ab34".into(); + + assert_eq!(label_menu_swatch("edit.label.Iris", &labels), Some(egui::Color32::from_rgb(0x12, 0xab, 0x34))); + assert_eq!(label_menu_swatch("edit.clear", &labels), None); + assert_eq!(label_menu_swatch("edit.label.invalid", &labels), None); + } + /// Off macOS `Ctrl` and `Cmd` are the same key, so either spelling parses to the modifiers a /// physical Ctrl press carries and matches a binding written the other way (#245). From be98b9d7f799e429581a583c1bd85f4ded20643c Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:49:03 +0200 Subject: [PATCH 044/203] #647: decode E-AC-3 (Dolby Digital Plus) audio Problem: files with E-AC-3 audio (MP4 ec-3, MOV, Matroska A_EAC3, MPEG-TS) imported silent: filmcraft-ac3 refused bsid 11-16. Fix: clean-room E-AC-3 decoding from ATSC A/52:2012 Annex E in filmcraft-ac3: bsi and audfrm syntax, 1/2/3/6-block syncframes, reduced sample rates, frame-based exponent strategies (exponents reused across short syncframes), SNR offset strategies, default band structures, spectral extension (translation, notch filter, noise blending). Independent substream 0 is decoded; dependent substreams and other programs are skipped (7.1 plays as its 5.1 core). AHT and enhanced coupling are refused as unsupported. The audio blocks share the AC-3 path; AC-3 output is unchanged. filmcraft-codecs routes E-AC-3 in MP4/MOV, Matroska and TS to the decoder; the TS splitter joins dependent substream frames to their access unit. Tests: ffmpeg oracle for raw E-AC-3 (64.8-91.0 dB SNR) and in MP4, MOV, Matroska and TS (79.5-91.3 dB, random access identical), mutation fuzz, synthetic frames for headers, substream skipping, refusals and exponent reuse across 1-block syncframes, spectral extension unit tests. Co-Authored-By: Claude Opus 5.5 --- crates/ac3/Cargo.toml | 2 +- crates/ac3/README.md | 48 ++- crates/ac3/src/eac3.rs | 634 +++++++++++++++++++++++++++++ crates/ac3/src/lib.rs | 401 ++++++++++++++---- crates/ac3/src/tests.rs | 175 +++++++- crates/ac3/tests/oracle.rs | 132 +++++- crates/codecs/src/audio.rs | 49 ++- crates/codecs/src/mkv.rs | 5 +- crates/codecs/src/mp4.rs | 6 +- crates/codecs/src/mpeg.rs | 4 +- crates/codecs/tests/eac3_oracle.rs | 79 ++++ crates/mpegts/src/es.rs | 46 ++- docs/codec-parity.md | 2 +- docs/gaps.md | 15 +- 14 files changed, 1474 insertions(+), 124 deletions(-) create mode 100644 crates/ac3/src/eac3.rs create mode 100644 crates/codecs/tests/eac3_oracle.rs diff --git a/crates/ac3/Cargo.toml b/crates/ac3/Cargo.toml index e4596596..e7e0149b 100644 --- a/crates/ac3/Cargo.toml +++ b/crates/ac3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "filmcraft-ac3" -description = "Clean-room AC-3 (Dolby Digital, ATSC A/52) audio decoder" +description = "Clean-room AC-3 and E-AC-3 (Dolby Digital / Dolby Digital Plus, ATSC A/52) audio decoder" version.workspace = true edition.workspace = true license.workspace = true diff --git a/crates/ac3/README.md b/crates/ac3/README.md index 53d2f324..2561540b 100644 --- a/crates/ac3/README.md +++ b/crates/ac3/README.md @@ -1,6 +1,6 @@ # filmcraft-ac3 -Clean-room AC-3 (Dolby Digital) audio decoder in pure Rust. Layer L0: depends only on +Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder in pure Rust. Layer L0: depends only on `thiserror`. Builds for `wasm32-unknown-unknown`; no `unsafe`. No GPL/LGPL code (FFmpeg, liba52) was consulted; FFmpeg is used only as an external fixture generator and test oracle. @@ -11,14 +11,17 @@ publicly available standard: §5 bit stream syntax and semantics, §6 decoding o exponents, §7.2 parametric bit allocation (Tables 7.6-7.16 extracted mechanically from the standard's text), §7.3 mantissas and dither, §7.4 coupling, §7.5 rematrixing, §7.7.1 dynamic range, §7.9 transforms and block switching. The window is computed as the Kaiser-Bessel derived -window (α = 5) and checked against Table 7.33 to 6·10⁻⁶. +window (α = 5) and checked against Table 7.33 to 6·10⁻⁶. E-AC-3: Annex E §E2 bit stream syntax +(syncinfo, bsi, audfrm, audblk; Tables E2.1-E2.13, frame exponent strategies from Table E2.10), +§E3.3 modified parameters, §E3.4.2 AHT helper variables (to detect AHT), §E3.6 spectral extension +(attenuation Table E3.14, which is 2^(-(code + 1)(bin + 1) / 15), checked against the table). ## API ```rust let mut dec = filmcraft_ac3::Decoder::new(); let h = filmcraft_ac3::parse_header(frame)?; // rate, channels, frame size -let out = dec.decode(frame)?; // 1536 samples per channel, WAV channel order +let out = dec.decode(frame)?; // h.samples() per channel, WAV channel order dec.reset(); // before decoding from another position ``` @@ -26,7 +29,7 @@ dec.reset(); // before decoding from another - Every audio coding mode (1+1, 1/0, 2/0, 3/0, 2/1, 3/1, 2/2, 3/2) with or without LFE; 32 / 44.1 / 48 kHz and every frame size; bsid ≤ 8 (9 and 10, the half / quarter-rate variants, are - accepted). E-AC-3 (bsid 11-16) is refused with `Error::Unsupported`. + accepted). - D15 / D25 / D45 exponents with reuse across blocks; the parametric bit allocation in the standard's fixed-point steps, delta bit allocation, the all-zero SNR offset special case; grouped 3-, 5- and 11-level mantissas (groups shared across exponent sets), 7- and 15-level @@ -43,6 +46,26 @@ dec.reset(); // before decoding from another - Errors (bad exponents, reserved codes, blocks running past the frame) return `Err`; garbage never panics. +## E-AC-3 (bsid 11-16) + +- Independent substream 0 is decoded (stream types 0 and 2); dependent substreams (the channels + beyond 5.1 of a 7.1 program) and further independent programs decode to no channels, so a 7.1 + stream plays as its 5.1 core. `Header::is_primary` tells them apart. +- 1, 2, 3 or 6 blocks per syncframe (`Header::samples`), 32 / 44.1 / 48 kHz and the reduced rates + 16 / 22.05 / 24 kHz, every acmod with or without LFE. +- The whole bsi (mixing and informational metadata, custom channel maps, addbsi) is parsed and + skipped; audfrm: per-block or frame-based exponent strategies (exponents may be reused across + syncframes of fewer than six blocks; the decoder keeps them until `reset`), the syntax-enable + flags and their defaults, SNR offset strategies 1-3, converter fields, block start information. +- Audio blocks share the AC-3 path: coupling with the default or transmitted band structure + (`firstcplcos` / `firstcplleak` states), rematrixing with the Annex E band count, end mantissas + derived from spectral extension. +- Spectral extension: band structure, coordinates, blending factors, translation with wrap, + banded RMS, the border / wrap-point notch filter, noise blending and scaling (§E3.6.4). +- Refused with `Error::Unsupported`: the adaptive hybrid transform (when a channel uses it; `ahte` + alone is fine) and enhanced coupling. Transient pre-noise processing data is read and not + applied. + ## Accuracy (vs FFmpeg as an external oracle) AC-3 output is not bit-exact across decoders: zero-bit mantissas are filled with @@ -56,10 +79,17 @@ FFmpeg's decode (worst channel SNR): | 1/0 32 kHz 96 kb/s | 94.1 dB | 95.7 dB | | 3/2 + LFE 48 kHz 448 kb/s | 81.4 dB | 90.2 dB | | 2/0 pink noise 128 kb/s (many zero-bit mantissas) | 27.7 dB | 27.9 dB | +| E-AC-3 2/0 48 kHz 192 kb/s (tones, chirp) | 64.8 dB | 74.7 dB | +| E-AC-3 2/0 44.1 kHz 64 kb/s with coupling and rematrixing | 65.5 dB | 66.1 dB | +| E-AC-3 1/0 32 kHz 64 kb/s | 91.0 dB | 90.1 dB | +| E-AC-3 3/2 + LFE 48 kHz 384 kb/s with mixing / informational metadata | 81.6 dB | 85.3 dB | +| E-AC-3 2/0 pink noise 96 kb/s | 48.6 dB | 47.9 dB | The noise row shows the difference is dither: two of our own decodes with different dither sequences differ as much as we differ from FFmpeg. In MPEG-2 TS (AVCHD-style `.mts`) and VOB the -maximum sample difference to FFmpeg is 2.3·10⁻³. +maximum sample difference to FFmpeg is 2.3·10⁻³. E-AC-3 through `filmcraft-codecs` +(`tests/eac3_oracle.rs`): 5.1 in MP4 and Matroska 91.3 dB, stereo in QuickTime 87.0 dB and +MPEG-TS 79.5 dB against FFmpeg's decode. ## Gaps @@ -67,4 +97,10 @@ maximum sample difference to FFmpeg is 2.3·10⁻³. §7.9.4.2 but not checked against an oracle. - No downmixing, no heavy compression (`compr`) or dialogue normalisation (both are metadata for playback systems), no CRC check (corrupt frames are caught by consistency checks). -- E-AC-3 is not decoded. +- FFmpeg's E-AC-3 encoder writes six-block syncframes with frame-based exponent strategies, + coupling with the default band structure and none of the optional per-block syntax. Spectral + extension (no free encoder writes it), fewer blocks per syncframe, the reduced rates and the + per-block syntax (shared with AC-3) are implemented from the standard and covered by unit tests + on synthetic data, not by an oracle. +- E-AC-3: no adaptive hybrid transform, enhanced coupling, transient pre-noise processing, or + channels from dependent substreams (7.1 plays as 5.1). diff --git a/crates/ac3/src/eac3.rs b/crates/ac3/src/eac3.rs new file mode 100644 index 00000000..075f3ddd --- /dev/null +++ b/crates/ac3/src/eac3.rs @@ -0,0 +1,634 @@ +//! E-AC-3 (ATSC A/52:2012 Annex E): the syncframe header and bit stream information (§E2.2.2), +//! the audio frame (§E2.2.3) and spectral extension (§E2.2.4, §E3.6). The audio blocks share the +//! AC-3 decoder (`Decoder::audio_block`), which reads the E-AC-3 differences from [`FrameInfo`]. + +use crate::{Bits, Error, Header, NFCHANS, Result}; + +/// Sample rates by fscod, and by fscod2 when fscod is 3 (Tables E2.2, E2.3). +const RATES: [u32; 3] = [48_000, 44_100, 32_000]; +const REDUCED_RATES: [u32; 3] = [24_000, 22_050, 16_000]; +/// Audio blocks per syncframe by numblkscod (Table E2.4). +const BLOCKS: [u8; 4] = [1, 2, 3, 6]; + +/// Exponent strategies of the six blocks for each frame-based strategy code (Table E2.10): +/// 0 reuse, 1 D15, 2 D25, 3 D45. +const FRAME_EXPSTR: [[u8; 6]; 32] = [ + [1, 0, 0, 0, 0, 0], + [1, 0, 0, 0, 0, 3], + [1, 0, 0, 0, 2, 0], + [1, 0, 0, 0, 3, 3], + [2, 0, 0, 2, 0, 0], + [2, 0, 0, 2, 0, 3], + [2, 0, 0, 3, 2, 0], + [2, 0, 0, 3, 3, 3], + [2, 0, 1, 0, 0, 0], + [2, 0, 2, 0, 0, 3], + [2, 0, 2, 0, 2, 0], + [2, 0, 2, 0, 3, 3], + [2, 0, 3, 2, 0, 0], + [2, 0, 3, 2, 0, 3], + [2, 0, 3, 3, 2, 0], + [2, 0, 3, 3, 3, 3], + [3, 1, 0, 0, 0, 0], + [3, 1, 0, 0, 0, 3], + [3, 2, 0, 0, 2, 0], + [3, 2, 0, 0, 3, 3], + [3, 2, 0, 2, 0, 0], + [3, 2, 0, 2, 0, 3], + [3, 2, 0, 3, 2, 0], + [3, 2, 0, 3, 3, 3], + [3, 3, 1, 0, 0, 0], + [3, 3, 2, 0, 0, 3], + [3, 3, 2, 0, 2, 0], + [3, 3, 2, 0, 3, 3], + [3, 3, 3, 2, 0, 0], + [3, 3, 3, 2, 0, 3], + [3, 3, 3, 3, 2, 0], + [3, 3, 3, 3, 3, 3], +]; + +/// Default coupling band structure by coupling sub-band (Table E2.12, "Default Coupling Banding +/// Structure"; sub-band 0 never starts a combined band). +const DEFAULT_CPLBNDSTRC: [bool; 18] = { + let mut t = [false; 18]; + let ones = [8, 10, 11, 13, 14, 15, 16, 17]; + let mut i = 0; + while i < ones.len() { + t[ones[i]] = true; + i += 1; + } + t +}; + +/// Default spectral extension band structure by sub-band (Table E2.11). +const DEFAULT_SPXBNDSTRC: [bool; 17] = { + let mut t = [false; 17]; + let mut i = 8; + while i < 17 { + t[i] = true; + i += 2; + } + t +}; + +/// First transform coefficient of spectral extension sub-band `k` (Table E3.13, k ≤ 17). +const fn spx_band_bin(k: usize) -> usize { + 25 + 12 * k +} + +/// Spectral extension attenuation (Table E3.14): spxattentab[code][binindex] = 2^(-(code + 1) · +/// (binindex + 1) / 15), which the table lists to nine decimals. +fn spx_atten(code: usize, binindex: usize) -> f32 { + let e = (code as f64 + 1.0) * (binindex as f64 + 1.0) / 15.0; + (-e).exp2() as f32 +} + +/// Parse the header of an E-AC-3 syncframe (bsid 11-16) at the start of `b` (at least 7 bytes). +pub(crate) fn parse_header(b: &[u8], bsid: u8) -> Result
{ + let mut r = Bits::new(b.get(2..).unwrap_or_default()); + let strmtyp = r.read(2) as u8; + let substreamid = r.read(3) as u8; + let words = r.read(11) as usize + 1; + let fscod = r.read(2) as usize; + let (sample_rate, blocks) = match RATES.get(fscod) { + Some(&rate) => (rate, BLOCKS[r.read(2) as usize & 3]), + None => (*REDUCED_RATES.get(r.read(2) as usize).ok_or(Error::Invalid("reserved fscod2"))?, 6), + }; + let acmod = r.read(3) as u8; + let lfeon = r.bit(); + if strmtyp == 3 { + return Err(Error::Unsupported("reserved E-AC-3 stream type 3".into())); + } + let frame_bytes = words * 2; + if frame_bytes < 8 { + return Err(Error::Invalid("E-AC-3 syncframe too short")); + } + // frame bits per second of audio + let bits = frame_bytes as u64 * 8 * u64::from(sample_rate) / (256 * u64::from(blocks)); + let bitrate_kbps = u32::try_from(bits / 1000).unwrap_or(u32::MAX); + Ok(Header { sample_rate, bitrate_kbps, frame_bytes, bsid, acmod, lfeon, blocks, strmtyp, substreamid }) +} + +/// What the audio frame and blocks need from the bit stream information. +pub(crate) struct Bsi { + pub strmtyp: u8, + /// Sample rate code for the bit allocation's hearing threshold: fscod, or fscod2 at the + /// reduced rates. + pub fscod: usize, + pub numblkscod: usize, +} + +/// Read syncinfo and bsi (§E2.2.1-2), leaving `r` at the audio frame. +pub(crate) fn read_bsi(r: &mut Bits) -> Result { + r.skip(16); // syncword + let strmtyp = r.read(2) as u8; + r.skip(3 + 11); // substreamid, frmsiz + let fscod = r.read(2) as usize; + let (rate_code, numblkscod) = if fscod == 3 { + let fscod2 = r.read(2) as usize; + if fscod2 == 3 { + return Err(Error::Invalid("reserved fscod2")); + } + (fscod2, 3) + } else { + (fscod, r.read(2) as usize) + }; + let nblocks = BLOCKS[numblkscod & 3] as usize; + let acmod = r.read(3) as usize; + let lfeon = r.bit(); + r.skip(5 + 5); // bsid, dialnorm + if r.bit() { + r.skip(8); // compr + } + if acmod == 0 { + r.skip(5); // dialnorm2 + if r.bit() { + r.skip(8); // compr2 + } + } + if strmtyp == 1 && r.bit() { + r.skip(16); // chanmap + } + if r.bit() { + // mixing metadata + if acmod > 2 { + r.skip(2); // dmixmod + } + if acmod & 1 != 0 && acmod > 2 { + r.skip(6); // ltrtcmixlev, lorocmixlev + } + if acmod & 4 != 0 { + r.skip(6); // ltrtsurmixlev, lorosurmixlev + } + if lfeon && r.bit() { + r.skip(5); // lfemixlevcod + } + if strmtyp == 0 { + if r.bit() { + r.skip(6); // pgmscl + } + if acmod == 0 && r.bit() { + r.skip(6); // pgmscl2 + } + if r.bit() { + r.skip(6); // extpgmscl + } + match r.read(2) { + 1 => r.skip(5), // premixcmpsel, drcsrc, premixcmpscl + 2 => r.skip(12), + // mixdeflen: the mixdata field (through mixdatafill) is mixdeflen + 2 bytes + 3 => { + let len = r.read(5) as usize; + r.skip(8 * (len + 2)); + } + _ => {} + } + if acmod < 2 { + if r.bit() { + r.skip(14); // panmean, paninfo + } + if acmod == 0 && r.bit() { + r.skip(14); // panmean2, paninfo2 + } + } + if r.bit() { + // frame mixing configuration information + if numblkscod == 0 { + r.skip(5); + } else { + for _ in 0..nblocks { + if r.bit() { + r.skip(5); + } + } + } + } + } + } + if r.bit() { + // informational metadata + r.skip(5); // bsmod, copyrightb, origbs + if acmod == 2 { + r.skip(4); // dsurmod, dheadphonmod + } + if acmod >= 6 { + r.skip(2); // dsurexmod + } + if r.bit() { + r.skip(8); // mixlevel, roomtyp, adconvtyp + } + if acmod == 0 && r.bit() { + r.skip(8); // mixlevel2, roomtyp2, adconvtyp2 + } + if fscod < 3 { + r.skip(1); // sourcefscod + } + } + if strmtyp == 0 && numblkscod != 3 { + r.skip(1); // convsync + } + if strmtyp == 2 && (numblkscod == 3 || r.bit()) { + r.skip(6); // blkid → frmsizecod + } + if r.bit() { + let l = r.read(6) as usize; + r.skip((l + 1) * 8); // addbsi + } + Ok(Bsi { strmtyp, fscod: rate_code, numblkscod }) +} + +/// The audio frame (§E2.2.3) and the state that runs from block to block within a syncframe. +pub(crate) struct FrameInfo { + pub strmtyp: u8, + pub blkswe: bool, + pub dithflage: bool, + pub bamode: bool, + pub frmfgaincode: bool, + pub dbaflde: bool, + pub skipflde: bool, + pub snroffststr: u8, + pub frmcsnroffst: i32, + pub frmfsnroffst: i32, + pub cplstre: [bool; 6], + pub cplinu: [bool; 6], + pub cplexpstr: [u8; 6], + pub chexpstr: [[u8; 5]; 6], + pub lfeexpstr: [u8; 6], + /// Spectral extension attenuation code per channel (spxattencod), when in use. + pub spxatten: [Option; 5], + pub firstcplcos: [bool; 5], + pub firstcplleak: bool, + /// Coupling band structure by absolute coupling sub-band. + pub cplbndstrc: [bool; 18], + pub spx: Spx, +} + +impl FrameInfo { + /// Read the audio frame. `words` is the syncframe size in 16-bit words. + pub(crate) fn read(r: &mut Bits, bsi: &Bsi, acmod: usize, lfeon: bool, nblocks: usize, words: usize) -> Result { + let nf = NFCHANS[acmod & 7]; + let six = bsi.numblkscod == 3; + let (expstre, ahte) = if six { (r.bit(), r.bit()) } else { (true, false) }; + let snroffststr = r.read(2) as u8; + if snroffststr == 3 { + return Err(Error::Invalid("reserved SNR offset strategy")); + } + let transproce = r.bit(); + let mut e = FrameInfo { + strmtyp: bsi.strmtyp, + blkswe: r.bit(), + dithflage: r.bit(), + bamode: r.bit(), + frmfgaincode: r.bit(), + dbaflde: r.bit(), + skipflde: r.bit(), + snroffststr, + frmcsnroffst: 0, + frmfsnroffst: 0, + cplstre: [false; 6], + cplinu: [false; 6], + cplexpstr: [0; 6], + chexpstr: [[0; 5]; 6], + lfeexpstr: [0; 6], + spxatten: [None; 5], + firstcplcos: [true; 5], + firstcplleak: true, + cplbndstrc: DEFAULT_CPLBNDSTRC, + spx: Spx::new(), + }; + let spxattene = r.bit(); + if acmod > 1 { + e.cplstre[0] = true; + e.cplinu[0] = r.bit(); + for blk in 1..nblocks { + e.cplstre[blk] = r.bit(); + e.cplinu[blk] = if e.cplstre[blk] { r.bit() } else { e.cplinu[blk - 1] }; + } + } + if expstre { + for blk in 0..nblocks { + if e.cplinu[blk] { + e.cplexpstr[blk] = r.read(2) as u8; + } + for ch in 0..nf { + e.chexpstr[blk][ch] = r.read(2) as u8; + } + } + } else { + // frame-based strategies (six blocks only) + if acmod > 1 && e.cplinu.iter().any(|&c| c) { + e.cplexpstr = FRAME_EXPSTR[r.read(5) as usize & 31]; + } + for ch in 0..nf { + let s = FRAME_EXPSTR[r.read(5) as usize & 31]; + for (blk, &v) in s.iter().enumerate() { + e.chexpstr[blk][ch] = v; + } + } + } + if lfeon { + for blk in 0..nblocks { + e.lfeexpstr[blk] = r.read(1) as u8; + } + } + if bsi.strmtyp == 0 && (six || r.bit()) { + r.skip(5 * nf); // convexpstr + } + if ahte { + // AHT flags exist for channels whose exponents are sent once in the frame (§E3.4.2) + let unsupported = || Err(Error::Unsupported("E-AC-3 adaptive hybrid transform".into())); + let ncplblks = e.cplinu.iter().filter(|&&c| c).count(); + let ncplregs = (0..6).filter(|&b| e.cplstre[b] || e.cplexpstr[b] != 0).count(); + if ncplblks == 6 && ncplregs == 1 && r.bit() { + return unsupported(); + } + for ch in 0..nf { + if (0..6).filter(|&b| e.chexpstr[b][ch] != 0).count() == 1 && r.bit() { + return unsupported(); + } + } + if lfeon && e.lfeexpstr.iter().filter(|&&s| s != 0).count() == 1 && r.bit() { + return unsupported(); + } + } + if snroffststr == 0 { + e.frmcsnroffst = r.read(6) as i32; + e.frmfsnroffst = r.read(4) as i32; + } + if transproce { + // transient pre-noise processing (§E3.7) is not applied + for _ in 0..nf { + if r.bit() { + r.skip(10 + 8); // transprocloc, transproclen + } + } + } + if spxattene { + for ch in 0..nf { + if r.bit() { + e.spxatten[ch] = Some(r.read(5) as usize); + } + } + } + if bsi.numblkscod != 0 && r.bit() { + // blkstrtinfo: (blocks - 1) × (4 + ceil(log2(words per frame))) bits (§E2.3.2.27) + let log2 = (usize::BITS - words.saturating_sub(1).leading_zeros()) as usize; + r.skip((nblocks - 1) * (4 + log2)); + } + Ok(e) + } +} + +/// Spectral extension parameters (§E2.2.4, §E3.6), carried from block to block of a syncframe. +pub(crate) struct Spx { + pub inu: bool, + pub chinspx: [bool; 5], + pub begf: usize, + strtf: usize, + /// First sub-band and one past the last (spx_begin_subbnd, spx_end_subbnd). + begin: usize, + end: usize, + bndstrc: [bool; 17], + /// Band sizes in coefficients (spxbndsztab) and their count (nspxbnds). + sizes: [usize; 17], + nbands: usize, + first: [bool; 5], + co: [[f32; 17]; 5], + nblend: [[f32; 17]; 5], + sblend: [[f32; 17]; 5], +} + +impl Spx { + fn new() -> Spx { + Spx { + inu: false, + chinspx: [false; 5], + begf: 0, + strtf: 0, + begin: 0, + end: 0, + bndstrc: DEFAULT_SPXBNDSTRC, + sizes: [0; 17], + nbands: 0, + first: [true; 5], + co: [[0.0; 17]; 5], + nblend: [[0.0; 17]; 5], + sblend: [[1.0; 17]; 5], + } + } + + /// First coefficient of the extension region. + pub fn begin_bin(&self) -> usize { + spx_band_bin(self.begin) + } + + /// Read the strategy and coordinates of block `blk`. + pub(crate) fn read(&mut self, r: &mut Bits, blk: usize, acmod: usize, nf: usize) -> Result<()> { + if blk == 0 || r.bit() { + self.inu = r.bit(); + if self.inu { + if acmod == 1 { + self.chinspx = [true, false, false, false, false]; + } else { + for ch in 0..nf { + self.chinspx[ch] = r.bit(); + } + } + self.strtf = r.read(2) as usize; + self.begf = r.read(3) as usize; + let endf = r.read(3) as usize; + self.begin = if self.begf < 6 { self.begf + 2 } else { self.begf * 2 - 3 }; + self.end = if endf < 3 { endf + 5 } else { endf * 2 + 3 }; + if r.bit() { + for b in self.begin + 1..self.end { + self.bndstrc[b] = r.bit(); + } + } + if self.begin >= self.end { + return Err(Error::Invalid("spectral extension ends before it begins")); + } + if self.strtf >= self.begin { + return Err(Error::Invalid("spectral extension copies from above its start")); + } + self.nbands = 1; + self.sizes = [0; 17]; + self.sizes[0] = 12; + for b in self.begin + 1..self.end { + if self.bndstrc[b] { + self.sizes[self.nbands - 1] += 12; + } else { + self.sizes[self.nbands] = 12; + self.nbands += 1; + } + } + } else { + self.chinspx = [false; 5]; + self.first = [true; 5]; + } + } + if !self.inu { + return Ok(()); + } + for ch in 0..nf { + if !self.chinspx[ch] { + self.first[ch] = true; + continue; + } + let coe = std::mem::replace(&mut self.first[ch], false) || r.bit(); + if !coe { + continue; + } + let blend = r.read(5) as f32 / 32.0; + let mstr = r.read(2) as i32; + // coordinates (§E3.6.3) and blending factors (§E3.6.4.2.1) + let mut mant = self.begin_bin() as f32; + let end = spx_band_bin(self.end) as f32; + for b in 0..self.nbands { + let e = r.read(4) as i32; + let m = r.read(2) as f32; + let v = if e == 15 { m / 4.0 } else { (m + 4.0) / 8.0 }; + self.co[ch][b] = v * 2f32.powi(-(e + 3 * mstr)); + let size = self.sizes[b] as f32; + let nratio = ((mant + 0.5 * size) / end - blend).clamp(0.0, 1.0); + self.nblend[ch][b] = nratio.sqrt(); + self.sblend[ch][b] = (1.0 - nratio).sqrt(); + mant += size; + } + } + Ok(()) + } + + /// Synthesize the extension region of one channel's coefficients (§E3.6.4): translate the + /// low band up, notch-filter the borders when attenuation is on, blend with `noise` (zero + /// mean, unit variance) scaled to the band's energy, and scale by the coordinates. + pub(crate) fn synthesize(&self, ch: usize, atten: Option, tc: &mut [f32; 256], mut noise: impl FnMut() -> f32) { + let copystart = spx_band_bin(self.strtf); + let copyend = self.begin_bin(); + let sizes = &self.sizes[..self.nbands]; + // translation + let mut wrap = [false; 17]; + let (mut copy, mut insert) = (copystart, copyend); + for (b, &size) in sizes.iter().enumerate() { + if copy + size > copyend { + copy = copystart; + wrap[b] = true; + } + for _ in 0..size { + if copy == copyend { + copy = copystart; + } + if let (Some(&v), true) = (tc.get(copy), insert < tc.len()) { + tc[insert] = v; + } + insert += 1; + copy += 1; + } + } + // banded RMS energy of the translated coefficients + let mut rms = [0f32; 17]; + let mut at = copyend; + for (b, &size) in sizes.iter().enumerate() { + let band = tc.get(at..at + size).unwrap_or_default(); + rms[b] = (band.iter().map(|v| v * v).sum::() / size as f32).sqrt(); + at += size; + } + // notch filter around the baseband / extension border and each wrap point + if let Some(code) = atten { + let g = [spx_atten(code, 0), spx_atten(code, 1), spx_atten(code, 2)]; + let mut notch = |center: usize| { + for (k, gain) in [g[0], g[1], g[2], g[1], g[0]].into_iter().enumerate() { + if let Some(v) = (center + k).checked_sub(2).and_then(|i| tc.get_mut(i)) { + *v *= gain; + } + } + }; + notch(copyend); + let mut start = copyend; + for (b, &size) in sizes.iter().enumerate() { + if b > 0 && wrap[b] { + notch(start); + } + start += size; + } + } + // noise blending and scaling + let mut at = copyend; + for (b, &size) in sizes.iter().enumerate() { + let nscale = rms[b] * self.nblend[ch][b]; + let sscale = self.sblend[ch][b]; + let co = self.co[ch][b] * 32.0; + for _ in 0..size { + let n = noise(); + if let Some(v) = tc.get_mut(at) { + *v = (*v * sscale + n * nscale) * co; + } + at += 1; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn attenuation_matches_table_e3_14() { + // rows 0, 4, 14 and 31 as printed + let rows: [(usize, [f64; 3]); 4] = [ + (0, [0.954841604, 0.911722489, 0.870550563]), + (4, [0.793700526, 0.629960525, 0.500000000]), + (14, [0.500000000, 0.250000000, 0.125000000]), + (31, [0.227930622, 0.051952369, 0.011841536]), + ]; + for (code, want) in rows { + for (i, w) in want.into_iter().enumerate() { + assert!((f64::from(spx_atten(code, i)) - w).abs() < 1e-7, "{code} {i}"); + } + } + } + + #[test] + fn default_band_structures() { + assert_eq!(DEFAULT_CPLBNDSTRC.iter().filter(|&&b| b).count(), 8); + assert!(DEFAULT_CPLBNDSTRC[8] && !DEFAULT_CPLBNDSTRC[9] && !DEFAULT_CPLBNDSTRC[12] && DEFAULT_CPLBNDSTRC[17]); + assert_eq!(DEFAULT_SPXBNDSTRC.iter().enumerate().filter(|(_, b)| **b).map(|(i, _)| i).collect::>(), vec![8, 10, 12, 14, 16]); + // every frame-based strategy sends new exponents in block 0 + assert!(FRAME_EXPSTR.iter().all(|s| s[0] != 0)); + } + + #[test] + fn spectral_extension_translation_and_scaling() { + // begin at sub-band 2 (bin 49), end at 5 (bin 85), copy from sub-band 0 (bin 25): three + // 12-bin bands, the third wraps back to the copy start + let mut s = Spx::new(); + s.inu = true; + (s.strtf, s.begin, s.end, s.nbands) = (0, 2, 5, 3); + s.sizes[..3].copy_from_slice(&[12, 12, 12]); + s.co[0][..3].copy_from_slice(&[1.0 / 32.0, 0.5 / 32.0, 0.25 / 32.0]); + s.nblend[0] = [0.0; 17]; + s.sblend[0] = [1.0; 17]; + let mut tc = [0f32; 256]; + for (i, v) in tc[25..49].iter_mut().enumerate() { + *v = i as f32 + 1.0; + } + s.synthesize(0, None, &mut tc, || 0.0); + assert_eq!(tc[49], 1.0); + assert_eq!(tc[61], 13.0 * 0.5); + assert_eq!(tc[73], 1.0 * 0.25); + assert_eq!(tc[84], 12.0 * 0.25); + assert_eq!(tc[85], 0.0); + // pure noise: the band gets the energy of its translated coefficients + s.nblend[0] = [1.0; 17]; + s.sblend[0] = [0.0; 17]; + s.co[0] = [1.0 / 32.0; 17]; + let mut tc2 = [0f32; 256]; + tc2[25..49].fill(2.0); + let mut k = 0; + s.synthesize(0, None, &mut tc2, || { + k += 1; + if k % 2 == 0 { 1.0 } else { -1.0 } + }); + assert!(tc2[49..85].iter().all(|v| v.abs() == 2.0)); + } +} diff --git a/crates/ac3/src/lib.rs b/crates/ac3/src/lib.rs index dfb53c5f..acc2dbe4 100644 --- a/crates/ac3/src/lib.rs +++ b/crates/ac3/src/lib.rs @@ -1,5 +1,6 @@ -//! Clean-room AC-3 (Dolby Digital) audio decoder, written from ATSC A/52:2012 ("Digital Audio -//! Compression Standard", §5 bit stream syntax, §6-7 decoding). +//! Clean-room AC-3 (Dolby Digital) and E-AC-3 (Dolby Digital Plus) audio decoder, written from +//! ATSC A/52:2012 ("Digital Audio Compression Standard", §5 bit stream syntax, §6-7 decoding, +//! Annex E for E-AC-3). //! //! - All audio coding modes (1+1, 1/0 … 3/2) with or without LFE, 32 / 44.1 / 48 kHz, every //! frame size (Table 5.18). @@ -11,10 +12,15 @@ //! - Output: planar f32 at full scale ±1, channels in WAV / SMPTE order (L R C LFE Ls Rs); no //! downmix. //! -//! E-AC-3 (bsid 11-16) is recognised and refused. +//! E-AC-3 (bsid 11-16, Annex E): independent substream 0 (dependent substreams and further +//! programs are skipped), 1 / 2 / 3 / 6 blocks per syncframe, the reduced sample rates, frame-based +//! exponent strategies and the other frame-level syntax, and spectral extension (§E3.6). The +//! adaptive hybrid transform and enhanced coupling are refused with [`Error::Unsupported`]; +//! transient pre-noise processing data is read and not applied. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] +mod eac3; mod tables; use std::sync::OnceLock; @@ -47,12 +53,35 @@ pub struct Header { pub bsid: u8, pub acmod: u8, pub lfeon: bool, + /// Audio blocks (of 256 samples per channel) in the syncframe: 6 for AC-3; 1, 2, 3 or 6 for + /// E-AC-3. + pub blocks: u8, + /// E-AC-3 stream type (Table E2.1): 0 independent, 1 dependent, 2 independent converted from + /// AC-3. 0 for AC-3. + pub strmtyp: u8, + /// E-AC-3 substream id (0 for AC-3). + pub substreamid: u8, } impl Header { /// Output channels (full bandwidth + LFE). pub fn channels(&self) -> usize { - NFCHANS[self.acmod as usize] + self.lfeon as usize + NFCHANS[self.acmod as usize & 7] + self.lfeon as usize + } + /// Samples per channel the syncframe decodes to. + pub fn samples(&self) -> usize { + 256 * self.blocks as usize + } + /// Whether this is an E-AC-3 syncframe (bsid 11-16). + pub fn is_eac3(&self) -> bool { + self.bsid > 10 + } + /// Whether the syncframe carries the program [`Decoder::decode`] outputs: AC-3, or E-AC-3 + /// independent substream 0 (§E2.3.1.2). [`Decoder::decode`] returns no channels for the + /// others: dependent substreams (the channels beyond 5.1 of a 7.1 program) and further + /// independent programs. + pub fn is_primary(&self) -> bool { + self.strmtyp != 1 && self.substreamid == 0 } } @@ -64,8 +93,11 @@ pub fn parse_header(b: &[u8]) -> Result
{ let fscod = b[4] >> 6; let frmsizecod = b[4] & 0x3F; let bsid = b[5] >> 3; - if bsid > 10 { - return Err(Error::Unsupported(format!("bsid {bsid} (E-AC-3)"))); + if (11..=16).contains(&bsid) { + return eac3::parse_header(b, bsid); + } + if bsid > 16 { + return Err(Error::Unsupported(format!("bsid {bsid}"))); } if fscod == 3 || frmsizecod >= 38 { return Err(Error::Invalid("reserved fscod / frmsizecod")); @@ -91,7 +123,7 @@ pub fn parse_header(b: &[u8]) -> Result
{ r.skip(2); } let lfeon = r.read(1) == 1; - Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon }) + Ok(Header { sample_rate, bitrate_kbps: kbps >> shift, frame_bytes: words as usize * 2, bsid, acmod, lfeon, blocks: 6, strmtyp: 0, substreamid: 0 }) } /// MSB-first bit reader; reads past the end give zeros. @@ -469,7 +501,9 @@ struct Frame { chincpl: [bool; 5], phsflginu: bool, cplbegf: usize, - cplendf: usize, + /// cplendf + 3: one past the last coupling sub-band (with spectral extension cplendf may be + /// negative, §E3.3.1). + cplend: usize, /// Coupling band of each coupling sub-band (relative to cplbegf). sub_to_band: [usize; 18], ncplbnd: usize, @@ -500,7 +534,7 @@ impl Frame { chincpl: [false; 5], phsflginu: false, cplbegf: 0, - cplendf: 0, + cplend: 0, sub_to_band: [0; 18], ncplbnd: 0, cplco: [[0.0; 18]; 5], @@ -523,7 +557,7 @@ impl Frame { 37 + 12 * self.cplbegf } fn cplendmant(&self) -> usize { - 37 + 12 * (self.cplendf + 3) + 37 + 12 * self.cplend } } @@ -622,6 +656,9 @@ pub struct Decoder { delay: [[f32; 256]; 6], dither: u32, seed: u32, + /// E-AC-3: the block state of the last syncframe (acmod, lfeon, sample rate code), whose + /// exponents a syncframe of fewer than six blocks may reuse. + carry: Option<(usize, bool, usize, Box)>, } impl Default for Decoder { @@ -630,7 +667,8 @@ impl Default for Decoder { } } -/// A decoded syncframe: 1536 samples per channel, WAV channel order. +/// A decoded syncframe: [`Header::samples`] samples per channel (1536 for AC-3), WAV channel +/// order. No channels for an E-AC-3 syncframe that is not [`Header::is_primary`]. #[derive(Clone, Debug)] pub struct Decoded { pub header: Header, @@ -639,7 +677,7 @@ pub struct Decoded { impl Decoder { pub fn new() -> Decoder { - Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678 } + Decoder { delay: [[0.0; 256]; 6], dither: 0, seed: 0x1234_5678, carry: None } } /// Seed the dither generator (dither is decoder-specific noise; tests compare two seeds). @@ -650,6 +688,7 @@ impl Decoder { /// Forget the overlap (before decoding from another position). pub fn reset(&mut self) { self.delay = [[0.0; 256]; 6]; + self.carry = None; } fn dither(&mut self) -> f32 { @@ -658,12 +697,20 @@ impl Decoder { ((self.dither >> 8) as f32 / (1u32 << 24) as f32 * 2.0 - 1.0) * 0.707 } + /// Zero-mean, unit-variance noise for spectral extension (§E3.6.4.2.4): uniform in ±√3. + fn noise(&mut self) -> f32 { + self.dither() * (3f32.sqrt() / 0.707) + } + /// Decode one syncframe. pub fn decode(&mut self, frame: &[u8]) -> Result { let h = parse_header(frame)?; if frame.len() < h.frame_bytes { return Err(Error::Invalid("truncated syncframe")); } + if h.is_eac3() { + return self.decode_eac3(&frame[..h.frame_bytes], h); + } let fscod = (frame[4] >> 6) as usize; // Dither is reseeded per syncframe from its CRC words (plus the configured seed), so a // frame decodes to the same samples however it was reached (seeking, render caches). @@ -708,39 +755,83 @@ impl Decoder { let l = r.read(6) as usize; r.skip((l + 1) * 8); } - let nfchans = NFCHANS[acmod]; - let mut f = Frame::new(nfchans, fscod); + let mut f = Frame::new(NFCHANS[acmod], fscod); + let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, 6, None)?; + Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) }) + } + + /// Decode one E-AC-3 syncframe (`frame` is exactly the syncframe). + fn decode_eac3(&mut self, frame: &[u8], h: Header) -> Result { + if !h.is_primary() { + return Ok(Decoded { header: h, channels: Vec::new() }); + } + // E-AC-3 has a single CRC, at the end (§E2.2.6): reseed the dither from the last words + let n = frame.len(); + let tail = frame.get(n.saturating_sub(4)..).and_then(|t| <[u8; 4]>::try_from(t).ok()).unwrap_or_default(); + self.dither = self.seed ^ u32::from_be_bytes(tail).wrapping_mul(0x9E37_79B9); + let mut r = Bits::new(frame); + let bsi = eac3::read_bsi(&mut r)?; + let (acmod, lfeon) = (h.acmod as usize & 7, h.lfeon); + let nblocks = h.blocks as usize; + let mut e = eac3::FrameInfo::read(&mut r, &bsi, acmod, lfeon, nblocks, n / 2)?; + let mut f = match self.carry.take() { + Some((a, l, fs, f)) if (a, l, fs) == (acmod, lfeon, bsi.fscod) => f, + _ => Box::new(Frame::new(NFCHANS[acmod], bsi.fscod)), + }; + let pcm = self.blocks(&mut r, &mut f, acmod, lfeon, nblocks, Some(&mut e))?; + self.carry = Some((acmod, lfeon, bsi.fscod, f)); + Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) }) + } + + /// Decode `nblocks` audio blocks and synthesize them (inverse transform, overlap-add), in coded + /// channel order with the LFE last. + fn blocks(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, nblocks: usize, mut x: Option<&mut eac3::FrameInfo>) -> Result>> { + let nfchans = f.nfchans; let nout = nfchans + lfeon as usize; - let mut pcm: Vec> = vec![Vec::with_capacity(1536); nout]; - for blk in 0..6 { + let mut pcm: Vec> = vec![Vec::with_capacity(256 * nblocks); nout]; + for blk in 0..nblocks { let mut coefs = [[0f32; 256]; 6]; - self.audio_block(&mut r, &mut f, acmod, lfeon, blk, &mut coefs)?; + self.audio_block(r, f, acmod, lfeon, blk, &mut coefs, x.as_deref_mut())?; if r.overrun() { return Err(Error::Invalid("audio block runs past the frame")); } // inverse transform, overlap-add let mut buf = [0f32; 512]; - for ch in 0..nout { + for (ch, out) in pcm.iter_mut().enumerate() { let src = if ch < nfchans { ch } else { LFE }; let short = ch < nfchans && f.blksw[ch]; imdct(&coefs[src], short, &mut buf); let d = &mut self.delay[src]; for n in 0..256 { - pcm[ch].push(2.0 * (buf[n] + d[n])); + out.push(2.0 * (buf[n] + d[n])); d[n] = buf[256 + n]; } } } - Ok(Decoded { header: h, channels: wav_order(pcm, acmod, lfeon) }) + Ok(pcm) } - fn audio_block(&mut self, r: &mut Bits, f: &mut Frame, acmod: usize, lfeon: bool, blk: usize, coefs: &mut [[f32; 256]; 6]) -> Result<()> { + #[allow(clippy::too_many_arguments)] + fn audio_block( + &mut self, + r: &mut Bits, + f: &mut Frame, + acmod: usize, + lfeon: bool, + blk: usize, + coefs: &mut [[f32; 256]; 6], + mut x: Option<&mut eac3::FrameInfo>, + ) -> Result<()> { let nf = f.nfchans; - for ch in 0..nf { - f.blksw[ch] = r.bit(); + let eac3 = x.is_some(); + match x.as_deref() { + Some(e) if !e.blkswe => f.blksw = [false; 5], + _ => (0..nf).for_each(|ch| f.blksw[ch] = r.bit()), } - for ch in 0..nf { - f.dithflag[ch] = r.bit(); + match x.as_deref() { + // dither on when the flags are not sent (§E2.2.4) + Some(e) if !e.dithflage => f.dithflag = [true; 5], + _ => (0..nf).for_each(|ch| f.dithflag[ch] = r.bit()), } for k in 0..if acmod == 0 { 2 } else { 1 } { if r.bit() { @@ -749,26 +840,62 @@ impl Decoder { f.dynrng[k] = 1.0; } } + // E-AC-3 spectral extension strategy and coordinates + if let Some(e) = x.as_deref_mut() { + e.spx.read(r, blk, acmod, nf)?; + } + let spx = x.as_deref().map(|e| &e.spx).filter(|s| s.inu); + let spxbegf = spx.map(|s| s.begf); + let spx_begin = spx.map(|s| s.begin_bin()); + let spx_chans = spx.map(|s| s.chinspx).unwrap_or([false; 5]); // coupling strategy - if r.bit() { - f.cplinu = r.bit(); + let strategy = match x.as_deref() { + Some(e) => e.cplstre[blk].then_some(e.cplinu[blk]), + None => r.bit().then(|| r.bit()), + }; + if let Some(inu) = strategy { + f.cplinu = inu; if f.cplinu { - for ch in 0..nf { - f.chincpl[ch] = r.bit(); + if eac3 && r.bit() { + return Err(Error::Unsupported("E-AC-3 enhanced coupling".into())); + } + if eac3 && acmod == 2 { + f.chincpl = [true, true, false, false, false]; + } else { + for ch in 0..nf { + f.chincpl[ch] = r.bit(); + } } if acmod == 2 { f.phsflginu = r.bit(); } f.cplbegf = r.read(4) as usize; - f.cplendf = r.read(4) as usize; - if f.cplendf + 3 <= f.cplbegf { + f.cplend = match spxbegf { + // the coupling region ends where spectral extension begins (§E2.2.4) + Some(b) if b < 6 => b + 1, + Some(b) => 2 * b - 4, + None => r.read(4) as usize + 3, + }; + if f.cplend <= f.cplbegf { return Err(Error::Invalid("coupling end below start")); } - let nsub = 3 + f.cplendf - f.cplbegf; + // coupling band structure, by absolute sub-band + let mut strc = [false; 18]; + match x.as_deref_mut() { + Some(e) => { + if r.bit() { + for s in f.cplbegf + 1..f.cplend { + e.cplbndstrc[s] = r.bit(); + } + } + strc = e.cplbndstrc; + } + None => (f.cplbegf + 1..f.cplend).for_each(|s| strc[s] = r.bit()), + } let mut band = 0; f.sub_to_band[0] = 0; - for s in 1..nsub { - if !r.bit() { + for s in 1..f.cplend - f.cplbegf { + if !strc[f.cplbegf + s] { band += 1; } f.sub_to_band[s] = band; @@ -776,15 +903,33 @@ impl Decoder { f.ncplbnd = band + 1; } else { f.chincpl = [false; 5]; + if let Some(e) = x.as_deref_mut() { + e.firstcplcos = [true; 5]; + e.firstcplleak = true; + f.phsflginu = false; + } } - } else if blk == 0 { + } else if blk == 0 && !eac3 { return Err(Error::Invalid("no coupling strategy in block 0")); } // coupling coordinates if f.cplinu { let mut any = false; for ch in 0..nf { - if f.chincpl[ch] && r.bit() { + if !f.chincpl[ch] { + if let Some(e) = x.as_deref_mut() { + e.firstcplcos[ch] = true; + } + continue; + } + let coe = match x.as_deref_mut() { + Some(e) if e.firstcplcos[ch] => { + e.firstcplcos[ch] = false; + true + } + _ => r.bit(), + }; + if coe { any = true; let mstr = r.read(2) as i32; for bnd in 0..f.ncplbnd { @@ -792,7 +937,7 @@ impl Decoder { let m = r.read(4) as f32; let v = if e == 15 { m / 16.0 } else { (m + 16.0) / 32.0 }; let co = v * 2f32.powi(-(e + 3 * mstr)); - for s in 0..(3 + f.cplendf - f.cplbegf) { + for s in 0..(f.cplend - f.cplbegf) { if f.sub_to_band[s] == bnd { f.cplco[ch][s] = co; } @@ -803,7 +948,7 @@ impl Decoder { if acmod == 2 && f.phsflginu && any { for bnd in 0..f.ncplbnd { let flag = r.bit(); - for s in 0..(3 + f.cplendf - f.cplbegf) { + for s in 0..(f.cplend - f.cplbegf) { if f.sub_to_band[s] == bnd { f.phsflg[s] = flag; } @@ -811,34 +956,55 @@ impl Decoder { } } } - // rematrixing - if acmod == 2 && r.bit() { - let n = if !f.cplinu || f.cplbegf > 2 { - 4 - } else if f.cplbegf > 0 { - 3 + // rematrixing (E-AC-3 always sends the flags in block 0) + if acmod == 2 && ((eac3 && blk == 0) || r.bit()) { + let n = if f.cplinu { + match f.cplbegf { + 0 => 2, + 1 | 2 => 3, + _ => 4, + } } else { - 2 + match spxbegf { + Some(b) if b < 2 => 3, + _ => 4, + } }; f.rematflg = [false; 4]; for k in 0..n { f.rematflg[k] = r.bit(); } } - // exponent strategies - if f.cplinu { - f.expstr[CPL] = r.read(2) as u8; - } - for ch in 0..nf { - f.expstr[ch] = r.read(2) as u8; - } - if lfeon { - f.expstr[LFE] = r.read(1) as u8; + // exponent strategies (E-AC-3: from the audio frame) + match x.as_deref() { + Some(e) => { + if f.cplinu { + f.expstr[CPL] = e.cplexpstr[blk]; + } + f.expstr[..nf].copy_from_slice(&e.chexpstr[blk][..nf]); + if lfeon { + f.expstr[LFE] = e.lfeexpstr[blk]; + } + } + None => { + if f.cplinu { + f.expstr[CPL] = r.read(2) as u8; + } + for ch in 0..nf { + f.expstr[ch] = r.read(2) as u8; + } + if lfeon { + f.expstr[LFE] = r.read(1) as u8; + } + } } for ch in 0..nf { if f.expstr[ch] != 0 { if f.chincpl[ch] && f.cplinu { f.endmant[ch] = f.cplstrtmant(); + } else if let (true, Some(begin)) = (spx_chans[ch], spx_begin) { + // §E3.3.3: the channel ends where spectral extension begins + f.endmant[ch] = begin; } else { let bw = r.read(6) as usize; if bw > 60 { @@ -870,40 +1036,105 @@ impl Decoder { decode_exponents(r, abs, 2, 1, &mut f.exps[LFE], 0, false)?; } // bit allocation parametric information - if r.bit() { - f.g.sdcycod = r.read(2) as usize; - f.g.fdcycod = r.read(2) as usize; - f.g.sgaincod = r.read(2) as usize; - f.g.dbpbcod = r.read(2) as usize; - f.g.floorcod = r.read(3) as usize; - } else if blk == 0 { - return Err(Error::Invalid("no bit allocation information in block 0")); + match x.as_deref() { + Some(e) if !e.bamode => { + // §E2.2.4 defaults + (f.g.sdcycod, f.g.fdcycod, f.g.sgaincod, f.g.dbpbcod, f.g.floorcod) = (2, 1, 1, 2, 7); + } + _ => { + if r.bit() { + f.g.sdcycod = r.read(2) as usize; + f.g.fdcycod = r.read(2) as usize; + f.g.sgaincod = r.read(2) as usize; + f.g.dbpbcod = r.read(2) as usize; + f.g.floorcod = r.read(3) as usize; + } else if blk == 0 && !eac3 { + return Err(Error::Invalid("no bit allocation information in block 0")); + } + } } - if r.bit() { - f.csnroffst = r.read(6) as i32; - if f.cplinu { - f.fsnroffst[CPL] = r.read(4) as i32; - f.fgaincod[CPL] = r.read(3) as usize; + match x.as_deref() { + Some(e) => { + // SNR offset strategies (Table E2.9), fast gain codes + let sets = [CPL, 0, 1, 2, 3, 4, LFE]; + match e.snroffststr { + 0 => { + f.csnroffst = e.frmcsnroffst; + sets.iter().for_each(|&c| f.fsnroffst[c] = e.frmfsnroffst); + } + s if blk == 0 || r.bit() => { + f.csnroffst = r.read(6) as i32; + if s == 1 { + let v = r.read(4) as i32; + sets.iter().for_each(|&c| f.fsnroffst[c] = v); + } else { + if f.cplinu { + f.fsnroffst[CPL] = r.read(4) as i32; + } + for ch in 0..nf { + f.fsnroffst[ch] = r.read(4) as i32; + } + if lfeon { + f.fsnroffst[LFE] = r.read(4) as i32; + } + } + } + _ => {} + } + if e.frmfgaincode && r.bit() { + if f.cplinu { + f.fgaincod[CPL] = r.read(3) as usize; + } + for ch in 0..nf { + f.fgaincod[ch] = r.read(3) as usize; + } + if lfeon { + f.fgaincod[LFE] = r.read(3) as usize; + } + } else { + f.fgaincod = [4; 7]; + } + if e.strmtyp == 0 && r.bit() { + r.skip(10); // convsnroffst + } } - for ch in 0..nf { - f.fsnroffst[ch] = r.read(4) as i32; - f.fgaincod[ch] = r.read(3) as usize; + None => { + if r.bit() { + f.csnroffst = r.read(6) as i32; + if f.cplinu { + f.fsnroffst[CPL] = r.read(4) as i32; + f.fgaincod[CPL] = r.read(3) as usize; + } + for ch in 0..nf { + f.fsnroffst[ch] = r.read(4) as i32; + f.fgaincod[ch] = r.read(3) as usize; + } + if lfeon { + f.fsnroffst[LFE] = r.read(4) as i32; + f.fgaincod[LFE] = r.read(3) as usize; + } + } else if blk == 0 { + return Err(Error::Invalid("no SNR offsets in block 0")); + } } - if lfeon { - f.fsnroffst[LFE] = r.read(4) as i32; - f.fgaincod[LFE] = r.read(3) as usize; - } - } else if blk == 0 { - return Err(Error::Invalid("no SNR offsets in block 0")); } - if f.cplinu && r.bit() { - f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768); + if f.cplinu { + let leak = match x.as_deref_mut() { + Some(e) if e.firstcplleak => { + e.firstcplleak = false; + true + } + _ => r.bit(), + }; + if leak { + f.cplleak = (((r.read(3) as i32) << 8) + 768, ((r.read(3) as i32) << 8) + 768); + } } // delta bit allocation if blk == 0 { f.deltbae = [2; 7]; } - if r.bit() { + if x.as_deref().is_none_or(|e| e.dbaflde) && r.bit() { if f.cplinu { f.deltbae[CPL] = r.read(2) as u8; } @@ -921,7 +1152,7 @@ impl Decoder { } } // skip field - if r.bit() { + if x.as_deref().is_none_or(|e| e.skipflde) && r.bit() { let l = r.read(9) as usize; r.skip(l * 8); } @@ -1014,6 +1245,16 @@ impl Decoder { } } } + // spectral extension: synthesize the high band of each channel from its low band + if let Some(e) = x.as_deref() + && e.spx.inu + { + for ch in 0..nf { + if e.spx.chinspx[ch] { + e.spx.synthesize(ch, e.spxatten[ch], &mut coefs[ch], || self.noise()); + } + } + } // dynamic range for ch in 0..nf { let g = if acmod == 0 && ch == 1 { f.dynrng[1] } else { f.dynrng[0] }; diff --git a/crates/ac3/src/tests.rs b/crates/ac3/src/tests.rs index d015acbe..c77d97b7 100644 --- a/crates/ac3/src/tests.rs +++ b/crates/ac3/src/tests.rs @@ -56,7 +56,7 @@ fn headers_and_gains() { let b = [0x0B, 0x77, 0, 0, 30, 8 << 3, 0b1110_0001, 0b0100_0000]; let h = parse_header(&b).unwrap(); assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes, h.acmod, h.lfeon, h.channels()), (48_000, 448, 1792, 7, true, 6)); - assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 16 << 3, 0, 0]), Err(Error::Unsupported("bsid 16 (E-AC-3)".into()))); + assert_eq!(parse_header(&[0x0B, 0x77, 0, 0, 0, 17 << 3, 0, 0]), Err(Error::Unsupported("bsid 17".into()))); assert_eq!(dynrng_gain(0), 1.0); assert!((dynrng_gain(0b1110_0000) - 0.5).abs() < 1e-7); assert!((dynrng_gain(0b0111_1111) - 16.0 * 63.0 / 64.0).abs() < 1e-5); @@ -96,3 +96,176 @@ fn garbage_never_panics() { } } } + +/// MSB-first bit writer for synthetic E-AC-3 syncframes. +#[derive(Default)] +struct W { + b: Vec, + n: usize, +} + +impl W { + fn put(&mut self, bits: u32, v: u32) { + for i in (0..bits).rev() { + if self.n.is_multiple_of(8) { + self.b.push(0); + } + let bit = ((v >> i) & 1) as u8; + *self.b.last_mut().unwrap() |= bit << (7 - self.n % 8); + self.n += 1; + } + } +} + +/// An E-AC-3 syncframe of `words` 16-bit words: syncinfo and a bsi with no optional metadata +/// (fscod < 3 with `numblkscod`), then `audio` writes the rest; zero padded. +fn eac3_frame(strmtyp: u32, substreamid: u32, words: u32, numblkscod: u32, acmod: u32, audio: impl FnOnce(&mut W)) -> Vec { + let mut w = W::default(); + w.put(16, 0x0B77); + w.put(2, strmtyp); + w.put(3, substreamid); + w.put(11, words - 1); + w.put(2, 0); // 48 kHz + w.put(2, numblkscod); + w.put(3, acmod); + w.put(1, 0); // lfeon + w.put(5, 16); // bsid + w.put(5, 31); // dialnorm + w.put(1, 0); // compre + if strmtyp == 1 { + w.put(1, 0); // chanmape + } + w.put(2, 0); // mixmdate, infomdate + if strmtyp == 0 && numblkscod != 3 { + w.put(1, 0); // convsync + } + w.put(1, 0); // addbsie + audio(&mut w); + let mut b = w.b; + b.resize(words as usize * 2, 0); + b +} + +#[test] +fn eac3_headers() { + let f = eac3_frame(0, 0, 384, 3, 2, |_| {}); + let h = parse_header(&f).unwrap(); + assert_eq!((h.sample_rate, h.blocks, h.samples(), h.frame_bytes, h.bitrate_kbps, h.channels()), (48_000, 6, 1536, 768, 192, 2)); + assert!(h.is_eac3() && h.is_primary()); + // one block per syncframe: 256 samples + let h = parse_header(&eac3_frame(0, 0, 100, 0, 7, |_| {})).unwrap(); + assert_eq!((h.blocks, h.samples(), h.channels(), h.bitrate_kbps), (1, 256, 5, 300)); + // reduced sample rate (fscod 3, fscod2 1): six blocks at 22.05 kHz + let mut f = eac3_frame(0, 0, 384, 0, 2, |_| {}); + f[4] = 0b1101_0100; + let h = parse_header(&f).unwrap(); + assert_eq!((h.sample_rate, h.blocks, h.acmod), (22_050, 6, 2)); + f[4] = 0b1111_0100; + assert_eq!(parse_header(&f), Err(Error::Invalid("reserved fscod2"))); + // a dependent substream and a second independent program are recognised and skipped + let mut d = Decoder::new(); + for (strmtyp, id) in [(1, 0), (0, 1)] { + let f = eac3_frame(strmtyp, id, 64, 3, 2, |_| {}); + let out = d.decode(&f).unwrap(); + assert!(!out.header.is_primary() && out.channels.is_empty()); + } + assert_eq!(parse_header(&eac3_frame(3, 0, 64, 3, 2, |_| {})), Err(Error::Unsupported("reserved E-AC-3 stream type 3".into()))); +} + +#[test] +fn eac3_tools_we_do_not_decode_are_refused() { + // mono, six blocks, AHT enabled and used: chexpstr D15 then reuse, so chahtinu is sent + let aht = eac3_frame(0, 0, 64, 3, 1, |w| { + w.put(1, 1); // expstre + w.put(1, 1); // ahte + w.put(2, 0); // snroffststr + w.put(8, 0); // transproce … spxattene + w.put(2, 1); // chexpstr[0] = D15 + w.put(10, 0); // chexpstr[1..6] = reuse + w.put(5, 0); // convexpstr + w.put(1, 1); // chahtinu + }); + assert_eq!(Decoder::new().decode(&aht).unwrap_err(), Error::Unsupported("E-AC-3 adaptive hybrid transform".into())); + // stereo with coupling in block 0 using enhanced coupling + let ecpl = eac3_frame(0, 0, 64, 3, 2, |w| { + w.put(1, 1); // expstre + w.put(1, 0); // ahte + w.put(2, 0); // snroffststr + w.put(8, 0); + w.put(1, 1); // cplinu[0] + w.put(5, 0); // cplstre[1..6] + w.put(6, 0b01_01_01); // block 0: cplexpstr, chexpstr × 2 + w.put(30, 0); // blocks 1-5: reuse + w.put(10, 0); // convexpstr + w.put(10, 0); // frmcsnroffst, frmfsnroffst + w.put(1, 0); // blkstrtinfoe + w.put(1, 0); // dynrnge + w.put(1, 0); // spxinu + w.put(1, 1); // ecplinu + }); + assert_eq!(Decoder::new().decode(&ecpl).unwrap_err(), Error::Unsupported("E-AC-3 enhanced coupling".into())); +} + +#[test] +fn eac3_single_block_frames_reuse_exponents_across_syncframes() { + // mono, one block per syncframe; the first sends exponents (all 10, bandwidth code 0), the + // second reuses them. Zero SNR offsets allocate no bits: every coefficient is dither. + let frame = |new_exps: bool| { + eac3_frame(0, 0, 32, 0, 1, |w| { + // (expstre and ahte are not sent with fewer than six blocks) + w.put(2, 0); // snroffststr + w.put(8, 0); // transproce … spxattene + w.put(2, new_exps as u32); // chexpstr[0]: D15 or reuse + w.put(1, 0); // convexpstre + w.put(10, 0); // frmcsnroffst, frmfsnroffst + w.put(1, 0); // dynrnge + w.put(1, 0); // spxinu + if new_exps { + w.put(6, 0); // chbwcod → 73 coefficients + w.put(4, 10); // absolute exponent + for _ in 0..24 { + w.put(7, 62); // three zero deltas + } + w.put(2, 0); // gainrng + } + w.put(1, 0); // convsnroffste + }) + }; + let (first, second) = (frame(true), frame(false)); + let mut d = Decoder::new(); + let a = d.decode(&first).unwrap(); + assert_eq!((a.header.samples(), a.channels.len(), a.channels[0].len()), (256, 1, 256)); + let b = d.decode(&second).unwrap(); + let peak = |c: &[f32]| c.iter().fold(0f32, |m, v| m.max(v.abs())); + assert!(peak(&b.channels[0]) > 1e-5 && peak(&b.channels[0]) < 0.1, "{}", peak(&b.channels[0])); + // after a reset there are no exponents to reuse: no coefficients, silence + d.reset(); + let c = d.decode(&second).unwrap(); + assert_eq!(peak(&c.channels[0]), 0.0); +} + +#[test] +fn eac3_garbage_never_panics() { + let mut d = Decoder::new(); + let mut x = 0x5EEDu32; + for len in [8usize, 40, 300, 1500, 4096] { + for _ in 0..300 { + let mut f: Vec = (0..len) + .map(|_| { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x as u8 + }) + .collect(); + f[0] = 0x0B; + f[1] = 0x77; + // independent substream 0, frmsiz within the buffer, bsid 11-16 + let words = (len / 2).clamp(4, 2048) - 1; + f[2] = (words >> 8) as u8 & 7; + f[3] = words as u8; + f[5] = (f[5] & 7) | ((11 + x % 6) as u8) << 3; + let _ = d.decode(&f); + } + } +} diff --git a/crates/ac3/tests/oracle.rs b/crates/ac3/tests/oracle.rs index 019d8138..b4d4d067 100644 --- a/crates/ac3/tests/oracle.rs +++ b/crates/ac3/tests/oracle.rs @@ -1,5 +1,5 @@ -//! AC-3 against ffmpeg: ffmpeg's AC-3 encoder writes raw syncframes from generated tones and -//! noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across +//! AC-3 and E-AC-3 against ffmpeg: ffmpeg's AC-3 and E-AC-3 encoders write raw syncframes from +//! generated tones and noise; ffmpeg's decode of the same file is the reference. AC-3 output is not bit-exact across //! decoders (zero-bit mantissas are filled with decoder-specific dither, §7.3.4; float vs fixed //! arithmetic), so the criterion is SNR against ffmpeg's decode. @@ -8,6 +8,8 @@ use std::process::{Command, Stdio}; struct Spec { name: &'static str, + /// ffmpeg encoder and raw muxer: "ac3" or "eac3". + codec: &'static str, input: &'static str, rate: u32, channels: u32, @@ -19,6 +21,7 @@ struct Spec { const SPECS: &[Spec] = &[ Spec { name: "ac3_stereo_192k", + codec: "ac3", input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000", rate: 48_000, channels: 2, @@ -28,16 +31,26 @@ const SPECS: &[Spec] = &[ // low bitrate stereo: coupling and rematrixing Spec { name: "ac3_stereo_96k_coupling", + codec: "ac3", input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100", rate: 44_100, channels: 2, enc: &["-b:a", "96k", "-channel_coupling", "1"], min_snr: 40.0, }, - Spec { name: "ac3_mono_32k", input: "sine=frequency=1000:sample_rate=32000", rate: 32_000, channels: 1, enc: &["-b:a", "96k"], min_snr: 60.0 }, + Spec { + name: "ac3_mono_32k", + codec: "ac3", + input: "sine=frequency=1000:sample_rate=32000", + rate: 32_000, + channels: 1, + enc: &["-b:a", "96k"], + min_snr: 60.0, + }, // 5.1 with LFE Spec { name: "ac3_51_448k", + codec: "ac3", input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1", rate: 48_000, channels: 6, @@ -47,22 +60,86 @@ const SPECS: &[Spec] = &[ // noise (wideband, many zero-bit mantissas): the SNR is limited by dither Spec { name: "ac3_noise_stereo", + codec: "ac3", input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0", rate: 48_000, channels: 2, enc: &["-b:a", "128k"], min_snr: 18.0, }, + // E-AC-3 (Annex E): ffmpeg writes six-block syncframes with frame-based exponent strategies + Spec { + name: "eac3_stereo_192k", + codec: "eac3", + input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000", + rate: 48_000, + channels: 2, + enc: &["-b:a", "192k"], + min_snr: 60.0, + }, + // low bitrate stereo: coupling (with the default band structure) and rematrixing + Spec { + name: "eac3_stereo_64k_coupling", + codec: "eac3", + input: "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.05*sin(2*PI*9100*t)|0.3*sin(2*PI*441*t)+0.05*sin(2*PI*11000*t):s=44100", + rate: 44_100, + channels: 2, + enc: &["-b:a", "64k", "-channel_coupling", "1"], + min_snr: 35.0, + }, + Spec { + name: "eac3_mono_32k", + codec: "eac3", + input: "sine=frequency=1000:sample_rate=32000", + rate: 32_000, + channels: 1, + enc: &["-b:a", "64k"], + min_snr: 60.0, + }, + // 5.1 with LFE and mixing / informational metadata in the bsi + Spec { + name: "eac3_51_384k_metadata", + codec: "eac3", + input: "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1", + rate: 48_000, + channels: 6, + enc: &[ + "-b:a", + "384k", + "-dmix_mode", + "loro", + "-ltrt_cmixlev", + "0.707", + "-loro_surmixlev", + "0.5", + "-dsurex_mode", + "on", + "-mixing_level", + "100", + "-room_type", + "small", + ], + min_snr: 55.0, + }, + Spec { + name: "eac3_noise_stereo", + codec: "eac3", + input: "anoisesrc=color=pink:sample_rate=48000:amplitude=0.3:seed=3,pan=stereo|c0=c0|c1=-0.5*c0", + rate: 48_000, + channels: 2, + enc: &["-b:a", "96k", "-dsur_mode", "on", "-dheadphone_mode", "off"], + min_snr: 18.0, + }, ]; fn make(ff: &Path, s: &Spec) -> Option { - let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name)); + let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec)); filmcraft_testkit::fixtures::generate(&out, |tmp| { let st = Command::new(ff) .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1"]) - .args(["-c:a", "ac3"]) + .args(["-c:a", s.codec]) .args(s.enc) - .args(["-f", "ac3"]) + .args(["-f", s.codec]) .arg(tmp) .stdin(Stdio::null()) .status(); @@ -94,6 +171,7 @@ fn ffmpeg_encoded_streams_decode() { for fr in frames(&data) { let d = dec.decode(fr).unwrap(); assert_eq!((d.header.sample_rate, d.channels.len() as u32), (s.rate, s.channels), "{}", s.name); + assert_eq!(d.header.is_eac3(), s.codec == "eac3", "{}", s.name); for (o, c) in ours.iter_mut().zip(d.channels) { o.extend(c); } @@ -136,12 +214,52 @@ fn ffmpeg_encoded_streams_decode() { } } +/// Damaged E-AC-3 syncframes (bit flips, truncation, corrupt sizes) give errors or noise, never +/// a panic. +#[test] +fn damaged_eac3_frames_never_panic() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut x = 0x00C0_FFEEu32; + let mut rnd = move || { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x + }; + for s in SPECS.iter().filter(|s| s.codec == "eac3") { + let f = make(&ff, s).unwrap_or_else(|| panic!("could not generate {}", s.name)); + let data = std::fs::read(&f).unwrap(); + let mut dec = filmcraft_ac3::Decoder::new(); + for fr in frames(&data).into_iter().take(8) { + for k in 0..300 { + let mut m = fr.to_vec(); + match k % 3 { + 0 => { + for _ in 0..1 + rnd() % 8 { + let bit = rnd() as usize % (m.len() * 8); + m[bit / 8] ^= 0x80 >> (bit % 8); + } + } + 1 => m.truncate(rnd() as usize % m.len()), + _ => { + // keep the sync word, scramble frmsiz / fscod / numblkscod / acmod + m[2] ^= rnd() as u8; + m[3] ^= rnd() as u8; + m[4] ^= rnd() as u8; + } + } + let _ = dec.decode(&m); + } + } + } +} + #[test] #[ignore] fn generate_fixtures() { let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("ac3 fixtures") else { return }; for s in SPECS { - let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.ac3", s.name)); + let out = filmcraft_testkit::fixtures_dir("ac3").join(format!("{}.{}", s.name, s.codec)); filmcraft_testkit::fixtures::generate_and_report(&format!("ac3/{}", s.name), &[out], || make(&ff, s)); } } diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index e364402a..63351636 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -25,7 +25,7 @@ enum Inner { Opus { dec: Box, order: Option<&'static [usize]> }, /// Bootstrap decoders (MP3, ALAC, FLAC, HE-AAC…) via symphonia. Symphonia(Box), - /// Our AC-3 decoder (ATSC A/52). + /// Our AC-3 and E-AC-3 decoder (ATSC A/52, Annex E). Ac3(Box), } @@ -148,7 +148,7 @@ impl PacketDecoder { self.inner = Inner::Aac { dec: Box::new(dec), asc: asc.to_vec(), up: None }; Ok(()) } - /// AC-3 (ATSC A/52). + /// AC-3 and E-AC-3 (ATSC A/52, Annex E). pub fn ac3() -> Result { Ok(Self { inner: Inner::Ac3(Box::new(filmcraft_ac3::Decoder::new())), channels: 0 }) } @@ -161,7 +161,7 @@ impl PacketDecoder { match c { C::Aac(a) => Self::aac(&a.asc, if rate > 0 { rate } else { a.sample_rate }), C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None), - C::Ac3 { .. } => Self::ac3(), + C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(), C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())), C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None), C::Opus(o) => Self::opus(filmcraft_opus::OpusHead::from_dops(&o.to_bytes()).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), @@ -193,12 +193,16 @@ impl PacketDecoder { Inner::Symphonia(d) => d, Inner::AacPending => return Err(CodecError::Decode("AAC: no configuration yet".into())), Inner::Ac3(d) => { - // a packet may hold several syncframes (MP4 / Matroska) + // a packet may hold several syncframes (MP4 / Matroska); E-AC-3 dependent + // substreams and further programs decode to no channels and are skipped let mut out: Vec> = Vec::new(); let mut p = 0; while p < data.len() { let f = d.decode(&data[p..]).map_err(|e| CodecError::Decode(e.to_string()))?; p += f.header.frame_bytes; + if f.channels.is_empty() { + continue; + } if out.is_empty() { out = f.channels; } else { @@ -279,21 +283,39 @@ pub enum FixedFrames<'a> { Aac(&'a [u8]), /// MPEG-1/2 audio, layer I, II or III. MpegAudio, - /// AC-3 (and E-AC-3). + /// AC-3 (and E-AC-3 in a stream of single syncframes, MPEG-TS). Ac3, + /// E-AC-3 packets of whole syncframes (MP4, Matroska): an access unit may hold several + /// syncframes and dependent substreams. + Eac3, } /// Samples each packet decodes to at the stream's output `rate`, for codecs where that is the same /// for every packet: AAC 1024 (2048 when [`PacketDecoder::aac`] upsamples an HE-AAC core); MPEG -/// audio and AC-3 from the frame header of `first`, the stream's first packet. `None` when it is -/// not known: an AAC configuration our decoder does not read, a header that does not parse, or a -/// header whose rate is not `rate`. +/// audio and AC-3 from the frame header of `first`, the stream's first packet; E-AC-3 from the +/// syncframes of the program we decode in `first`. `None` when it is not known: an AAC +/// configuration our decoder does not read, a header that does not parse, or a header whose rate +/// is not `rate`. pub fn fixed_packet_samples(codec: FixedFrames, first: &[u8], rate: u32) -> Option { let mpegts = match codec { FixedFrames::Aac(asc) => { let core = filmcraft_aac::Decoder::new(asc).ok()?.sample_rate(); return Some(if upsamples_core(core, rate) { 2048 } else { 1024 }); } + FixedFrames::Eac3 => { + let mut samples = 0usize; + let mut p = 0; + while let Some(h) = first.get(p..).and_then(|b| filmcraft_ac3::parse_header(b).ok()) { + if h.is_primary() { + if h.sample_rate != rate { + return None; + } + samples += h.samples(); + } + p += h.frame_bytes; + } + return i64::try_from(samples).ok().filter(|&n| n > 0); + } FixedFrames::MpegAudio => filmcraft_mpegts::Codec::MpegAudio, FixedFrames::Ac3 => filmcraft_mpegts::Codec::Ac3, }; @@ -405,7 +427,7 @@ impl Upsample2x { } } -/// Whether AC-3 audio decodes (our ATSC A/52 decoder). +/// Whether AC-3 and E-AC-3 audio decode (our ATSC A/52 decoder). pub const AC3_DECODER: bool = true; /// An AudioSpecificConfig for AAC with these parameters (ISO/IEC 14496-3 §1.6.2.1, GA specific @@ -967,5 +989,14 @@ mod packet_time_tests { assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF, 0xFB, 0x90, 0x44], 48_000), None); assert_eq!(fixed_packet_samples(FixedFrames::MpegAudio, &[0xFF], 44_100), None); assert_eq!(fixed_packet_samples(FixedFrames::Ac3, &[0x0B, 0x77], 48_000), None); + // E-AC-3 access units: 8-byte syncframes (frmsiz 3) at 48 kHz, stereo. A six-block + // independent frame with a dependent substream (7.1) is 1536 samples; two one-block + // frames are 512 + let frame = |strmtyp: u8, numblkscod: u8| [0x0B, 0x77, strmtyp << 6, 3, (numblkscod << 4) | (2 << 1), 16 << 3, 0, 0]; + let au = |frames: &[[u8; 8]]| frames.concat(); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 3), frame(1, 3)]), 48_000), Some(1536)); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 0), frame(0, 0)]), 48_000), Some(512)); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &au(&[frame(0, 3)]), 44_100), None); + assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &[0x0B, 0x77], 48_000), None); } } diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 97de6743..9e0b6c0a 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -68,7 +68,7 @@ pub struct MkvSource { /// /// Block timestamps are quantised to `TimestampScale` (usually 1 ms), so they are only accurate to a /// tick: a 1024-sample AAC frame at 48 kHz lasts 21.33 ms. Packets that all decode to the same -/// length (AAC, MPEG audio, AC-3) therefore run on from one another, resynchronising to the +/// length (AAC, MPEG audio, AC-3, E-AC-3) therefore run on from one another, resynchronising to the /// timestamp only across gaps of more than half a packet (and more than two ticks); see /// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their /// timestamps. @@ -86,6 +86,7 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec Codec::Aac { asc } => fixed_packet_samples(FixedFrames::Aac(asc), &[], out_rate), Codec::Mp3 | Codec::Mp2 => fixed_packet_samples(FixedFrames::MpegAudio, &first(), out_rate), Codec::Ac3 => fixed_packet_samples(FixedFrames::Ac3, &first(), out_rate), + Codec::Eac3 => fixed_packet_samples(FixedFrames::Eac3, &first(), out_rate), _ => None, }; let Some(frame) = frame else { @@ -447,7 +448,7 @@ impl MkvSource { Codec::Opus { head } => PacketDecoder::opus(filmcraft_opus::OpusHead::parse(head).map_err(|e| CodecError::Unsupported(format!("Opus: {e}")))?), Codec::Mp3 => PacketDecoder::new(CODEC_TYPE_MP3, rate, None), Codec::Mp2 => PacketDecoder::mpeg_audio(2, rate), - Codec::Ac3 => PacketDecoder::ac3(), + Codec::Ac3 | Codec::Eac3 => PacketDecoder::ac3(), // symphonia wants the STREAMINFO block body: skip `fLaC` + the 4-byte block header. Codec::Flac { private } => PacketDecoder::new(CODEC_TYPE_FLAC, rate, private.get(8..42).map(<[u8]>::to_vec)), Codec::Vorbis { headers } if headers.len() == 3 => { diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index dffa0dd6..4a3a2c53 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -57,8 +57,9 @@ pub fn sniff(b: &[u8]) -> bool { } /// Audio packet start positions in source sample frames at `rate`: the running total of the sample -/// durations, except that packets which all decode to the same length (AAC, MPEG audio, AC-3) run -/// on from one another, resynchronising to the total only across gaps of more than half a packet. +/// durations, except that packets which all decode to the same length (AAC, MPEG audio, AC-3, +/// E-AC-3) run on from one another, resynchronising to the total only across gaps of more than half +/// a packet. /// A remux from Matroska (OBS's) carries the millisecond rounding of its timestamps into the /// durations: 1008, 1008, 1056… for 1024-sample AAC frames at 48 kHz (see /// [`crate::audio::contiguous_starts`]). @@ -81,6 +82,7 @@ fn audio_starts(file: &Mp4File, bytes: &crate::Src, ti: usize, rate: u32) -> Vec Some(CodecConfig::Aac(a)) => fixed_packet_samples(FixedFrames::Aac(&a.asc), &[], rate), Some(CodecConfig::Mp3) => fixed_packet_samples(FixedFrames::MpegAudio, &first(), rate), Some(CodecConfig::Ac3 { .. }) => fixed_packet_samples(FixedFrames::Ac3, &first(), rate), + Some(CodecConfig::Eac3 { .. }) => fixed_packet_samples(FixedFrames::Eac3, &first(), rate), _ => None, }; let Some(frame) = frame else { return stamps }; diff --git a/crates/codecs/src/mpeg.rs b/crates/codecs/src/mpeg.rs index ef2a6999..ca72c88b 100644 --- a/crates/codecs/src/mpeg.rs +++ b/crates/codecs/src/mpeg.rs @@ -120,7 +120,7 @@ fn vkind(c: &Codec) -> Option { } fn audio_supported(c: &Codec) -> bool { - matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3) + matches!(c, Codec::MpegAudio | Codec::AacAdts | Codec::AacLatm | Codec::LpcmBluray | Codec::LpcmDvd | Codec::Ac3 | Codec::Eac3) } /// The coded rate unless the timestamps say otherwise (3:2 pulldown, field-rate coding). Never @@ -505,7 +505,7 @@ impl MpegSource { }; let mut latm = None; let (codec, rate, channels, per_frame, bits) = match &st.codec { - Codec::MpegAudio | Codec::AacAdts | Codec::Ac3 => { + Codec::MpegAudio | Codec::AacAdts | Codec::Ac3 | Codec::Eac3 => { let Some(fi) = filmcraft_mpegts::frame_info(&st.codec, &first) else { return self.audio_unsupported(&codec_name, format!("{codec_name}: bad frame header")); }; diff --git a/crates/codecs/tests/eac3_oracle.rs b/crates/codecs/tests/eac3_oracle.rs new file mode 100644 index 00000000..ee7f335d --- /dev/null +++ b/crates/codecs/tests/eac3_oracle.rs @@ -0,0 +1,79 @@ +//! E-AC-3 (Dolby Digital Plus) audio in MP4, Matroska and MPEG-TS against ffmpeg: ffmpeg encodes +//! and muxes the files and decodes them as the oracle. Zero-bit mantissas carry decoder-specific +//! dither (A/52 §7.3.4), so the criterion is SNR against ffmpeg's decode, sample-aligned. + +mod common; + +use common::*; + +const STEREO: &str = "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000"; +const SURROUND: &str = + "aevalsrc=exprs=0.3*sin(2*PI*300*t)|0.3*sin(2*PI*400*t)|0.3*sin(2*PI*500*t)|0.3*sin(2*PI*60*t)|0.2*sin(2*PI*700*t)|0.2*sin(2*PI*800*t):s=48000:c=5.1"; + +/// Worst channel SNR (dB) of `ours` (planar) against ffmpeg's interleaved `want`, with ours +/// shifted by `lag` samples, over the samples both cover past the first 1000. +fn snr(ours: &[Vec], want: &[f32], ch: usize, lag: i64) -> f64 { + let n = want.len() / ch; + (0..ch) + .map(|c| { + let (mut sig, mut err) = (0f64, 0f64); + for i in 1000..n - 1000 { + let Some(&a) = ours[c].get((i as i64 + lag) as usize) else { continue }; + let (a, b) = (a as f64, want[i * ch + c] as f64); + sig += b * b; + err += (a - b) * (a - b); + } + 10.0 * (sig / err.max(1e-30)).log10() + }) + .fold(f64::INFINITY, f64::min) +} + +#[test] +fn eac3_in_mp4_matroska_and_transport_streams() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let cases: [(&str, &str, u32, &[&str]); 4] = [ + ("eac3_51.mp4", SURROUND, 6, &["-b:a", "384k"]), + ("eac3_51.mkv", SURROUND, 6, &["-b:a", "384k"]), + ("eac3_stereo.mov", STEREO, 2, &["-b:a", "192k"]), + ("eac3_stereo.ts", STEREO, 2, &["-b:a", "192k"]), + ]; + for (name, input, channels, enc) in cases { + let args: Vec<&str> = ["-f", "lavfi", "-i", input, "-t", "2", "-c:a", "eac3"].into_iter().chain(enc.iter().copied()).collect(); + let f = fixture(&ff, name, &args).unwrap_or_else(|| panic!("could not write {name}")); + let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels), (48_000, channels), "{name}: {}", a.codec); + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let ch = channels as usize; + let n = want.len() / ch; + // MPEG-TS audio starts at its first PTS relative to the presentation start + let start = match name.ends_with(".ts") { + true => filmcraft_codecs::MpegSource::open(name, bytes(&f)).unwrap().audio_extent().map_or(0, |e| e.0), + false => 0, + }; + let ours = src.audio(start, n, 48_000).unwrap().channels; + assert_eq!(ours.len(), ch, "{name}"); + // Matroska stores CodecDelay (the encoder's 256-sample priming) in ns and timestamps in + // ms, and we place packets at the rounded value (240 samples): a shared Matroska timing + // limit (AAC and AC-3 alike), not a decoding one. Elsewhere the samples line up exactly. + let (worst, lag) = (-32..=32).map(|lag| (snr(&ours, &want, ch, lag), lag)).fold((f64::MIN, 0), |a, b| if b.0 > a.0 { b } else { a }); + let max_lag = if name.ends_with(".mkv") { 16 } else { 0 }; + assert!(lag.abs() <= max_lag, "{name}: decode is {lag} samples off ffmpeg's"); + // a window read on a fresh source (random access) gives the same samples + let fresh = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let at = n / 3; + let win = fresh.audio(start + at as i64, 4800, 48_000).unwrap().channels; + let mut seek_err = 0f32; + for c in 0..ch { + for (k, v) in win[c].iter().enumerate() { + seek_err = seek_err.max((v - ours[c][at + k]).abs()); + } + } + println!( + "{name}: {} ({} Hz, {ch} ch), worst channel SNR vs ffmpeg {worst:.1} dB (lag {lag}), random access max diff {seek_err:.1e}", + a.codec, a.sample_rate + ); + assert!(worst >= 50.0, "{name}: SNR {worst:.1} dB"); + assert!(seek_err <= 1e-6, "{name}: random-access audio differs by {seek_err}"); + } +} diff --git a/crates/mpegts/src/es.rs b/crates/mpegts/src/es.rs index 7d567969..eba1ffa0 100644 --- a/crates/mpegts/src/es.rs +++ b/crates/mpegts/src/es.rs @@ -235,6 +235,15 @@ pub fn frame_info(codec: &Codec, b: &[u8]) -> Option { } } +/// Whether the syncframe at the start of `b` is E-AC-3 (bsid 11-16) other than independent +/// substream 0: a dependent substream (strmtyp 1) or another program (A/52 §E2.3.1.1-2). +fn eac3_joins_unit(b: &[u8]) -> bool { + match b.get(2..6) { + Some(&[b2, _, _, b5]) => (11..=16).contains(&(b5 >> 3)) && (b2 >> 6 == 1 || (b2 >> 3) & 7 != 0), + _ => false, + } +} + /// Full-bandwidth channels per AC-3 audio coding mode (A/52 Table 5.8). const AC3_CHANNELS: [u32; 8] = [2, 1, 2, 3, 3, 4, 4, 5]; @@ -572,14 +581,18 @@ impl Splitter { } match frame_len(sync, &self.buf[i..]) { Some(len) if self.next_frame == Some(at) || self.confirmed(sync, i, len) => { - self.close(at); - let (pos, offset) = self.locate(at).unwrap_or((u64::MAX, 0)); - let mut o = Open { start: at, pos, offset, pts: None, dts: None, key: true, disposable: false, vcl: false, picture: None }; - if let Some((pts, dts)) = self.timestamps_at(at) { - o.pts = pts; - o.dts = dts; + // an E-AC-3 dependent substream or further program joins the access unit of + // the independent substream 0 before it, as in MP4 and Matroska + if !(sync == AudioSync::Ac3 && self.open.is_some() && eac3_joins_unit(&self.buf[i..])) { + self.close(at); + let (pos, offset) = self.locate(at).unwrap_or((u64::MAX, 0)); + let mut o = Open { start: at, pos, offset, pts: None, dts: None, key: true, disposable: false, vcl: false, picture: None }; + if let Some((pts, dts)) = self.timestamps_at(at) { + o.pts = pts; + o.dts = dts; + } + self.open = Some(o); } - self.open = Some(o); self.next_frame = Some(at + len as u64); if at + len as u64 >= self.buf_start + self.buf.len() as u64 { self.buf.clear(); @@ -654,4 +667,23 @@ mod tests { assert_eq!(ac3_frame_bytes(1, 21), Some(836)); assert_eq!(ac3_frame_bytes(2, 0), Some(192)); } + + #[test] + fn eac3_dependent_substreams_join_the_access_unit() { + // 16-byte E-AC-3 syncframes (frmsiz 7), 48 kHz six blocks stereo: independent, dependent + // (7.1 extension), independent, dependent + let frame = |strmtyp: u8| { + let mut f = vec![0x0B, 0x77, strmtyp << 6, 7, (3 << 4) | (2 << 1), 16 << 3]; + f.resize(16, 0); + f + }; + let es: Vec = [frame(0), frame(1), frame(0), frame(1)].concat(); + let mut s = Splitter::new(&Codec::Eac3); + s.feed(0, &es); + s.finish(); + assert_eq!(s.units.iter().map(|u| u.size).collect::>(), vec![32, 32]); + assert_eq!(frame_info(&Codec::Eac3, &es).map(|f| f.samples), Some(1536)); + // AC-3 frames are never joined + assert!(!eac3_joins_unit(&[0x0B, 0x77, 0x40, 0, 0, 8 << 3])); + } } diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 83416bf2..953cd378 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -41,7 +41,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | PCM (WAV, BWF, AIFF, MOV, MXF, Blu-ray / DVD LPCM) | yes | yes, BWF timecode | WAV, AIFF, MOV, MXF | sample-exact | 95% | — | | AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h | | AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — | -| **E-AC-3 (Dolby Digital Plus)** | decode | **refused** (bsid 11–16, #647) | none | — | 0% | 15–25 h | +| E-AC-3 (Dolby Digital Plus) | decode | own decoder (ATSC A/52 Annex E, #647): independent substream, 1–6 blocks, spectral extension; **no** adaptive hybrid transform, enhanced coupling or 7.1 dependent substreams (plays the 5.1 core) | none | SNR vs ffmpeg 65–91 dB (raw, MP4, MOV, Matroska, TS) | 75% | 10–20 h | | MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) | | Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — | | FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h | diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..4507944c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -149,12 +149,15 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G10. Container and codec gaps on import -- **Missing:** E-AC-3 (#647; our AC-3 decoder refuses bsid 11–16), AVI (#598; listed as an - extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), - camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, - #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance - HDR, AI / EPS; transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag - (#500); MKV multi-audio (#601). +- **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent + substream, 1–6 blocks per syncframe, spectral extension. +- **Missing:** E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 + dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no + demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D + incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / + JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; + transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV + multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). From e56ddd0e36619d668aff28227ea1c8b21208c66e Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:50:15 +0200 Subject: [PATCH 045/203] #780: Optical Flow time interpolation synthesises motion-compensated frames Problem: a speed-changed clip set to Time Interpolation > Optical Flow rendered exactly like Frame Blending, a cross-fade of the two neighbouring source frames, so moving objects showed double images. Fix: new filmcraft_render::flow. It computes dense coarse-to-fine Lucas-Kanade flow both ways on a luma pyramid at most 640 px wide, drops motion wherever standing still explains the window as well (flicker on static content), forward-splats both flows to the in-between time (Baker et al. 2011), and warps both frames there. Each fetch is weighted by how well its own flow agrees, so uncovered background comes from the frame that shows it. Pixels that look the same in both frames, or where neither fetch agrees, fade to the plain cross-fade. media_image uses it for OpticalFlow clips. docs/gaps.md G18 and target-app-parity updated (also drops the #415 / #458 items, already fixed on main). Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/clip_ops_tests.rs | 8 +- crates/render/src/flow.rs | 625 ++++++++++++++++++++++++++++ crates/render/src/lib.rs | 12 +- crates/render/src/track.rs | 2 +- docs/gaps.md | 10 +- docs/target-app-parity.md | 2 +- 6 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 crates/render/src/flow.rs diff --git a/crates/engine/src/clip_ops_tests.rs b/crates/engine/src/clip_ops_tests.rs index 11707e25..68eb470f 100644 --- a/crates/engine/src/clip_ops_tests.rs +++ b/crates/engine/src/clip_ops_tests.rs @@ -510,10 +510,14 @@ fn frame_blending_renders_in_between_frames() { *a = (*a + b) * 0.5; } assert!(mean_abs_diff(&avg, &blended) < mean_abs_diff(&sampled, &blended), "closer to the average than to either frame"); - // optical flow falls back to blending + // optical flow synthesises a motion-compensated frame instead of the cross-fade s.execute("clip.timeInterpolation.opticalFlow", json!({})).unwrap(); s.execute("playhead.set", json!({"time": t.0})).unwrap(); - assert!(mean_abs_diff(&s.render_program(0.125).unwrap(), &blended) < 1e-6); + let flowed = s.render_program(0.125).unwrap(); + // the moving traffic is motion compensated, the static city stays as blended + let d = mean_abs_diff(&flowed, &blended); + assert!(d > 1e-6, "optical flow should not be the plain cross-fade: {d}"); + assert!(d < mean_abs_diff(&blended, &sampled), "optical flow stays near the blend on mostly static footage: {d}"); // at 100 % nothing is blended s.execute("clip.speedDuration", json!({"speed": 100.0})).unwrap(); assert!(filmcraft_render::interpolation_blend(&clip(&s, c.id.0), t, src_rate).is_none()); diff --git a/crates/render/src/flow.rs b/crates/render/src/flow.rs new file mode 100644 index 00000000..1d80ba13 --- /dev/null +++ b/crates/render/src/flow.rs @@ -0,0 +1,625 @@ +//! Optical Flow time interpolation: the in-between frame of a speed-changed clip synthesised from +//! the motion between its two neighbouring source frames, so a moving object appears once, at its +//! interpolated position, instead of as the double image of Frame Blending. +//! +//! The method (textbook, implemented from the published equations): +//! +//! 1. **Dense flow** — Lucas & Kanade (1981) solved at every pixel: the gradient structure tensor +//! and the mismatch term summed over a (2·[`WIN`]+1)² window (box filter), Gauss–Newton iterated +//! with bilinear warping, Tikhonov-damped so flat areas keep the coarser estimate, coarse to fine +//! over a binomial pyramid of luma at a working width of at most [`WORK_W`] pixels. After each +//! level, motion is dropped wherever standing still matches the window as well (brightness +//! changes on static content are not motion), then a 3×3 median removes outliers. Computed both +//! ways (frame 0 → 1 and 1 → 0). +//! 2. **Flow at the in-between time** — forward splatting (the interpolation of Baker et al., "A +//! Database and Evaluation Methodology for Optical Flow", IJCV 2011): every pixel of frame 0 +//! lands at `p + t·u`, every pixel of frame 1 at `p + (1−t)·u'`; where several land on one +//! pixel the one whose flow is forward–backward consistent and photometrically best wins; holes +//! are filled from their neighbours. +//! 3. **Warp and blend** — each output pixel fetches frame 0 at `x − t·u` and frame 1 at +//! `x + (1−t)·u`, weighted `(1−t, t)` times how well the fetched pixel's own flow agrees with +//! `u` (a pixel that moves elsewhere is hidden at time `t`), so background an object uncovers +//! comes from the frame that shows it. Where neither fetch agrees, or the pixel looks the same in +//! both frames, it fades to the plain cross-fade (Frame Blending). +//! +//! Everything is deterministic (no randomness, per-pixel parallel work only). + +use rayon::prelude::*; + +use crate::image::Image; +use crate::track::Gray; + +/// Widest luma image the flow is estimated on (larger frames are box-reduced to it first). +pub const WORK_W: usize = 640; +/// Window radius of the per-pixel Lucas–Kanade sums. +pub const WIN: usize = 4; +/// Pyramid levels stop above this size (either dimension). +const MIN_LEVEL: usize = 12; +const MAX_LEVELS: usize = 7; +/// Gauss–Newton iterations per pyramid level. +const ITERS: usize = 6; +/// Gradient noise floor (square-root luma per pixel): texture weaker than this, summed over the +/// window, is not trusted to show motion. Damps the structure tensor (Tikhonov). +const NOISE: f32 = 0.01; +const DAMP: f32 = ((2 * WIN + 1) * (2 * WIN + 1)) as f32 * NOISE * NOISE; +/// Largest flow update per iteration (level pixels). +const MAX_STEP: f32 = 2.0; +/// Weight of the forward–backward error (pixels) against the luma error when splats collide. +const FB_WEIGHT: f32 = 0.05; +/// Flow disagreement (working pixels) at which a fetch counts half. +const AGREE_PX: f32 = 1.0; +/// Luma change (square-root luma) below which a pixel counts as unchanged between the frames; +/// the motion-compensated result fades in over the next `STILL_LUMA`. +const STILL_LUMA: f32 = 0.004; +/// Hole-filling passes after splatting (remaining holes keep zero motion). +const FILL_PASSES: usize = 64; + +/// A dense flow field on a working-resolution grid (pixel centres at integers). +#[derive(Clone, Debug, PartialEq)] +struct Flow { + w: usize, + h: usize, + uv: Vec<[f32; 2]>, +} + +impl Flow { + fn zero(w: usize, h: usize) -> Self { + Self { w, h, uv: vec![[0.0; 2]; w.saturating_mul(h)] } + } + #[inline] + fn at(&self, x: i64, y: i64) -> [f32; 2] { + if self.w == 0 || self.h == 0 { + return [0.0; 2]; + } + let x = x.clamp(0, self.w as i64 - 1) as usize; + let y = y.clamp(0, self.h as i64 - 1) as usize; + self.uv.get(y * self.w + x).copied().unwrap_or([0.0; 2]) + } + /// Bilinear sample, clamped at the edges. + #[inline] + fn sample(&self, x: f32, y: f32) -> [f32; 2] { + let (x, y) = (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }); + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = (x - x0, y - y0); + let (x0, y0) = (x0 as i64, y0 as i64); + let (a, b, c, d) = (self.at(x0, y0), self.at(x0 + 1, y0), self.at(x0, y0 + 1), self.at(x0 + 1, y0 + 1)); + let mut o = [0.0; 2]; + for k in 0..2 { + let top = a[k] + (b[k] - a[k]) * fx; + let bot = c[k] + (d[k] - c[k]) * fx; + o[k] = top + (bot - top) * fy; + } + o + } +} + +/// Clamped fetch from a luma image. +#[inline] +fn g_at(g: &Gray, x: i64, y: i64) -> f32 { + if g.w == 0 || g.h == 0 { + return 0.0; + } + let x = x.clamp(0, g.w as i64 - 1) as usize; + let y = y.clamp(0, g.h as i64 - 1) as usize; + g.px.get(y * g.w + x).copied().unwrap_or(0.0) +} + +/// Bilinear luma sample (pixel centres at integers), clamped; non-finite coordinates read the origin. +#[inline] +fn g_sample(g: &Gray, x: f32, y: f32) -> f32 { + let (x, y) = (if x.is_finite() { x } else { 0.0 }, if y.is_finite() { y } else { 0.0 }); + let (x0, y0) = (x.floor(), y.floor()); + let (fx, fy) = (x - x0, y - y0); + let (x0, y0) = (x0 as i64, y0 as i64); + let top = g_at(g, x0, y0) + (g_at(g, x0 + 1, y0) - g_at(g, x0, y0)) * fx; + let bot = g_at(g, x0, y0 + 1) + (g_at(g, x0 + 1, y0 + 1) - g_at(g, x0, y0 + 1)) * fx; + top + (bot - top) * fy +} + +/// Perceptual-ish luma of a premultiplied linear pixel (square root lifts the shadows so their +/// edges carry gradient; HDR highlights are capped). +#[inline] +fn luma(p: [f32; 4]) -> f32 { + let l = 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]; + if l.is_finite() { l.clamp(0.0, 16.0).sqrt() } else { 0.0 } +} + +/// Luma of `img` box-reduced by `s` (≥ 1) in each direction. +fn work_luma(img: &Image, s: usize) -> Gray { + let s = s.max(1); + let (w, h) = (img.w.div_ceil(s).max(1), img.h.div_ceil(s).max(1)); + let mut px = vec![0.0f32; w * h]; + px.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + for (x, o) in row.iter_mut().enumerate() { + let (mut sum, mut n) = (0.0f32, 0u32); + for yy in y * s..((y + 1) * s).min(img.h) { + for xx in x * s..((x + 1) * s).min(img.w) { + let i = (yy * img.w + xx) * 4; + if let Some(p) = img.px.get(i..i + 4) { + sum += luma([p[0], p[1], p[2], p[3]]); + n += 1; + } + } + } + *o = if n > 0 { sum / n as f32 } else { 0.0 }; + } + }); + Gray { w, h, px } +} + +/// Binomial pyramid, finest first. +fn pyramid(g: Gray) -> Vec { + let mut v = vec![g]; + while v.len() < MAX_LEVELS { + let Some(l) = v.last() else { break }; + if l.w < 2 * MIN_LEVEL || l.h < 2 * MIN_LEVEL { + break; + } + let n = l.down(); + v.push(n); + } + v +} + +/// Sum of `src` over a (2r+1)² window at every pixel (truncated at the borders). +fn box_sum(w: usize, h: usize, src: &[[f32; N]], r: usize) -> Vec<[f32; N]> { + if w == 0 || h == 0 || src.len() != w * h { + return vec![[0.0; N]; src.len()]; + } + // horizontal pass: prefix sums per row (f64, so differences of large sums stay exact) + let mut tmp = vec![[0.0f64; N]; w * h]; + tmp.par_chunks_mut(w).zip(src.par_chunks(w)).for_each(|(o, s)| { + let mut pre = vec![[0.0f64; N]; w + 1]; + for x in 0..w { + for k in 0..N { + pre[x + 1][k] = pre[x][k] + s[x][k] as f64; + } + } + for (x, ox) in o.iter_mut().enumerate() { + let (lo, hi) = (x.saturating_sub(r), (x + r + 1).min(w)); + for k in 0..N { + ox[k] = pre[hi][k] - pre[lo][k]; + } + } + }); + // vertical pass: column prefix sums, then one output row per task + let mut pre = vec![[0.0f64; N]; w * (h + 1)]; + for y in 0..h { + for x in 0..w { + for k in 0..N { + pre[(y + 1) * w + x][k] = pre[y * w + x][k] + tmp[y * w + x][k]; + } + } + } + let mut out = vec![[0.0f32; N]; w * h]; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let (lo, hi) = (y.saturating_sub(r), (y + r + 1).min(h)); + for (x, o) in row.iter_mut().enumerate() { + for k in 0..N { + o[k] = (pre[hi * w + x][k] - pre[lo * w + x][k]) as f32; + } + } + }); + out +} + +/// Gauss–Newton refinement of `flow` (mapping `a` onto `b`: `a(x) ≈ b(x + u)`) at one level. +fn refine(a: &Gray, b: &Gray, flow: &mut Flow) { + let (w, h) = (a.w, a.h); + if w == 0 || h == 0 || a.px.len() != w * h || flow.uv.len() != w * h || (flow.w, flow.h) != (w, h) { + return; + } + let grad: Vec<[f32; 2]> = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + [(g_at(a, x + 1, y) - g_at(a, x - 1, y)) * 0.5, (g_at(a, x, y + 1) - g_at(a, x, y - 1)) * 0.5] + }) + .collect(); + let products: Vec<[f32; 3]> = grad.iter().map(|g| [g[0] * g[0], g[0] * g[1], g[1] * g[1]]).collect(); + let tensor = box_sum(w, h, &products, WIN); + for _ in 0..ITERS { + let mismatch: Vec<[f32; 2]> = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let u = flow.uv[i]; + let e = g_sample(b, x + u[0], y + u[1]) - a.px[i]; + [grad[i][0] * e, grad[i][1] * e] + }) + .collect(); + let bsum = box_sum(w, h, &mismatch, WIN); + flow.uv.par_iter_mut().zip(tensor.par_iter().zip(bsum.par_iter())).for_each(|(f, (g, bv))| { + let (gxx, gxy, gyy) = (g[0] + DAMP, g[1], g[2] + DAMP); + let det = gxx * gyy - gxy * gxy; + if det > 1e-12 { + let dx = -(gyy * bv[0] - gxy * bv[1]) / det; + let dy = -(gxx * bv[1] - gxy * bv[0]) / det; + if dx.is_finite() && dy.is_finite() { + f[0] += dx.clamp(-MAX_STEP, MAX_STEP); + f[1] += dy.clamp(-MAX_STEP, MAX_STEP); + } + } + }); + } +} + +/// Where standing still explains the window at least as well as the estimated motion, the +/// motion is dropped: brightness changes (flicker, lights switching, noise) on static content +/// otherwise read as motion and would drag static detail around. +fn prefer_still(a: &Gray, b: &Gray, flow: &mut Flow) { + let (w, h) = (a.w, a.h); + if w == 0 || h == 0 || a.px.len() != w * h || b.px.len() != w * h || flow.uv.len() != w * h { + return; + } + let errs: Vec<[f32; 2]> = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as f32, (i / w) as f32); + let u = flow.uv[i]; + let moved = g_sample(b, x + u[0], y + u[1]) - a.px[i]; + let still = b.px[i] - a.px[i]; + [moved * moved, still * still] + }) + .collect(); + let sums = box_sum(w, h, &errs, WIN); + let slack = ((2 * WIN + 1) * (2 * WIN + 1)) as f32 * NOISE * NOISE * 0.25; + flow.uv.par_iter_mut().zip(sums.par_iter()).for_each(|(f, e)| { + if e[1] <= e[0] + slack { + *f = [0.0; 2]; + } + }); +} + +/// 3×3 median of each flow component (removes isolated outliers, keeps motion edges). +fn median3(f: &Flow) -> Flow { + let (w, h) = (f.w, f.h); + let uv = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = ((i % w) as i64, (i / w) as i64); + let mut xs = [0.0f32; 9]; + let mut ys = [0.0f32; 9]; + let mut k = 0; + for dy in -1..=1 { + for dx in -1..=1 { + let v = f.at(x + dx, y + dy); + xs[k] = v[0]; + ys[k] = v[1]; + k += 1; + } + } + xs.sort_by(f32::total_cmp); + ys.sort_by(f32::total_cmp); + [xs[4], ys[4]] + }) + .collect(); + Flow { w, h, uv } +} + +/// `f` on a grid twice as fine (`w`×`h`), its vectors doubled. +fn upsample(f: &Flow, w: usize, h: usize) -> Flow { + let uv = (0..w * h) + .into_par_iter() + .map(|i| { + let v = f.sample((i % w) as f32 * 0.5, (i / w) as f32 * 0.5); + [v[0] * 2.0, v[1] * 2.0] + }) + .collect(); + Flow { w, h, uv } +} + +/// Coarse-to-fine dense flow from `pa[0]` to `pb[0]` (pyramids of equal-sized images). +fn dense_flow(pa: &[Gray], pb: &[Gray]) -> Option { + let levels = pa.len().min(pb.len()); + let mut flow: Option = None; + for l in (0..levels).rev() { + let (a, b) = (pa.get(l)?, pb.get(l)?); + if (a.w, a.h) != (b.w, b.h) { + return None; + } + let mut f = match &flow { + Some(c) => upsample(c, a.w, a.h), + None => Flow::zero(a.w, a.h), + }; + refine(a, b, &mut f); + prefer_still(a, b, &mut f); + flow = Some(median3(&f)); + } + flow +} + +/// The flow (in the 0 → 1 direction) of the surface seen at each pixel at time `t`, by forward +/// splatting both flows (see the module docs). +fn flow_at(a: &Gray, b: &Gray, f01: &Flow, f10: &Flow, t: f32) -> Flow { + let (w, h) = (a.w, a.h); + let n = w * h; + if n == 0 || [b.px.len(), f01.uv.len(), f10.uv.len(), a.px.len()].iter().any(|&l| l != n) { + return Flow::zero(w, h); + } + let mut best = vec![f32::INFINITY; n]; + let mut out = vec![[0.0f32; 2]; n]; + // frame 0 moves forward by t·u01, frame 1 backward by (1−t)·u10 (= forward flow −u10) + for (src, dst, f, back, tt, sign) in [(a, b, f01, f10, t, 1.0f32), (b, a, f10, f01, 1.0 - t, -1.0f32)] { + for y in 0..h { + for x in 0..w { + let i = y * w + x; + let u = f.uv[i]; + let (qx, qy) = (x as f32 + u[0], y as f32 + u[1]); + let photo = (src.px[i] - g_sample(dst, qx, qy)).abs(); + let r = back.sample(qx, qy); + let fb = ((u[0] + r[0]).powi(2) + (u[1] + r[1]).powi(2)).sqrt().min(100.0); + let score = photo + FB_WEIGHT * fb; + if !score.is_finite() { + continue; + } + let (tx, ty) = (x as f32 + tt * u[0], y as f32 + tt * u[1]); + if !(tx > -1.0 && ty > -1.0 && tx < w as f32 && ty < h as f32) { + continue; + } + let (x0, y0) = (tx.floor() as i64, ty.floor() as i64); + for (sx, sy) in [(x0, y0), (x0 + 1, y0), (x0, y0 + 1), (x0 + 1, y0 + 1)] { + if sx < 0 || sy < 0 || sx >= w as i64 || sy >= h as i64 { + continue; + } + let j = sy as usize * w + sx as usize; + if score < best[j] { + best[j] = score; + out[j] = [sign * u[0], sign * u[1]]; + } + } + } + } + } + // fill holes from their filled neighbours + let mut valid: Vec = best.iter().map(|s| s.is_finite()).collect(); + for _ in 0..FILL_PASSES { + let (prev, prev_valid) = (out.clone(), valid.clone()); + let mut changed = false; + for y in 0..h { + for x in 0..w { + let i = y * w + x; + if prev_valid[i] { + continue; + } + let (mut sum, mut k) = ([0.0f32; 2], 0u32); + for (dx, dy) in [(-1i64, -1i64), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)] { + let (nx, ny) = (x as i64 + dx, y as i64 + dy); + if nx < 0 || ny < 0 || nx >= w as i64 || ny >= h as i64 { + continue; + } + let j = ny as usize * w + nx as usize; + if prev_valid[j] { + sum = [sum[0] + prev[j][0], sum[1] + prev[j][1]]; + k += 1; + } + } + if k > 0 { + out[i] = [sum[0] / k as f32, sum[1] / k as f32]; + valid[i] = true; + changed = true; + } + } + } + if !changed { + break; + } + } + Flow { w, h, uv: out } +} + +#[inline] +fn agree(d: f32) -> f32 { + let q = d / AGREE_PX; + 1.0 / (1.0 + q * q) +} + +/// The frame between `a` (time 0) and `b` (time 1) at `t` (0..1), motion compensated. Frames of +/// different sizes (or empty ones) fall back to the plain cross-fade. +pub fn interpolate(a: &Image, b: &Image, t: f32) -> Image { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + let n = a.w.saturating_mul(a.h).saturating_mul(4); + if (a.w, a.h) != (b.w, b.h) || a.w == 0 || a.h == 0 || a.px.len() != n || b.px.len() != n { + return a.clone().lerp(b, t); + } + if t <= 0.0 { + return a.clone(); + } + if t >= 1.0 { + return b.clone(); + } + let s = a.w.div_ceil(WORK_W).max(1); + let (pa, pb) = (pyramid(work_luma(a, s)), pyramid(work_luma(b, s))); + let (Some(ga), Some(gb), Some(f01), Some(f10)) = (pa.first(), pb.first(), dense_flow(&pa, &pb), dense_flow(&pb, &pa)) else { + return a.clone().lerp(b, t); + }; + let ft = flow_at(ga, gb, &f01, &f10, t); + let sf = s as f32; + let to_work = |v: f32| v / sf - 0.5; + let mut out = Image::new(a.w, a.h); + out.px.par_chunks_mut(a.w * 4).enumerate().for_each(|(y, row)| { + for (x, o) in row.as_chunks_mut::<4>().0.iter_mut().enumerate() { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + let u = ft.sample(to_work(px), to_work(py)); + let (ux, uy) = (u[0] * sf, u[1] * sf); + let (x0, y0) = (px - t * ux, py - t * uy); + let (x1, y1) = (px + (1.0 - t) * ux, py + (1.0 - t) * uy); + // does the fetched pixel itself move along this trajectory? + let ua = f01.sample(to_work(x0), to_work(y0)); + let ub = f10.sample(to_work(x1), to_work(y1)); + let ka = agree(((ua[0] - u[0]).powi(2) + (ua[1] - u[1]).powi(2)).sqrt()); + let kb = agree(((ub[0] + u[0]).powi(2) + (ub[1] + u[1]).powi(2)).sqrt()); + let (wa, wb) = ((1.0 - t) * ka, t * kb); + let ca = a.sample_bilinear_clamped(x0, y0); + let cb = b.sample_bilinear_clamped(x1, y1); + let i = (y * a.w + x) * 4; + let (Some(pa), Some(pb)) = (a.px.get(i..i + 4), b.px.get(i..i + 4)) else { continue }; + let sum = wa + wb; + // a pixel that looks the same in both frames needs no motion compensation (static + // content, flat interiors): the cross-fade is right there and immune to flow errors + let changed = (luma([pa[0], pa[1], pa[2], 0.0]) - luma([pb[0], pb[1], pb[2], 0.0])).abs().max((pa[3] - pb[3]).abs()); + let changed = if changed.is_finite() { ((changed - STILL_LUMA) / STILL_LUMA).clamp(0.0, 1.0) } else { 0.0 }; + let conf = ka.max(kb) * changed; + for k in 0..4 { + let warped = if sum > 1e-6 { (ca[k] * wa + cb[k] * wb) / sum } else { ca[k] * (1.0 - t) + cb[k] * t }; + let plain = pa[k] + (pb[k] - pa[k]) * t; + o[k] = plain + (warped - plain) * conf; + } + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A smooth, textured, premultiplied opaque picture sampled with the content offset by `(dx, dy)`. + fn texture(w: usize, h: usize, dx: f32, dy: f32) -> Image { + let mut img = Image::new(w, h); + for y in 0..h { + for x in 0..w { + let (u, v) = (x as f32 + 0.5 - dx, y as f32 + 0.5 - dy); + let l = 0.35 + 0.15 * (u * 0.21).sin() * (v * 0.17).cos() + 0.1 * ((u + v) * 0.11).sin() + 0.05 * (u * 0.53 - v * 0.31).cos(); + let i = (y * w + x) * 4; + img.px[i..i + 4].copy_from_slice(&[l, l * 0.9, l * 0.8, 1.0]); + } + } + img + } + + /// A flat grey background with a white square of side `side` whose left/top edge is at `(x0, y0)`. + fn square(w: usize, h: usize, x0: usize, y0: usize, side: usize) -> Image { + let mut img = Image::filled(w, h, [0.1, 0.1, 0.1, 1.0]); + for y in y0..(y0 + side).min(h) { + for x in x0..(x0 + side).min(w) { + let i = (y * w + x) * 4; + img.px[i..i + 4].copy_from_slice(&[1.0, 1.0, 1.0, 1.0]); + } + } + img + } + + /// Mean absolute difference over the pixels at least `margin` from every edge. + fn interior_diff(a: &Image, b: &Image, margin: usize) -> f32 { + let (mut s, mut n) = (0.0f32, 0usize); + for y in margin..a.h - margin { + for x in margin..a.w - margin { + let (p, q) = (a.get(x, y), b.get(x, y)); + s += (0..3).map(|k| (p[k] - q[k]).abs()).sum::(); + n += 3; + } + } + s / n as f32 + } + + #[test] + fn uniform_motion_lands_half_way() { + let (a, b) = (texture(160, 96, 0.0, 0.0), texture(160, 96, 6.0, -3.0)); + let want = texture(160, 96, 3.0, -1.5); + let flow = interpolate(&a, &b, 0.5); + let blend = a.clone().lerp(&b, 0.5); + let (e_flow, e_blend) = (interior_diff(&flow, &want, 12), interior_diff(&blend, &want, 12)); + assert!(e_flow < 0.006, "optical flow off the half-way picture: {e_flow}"); + assert!(e_flow * 5.0 < e_blend, "optical flow {e_flow} should beat frame blending {e_blend}"); + } + + #[test] + fn quarter_way_follows_the_time() { + let (a, b) = (texture(160, 96, 0.0, 0.0), texture(160, 96, 8.0, 4.0)); + let want = texture(160, 96, 2.0, 1.0); + let e = interior_diff(&interpolate(&a, &b, 0.25), &want, 12); + assert!(e < 0.006, "{e}"); + } + + #[test] + fn a_moving_square_appears_once_at_its_interpolated_place() { + // a 24 px square moving 10 px right: Frame Blending shows two half-bright copies + let (a, b) = (square(128, 96, 40, 36, 24), square(128, 96, 50, 36, 24)); + let want = square(128, 96, 45, 36, 24); + let flow = interpolate(&a, &b, 0.5); + let blend = a.clone().lerp(&b, 0.5); + // the leading and trailing bands are where blending leaves ghosts + for x in [42usize, 71] { + let (f, bl, w) = (flow.get(x, 48)[0], blend.get(x, 48)[0], want.get(x, 48)[0]); + assert!((f - w).abs() < 0.1, "x={x}: flow {f}, want {w} (blend {bl})"); + assert!((bl - w).abs() > 0.3, "x={x}: the blend should ghost here"); + } + assert!(flow.get(57, 48)[0] > 0.95, "the square's centre stays solid"); + let (e_flow, e_blend) = (interior_diff(&flow, &want, 4), interior_diff(&blend, &want, 4)); + assert!(e_flow * 4.0 < e_blend, "flow {e_flow} vs blend {e_blend}"); + } + + #[test] + fn flicker_on_static_content_is_not_dragged_around() { + // static rows of lit windows; between the frames some switch on and some grow a pixel: + // brightness change, not motion, so the result is the plain cross-fade + let (w, h) = (160usize, 96usize); + let mut a = Image::filled(w, h, [0.004, 0.004, 0.006, 1.0]); + for row in 0..8 { + for col in 0..10 { + let (x0, y0) = (6 + col * 15, 6 + row * 11); + for x in x0..x0 + 8 { + for y in y0..y0 + 2 { + let i = (y * w + x) * 4; + a.px[i..i + 4].copy_from_slice(&[0.6, 0.45, 0.1, 1.0]); + } + } + } + } + let mut b = a.clone(); + for (k, i) in (0..w * h).step_by(97).enumerate() { + let lit = if k % 2 == 0 { [0.6, 0.45, 0.1, 1.0] } else { [0.004, 0.004, 0.006, 1.0] }; + b.px[i * 4..i * 4 + 4].copy_from_slice(&lit); + } + let flow = interpolate(&a, &b, 0.5); + let blend = a.clone().lerp(&b, 0.5); + let e = interior_diff(&flow, &blend, 0); + assert!(e < 1e-3, "static detail moved: {e}"); + } + + #[test] + fn identical_frames_are_returned_unchanged() { + let a = texture(64, 48, 0.0, 0.0); + let out = interpolate(&a, &a.clone(), 0.37); + assert!(interior_diff(&out, &a, 0) < 1e-5); + } + + #[test] + fn large_frames_estimate_flow_at_the_working_size() { + // wider than WORK_W: the flow is estimated on a reduced copy and scaled back up + let (a, b) = (texture(1400, 120, 0.0, 0.0), texture(1400, 120, 12.0, 0.0)); + let want = texture(1400, 120, 6.0, 0.0); + let e = interior_diff(&interpolate(&a, &b, 0.5), &want, 24); + assert!(e < 0.01, "{e}"); + } + + #[test] + fn hostile_inputs_do_not_panic() { + let a = texture(32, 24, 0.0, 0.0); + // size mismatch and empty frames fall back to the cross-fade + let small = texture(16, 24, 0.0, 0.0); + assert_eq!(interpolate(&a, &small, 0.5), a.clone().lerp(&small, 0.5)); + let empty = Image::new(0, 0); + assert_eq!(interpolate(&empty, &empty, 0.5).px.len(), 0); + // a pixel buffer that does not match its size + let broken = Image { w: 32, h: 24, px: vec![0.0; 7] }; + assert_eq!(interpolate(&broken, &a, 0.5).w, 32); + // end points and non-finite weights + assert_eq!(interpolate(&a, &small.clone(), f32::NAN).w, 32); + let b = texture(32, 24, 2.0, 1.0); + assert_eq!(interpolate(&a, &b, 0.0), a); + assert_eq!(interpolate(&a, &b, 7.0), b); + assert_eq!(interpolate(&a, &b, f32::NAN), a); + // NaN / infinite pixels and tiny frames + let mut nan = b.clone(); + nan.px[40] = f32::NAN; + nan.px[41] = f32::INFINITY; + nan.px[45] = -f32::INFINITY; + assert_eq!(interpolate(&a, &nan, 0.5).px.len(), a.px.len()); + for (w, h) in [(1, 1), (2, 1), (1, 3), (3, 2)] { + let (p, q) = (texture(w, h, 0.0, 0.0), texture(w, h, 1.0, 0.0)); + assert_eq!(interpolate(&p, &q, 0.5).px.len(), w * h * 4); + } + } +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 1d6ff3c4..22d6629f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -15,6 +15,7 @@ pub mod blend; pub mod color_match; pub mod colorman; pub mod effects; +pub mod flow; pub mod gpufx; pub mod graphic_clip; pub mod graphics; @@ -535,8 +536,8 @@ pub(crate) fn video_source_time(item: &TrackItem, t: Tick, media_rate: filmcraft media_rate.tick_of(media_rate.frame_at(ft)).clamp(item.source_in.min(ft), ft) } -/// The linear image of a media clip's `frame`, colour managed, and blended with the next frame when -/// the clip's speed asks for it. +/// The linear image of a media clip's `frame`, colour managed, and blended (or, with Optical Flow, +/// motion interpolated) with the next frame when the clip's speed asks for it. #[allow(clippy::too_many_arguments)] fn media_image(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, src: &SharedSource, frame: &VideoFrame, n: usize, want: f32) -> Image { let img = colorman::decode(project, item.item, frame, n, &seq.settings.color); @@ -544,7 +545,7 @@ fn media_image(project: &Project, seq: &Sequence, item: &TrackItem, t: Tick, src Some((next_time, wgt)) => match src.video_frame(FrameRequest { time: next_time, scale: want }) { Ok(f2) if f2.width == frame.width && f2.height == frame.height => { let b = colorman::decode(project, item.item, &f2, n, &seq.settings.color); - img.lerp(&b, wgt) + if item.time_interpolation == filmcraft_project::TimeInterpolation::OpticalFlow { flow::interpolate(&img, &b, wgt) } else { img.lerp(&b, wgt) } } _ => img, }, @@ -618,9 +619,8 @@ pub fn render_clip( /// Frame Blending / Optical Flow on a speed-changed clip: the later source frame to mix in and /// its weight (0..1) at timeline `t`, or None when the exact media time falls on a source frame -/// (or the clip plays at 100 %, is frame-held, or uses Frame Sampling). -/// -/// TODO(optical flow): motion-compensated interpolation; Optical Flow renders as Frame Blending. +/// (or the clip plays at 100 %, is frame-held, or uses Frame Sampling). Frame Blending cross-fades +/// the two frames; Optical Flow synthesises the in-between frame from their motion ([`flow`]). pub fn interpolation_blend(item: &TrackItem, t: Tick, src_rate: filmcraft_time::FrameRate) -> Option<(Tick, f32)> { if item.time_interpolation == filmcraft_project::TimeInterpolation::FrameSampling || item.frame_hold.is_some() { return None; diff --git a/crates/render/src/track.rs b/crates/render/src/track.rs index 06ecfa76..aa07c45c 100644 --- a/crates/render/src/track.rs +++ b/crates/render/src/track.rs @@ -71,7 +71,7 @@ impl Gray { top + (bot - top) * fy } /// 5-tap binomial blur then 2× decimation. - fn down(&self) -> Gray { + pub(crate) fn down(&self) -> Gray { let k = [1.0, 4.0, 6.0, 4.0, 1.0].map(|v: f32| v / 16.0); let (w, h) = (self.w, self.h); let mut tmp = vec![0.0f32; w * h]; diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..883ae8eb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -215,10 +215,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G18. Effects depth: approximations and missing behaviour -- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, optical - flow renders as frame blending, Transform shutter angle adds no motion blur (#415), Ultra Key - Setting scales only two factors (#458), Lumetri HDR-aware maths, multicam paging beyond 16 - angles, redesigned 26.0 mask tools (rounded corners, constrained lines). +- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri + HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (rounded corners, + constrained lines). +- **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense + coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key + Setting applied as a parameter preset (#458). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 29b2f706..335bef34 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -104,7 +104,7 @@ agent-hours to close the area to Premiere, sequential. | Area | Weight | Breadth | Ready | Hours | Evidence | Detail | |---|---|---|---|---|---|---| | Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) | -| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate, optical flow renders as frame blending. 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). Transform shutter angle adds no motion blur (#415); Ultra Key setting (#458) | [hardware-parity.md](hardware-parity.md) | +| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate; Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) | | Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) | | Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | | | Panels and UI fidelity | 10% | ~88% | ~60% | 120–200 | All main panels. Missing: on-screen transform handles in the Program monitor (#639), Effect Controls keyframe zoom / divider / row lines (#640–#645), docking rearrangement (#493), multi-monitor windows (#466), filmstrip thumbnails (#614), marquee in Project panel (#578) | [ui-parity.md](ui-parity.md) | From 022e645a91a25f98894a6da1555bccc18075b430 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:50:20 +0700 Subject: [PATCH 046/203] fix(playback): keep audio during forward shuttle speeds (#771) --- crates/ui-egui/src/lib.rs | 53 ++++++++++++++++++++++++++++++++------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38ee823c..5b6208ed 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -954,19 +954,19 @@ impl FilmcraftApp { } fn start_audio(&mut self) { - let speed = self.playback.speed; - if (speed - 1.0).abs() > 1e-9 { - if let Some(a) = self.audio.as_mut() { - a.stop(); - } - return; - } let Some(seq_id) = self.session.state.active_sequence else { return }; let start_tick = self.session.playhead(); let Some(a) = self.audio.as_mut() else { return }; let sr = a.sample_rate(); - let cursor = start_tick.to_units_floor(sr as i64); - let mix = playback_mix(&self.session, seq_id, sr); + // Mix at a lower effective rate for forward shuttle playback, then hand the + // samples to the device at its real rate. This keeps sound on the same + // timeline as the accelerated picture (with the expected pitch shift). + let Some(mix_rate) = shuttle_mix_rate(sr, self.playback.speed) else { + a.stop(); + return; + }; + let cursor = start_tick.to_units_floor(i64::from(mix_rate)); + let mix = playback_mix(&self.session, seq_id, mix_rate); // the old stream stops before the new mixer resets the underrun counters a.stop(); // Desktop: mix ahead on a thread so the device callback never waits on decoding. @@ -1989,6 +1989,20 @@ impl eframe::App for FilmcraftApp { } } +/// Effective sample rate for shuttle playback. The audio device still runs at `device_rate`, +/// so at 2x its output samples represent twice the timeline duration. Reverse and +/// extreme shuttle speeds remain silent rather than asking the mixer for invalid rates. +fn shuttle_mix_rate(device_rate: u32, speed: f64) -> Option { + if device_rate == 0 || !speed.is_finite() || !(0.5..=4.0).contains(&speed) { + return None; + } + let rate = (f64::from(device_rate) / speed).round(); + if !(1.0..=f64::from(u32::MAX)).contains(&rate) { + return None; + } + Some(rate as u32) +} + /// The program mix that playback plays: `mix(device_frame, interleaved, channels)` renders the /// active sequence at device rate `sr` from `device_frame`, with the live project snapshot (edits /// made while playing are heard), Output Mapping, the 5.1 mixdown and voice-over cues. @@ -2185,6 +2199,27 @@ mod audio_recovery_tests { } } + #[test] + fn forward_shuttle_playback_keeps_audio_enabled() { + assert_eq!(shuttle_mix_rate(48_000, 1.0), Some(48_000)); + assert_eq!(shuttle_mix_rate(48_000, 2.0), Some(24_000)); + assert_eq!(shuttle_mix_rate(48_000, 4.0), Some(12_000)); + assert_eq!(shuttle_mix_rate(48_000, -1.0), None); + assert_eq!(shuttle_mix_rate(0, 2.0), None); + assert_eq!(shuttle_mix_rate(48_000, f64::NAN), None); + + let mut session = Session::default(); + session.execute("file.newSequence", json!({"width":16,"height":16})).unwrap(); + let mut app = FilmcraftApp::new(session); + let starts = Arc::new(AtomicUsize::new(0)); + app.audio = Some(Box::new(Device { ready: true, starts: starts.clone(), reading: Some(0) })); + app.play(2.0); + app.end_preroll(0.0); + assert_eq!(starts.load(Ordering::Relaxed), 1, "2x playback must start audio output"); + assert!(app.playback.audio_clock, "the device is still the master clock"); + app.stop(); + } + #[test] fn changing_hardware_recovers_wall_clock_playback_without_rewinding() { let mut session = Session::default(); From c673ee85b99dac66db2d54f0b5bf016e69671feb Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:51:41 +0200 Subject: [PATCH 047/203] #779: export JPEG, Targa and DPX image sequences Problem: Premiere offers JPEG, Targa and DPX as image-sequence export formats; FilmCraft only wrote PNG, TIFF and BMP sequences (docs/gaps.md G13). Fix: three new export formats (ids jpg, tga, dpx), numbered like the other sequences, with presets, the export UI (JPEG quality slider, Targa alpha) and es/ja/zh-cn strings. JPEG uses the image crate at the export quality; Targa (TGA 2.0, uncompressed 24/32-bit) and DPX (SMPTE ST 268, 10-bit RGB method A, BT.709) are written by a small hostile-input-safe writer in crates/export/src/raster.rs. Format::is_image_sequence replaces the hand-kept format lists. OpenEXR stays open. Co-Authored-By: Claude Opus 5.5 --- crates/automation/src/long_job.rs | 4 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 5 +- crates/engine/src/export_tools.rs | 8 +- crates/export/src/lib.rs | 67 +++++++-- crates/export/src/presets.rs | 8 + crates/export/src/raster.rs | 179 +++++++++++++++++++++++ crates/export/src/settings.rs | 18 ++- crates/export/src/settings_tests.rs | 53 ++++++- crates/ui-egui/src/i18n/es.tsv | 9 ++ crates/ui-egui/src/i18n/ja.tsv | 9 ++ crates/ui-egui/src/i18n/zh-cn.tsv | 9 ++ crates/ui-egui/src/panels/export_mode.rs | 2 +- docs/architecture.md | 3 +- docs/file-format-parity.md | 6 +- docs/gaps.md | 3 +- 16 files changed, 357 insertions(+), 28 deletions(-) create mode 100644 crates/export/src/raster.rs diff --git a/crates/automation/src/long_job.rs b/crates/automation/src/long_job.rs index 8d4035ab..62549b90 100644 --- a/crates/automation/src/long_job.rs +++ b/crates/automation/src/long_job.rs @@ -108,7 +108,7 @@ impl FilmcraftMcp { /// Delete what an interrupted export wrote, and nothing else: the output file itself, the numbered /// stills of an image sequence (`<3+ digits>.`, only when the output is a -/// PNG/TIFF/BMP sequence) and the caption sidecar +/// PNG/TIFF/BMP/JPEG/Targa/DPX sequence) and the caption sidecar /// (`.srt` / `.vtt`), each only if it was written since `since`. Other files that /// merely share the name's stem (`.aep`, `2.psd`, `1.mp4` beside a movie, …) are /// never touched, even when they were saved during the export. @@ -152,7 +152,7 @@ fn is_export_output(name: &str, file: &str, stem: &str, ext: &str) -> bool { /// Whether an export to a file with extension `ext` writes an image sequence (one still per frame). fn is_sequence_extension(ext: &str) -> bool { use filmcraft_engine::export::Format; - Format::from_name(ext).is_some_and(|f| matches!(f, Format::PngSequence | Format::TiffSequence | Format::BmpSequence)) + Format::from_name(ext).is_some_and(Format::is_image_sequence) } #[cfg(test)] diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index d272c41f..c0494d1d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1268,7 +1268,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index e0d58794..6a8c5d71 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -137,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()), None => (1920, 1080), }; - if matches!(fmt.as_str(), "png" | "tiff" | "bmp") { + if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx") { let files = files_in(&sub); let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string(); assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps"); @@ -148,6 +148,9 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { let codec = match fmt.as_str() { "png" => "png", "tiff" => "tiff", + "jpg" => "mjpeg", + "tga" => "targa", + "dpx" => "dpx", _ => "bmp", }; assert_eq!(v["codec_name"], codec, "{name}"); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 5c4d875b..9e37a780 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -360,7 +360,13 @@ fn file_safe(n: &str) -> String { fn with_extension(path: &str, settings: &ExportSettings) -> String { let ext = settings.extension(); match Path::new(path).extension().and_then(|e| e.to_str()) { - Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) => path.to_string(), + Some(e) + if e.eq_ignore_ascii_case(ext) + || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) + || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) => + { + path.to_string() + } Some(_) if settings.is_image_sequence() || matches!(settings.format, Format::Aiff) => format!("{}.{ext}", path.rsplit_once('.').map_or(path, |x| x.0)), _ => format!("{path}.{ext}"), } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 57f8850b..279384f2 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -5,8 +5,8 @@ //! are shared atomics so the UI (Export mode, header progress) and MCP can observe/cancel jobs. //! //! Video encoders implement [`VideoEncoder`]; codec crates register theirs with -//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP -//! sequences, GIF, WAV and AIFF. +//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG / +//! Targa / DPX sequences, GIF, WAV and AIFF. //! //! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio //! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the @@ -22,6 +22,7 @@ mod pace; mod pcm; mod pipeline; pub mod presets; +mod raster; pub mod settings; pub mod still; pub use audio_out::LoudnessReport; @@ -96,6 +97,15 @@ pub enum Format { /// Numbered BMP stills. #[serde(rename = "bmp", alias = "BmpSequence")] BmpSequence, + /// Numbered JPEG stills (baseline, [`ExportSettings::quality`]). + #[serde(rename = "jpg", alias = "JpegSequence")] + JpegSequence, + /// Numbered Targa stills (uncompressed, 24-bit, or 32-bit with [`ExportSettings::alpha`]). + #[serde(rename = "tga", alias = "TgaSequence")] + TgaSequence, + /// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709). + #[serde(rename = "dpx", alias = "DpxSequence")] + DpxSequence, #[serde(rename = "gif", alias = "Gif")] Gif, #[serde(rename = "wav", alias = "Wav")] @@ -126,6 +136,10 @@ impl Format { "png" | "pngsequence" => Format::PngSequence, "tif" | "tiff" | "tiffsequence" => Format::TiffSequence, "bmp" | "bmpsequence" => Format::BmpSequence, + // "jpeg" alone stays Motion-JPEG (QuickTime), as it always was + "jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence, + "tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence, + "dpx" | "dpxsequence" => Format::DpxSequence, "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, @@ -145,6 +159,9 @@ impl Format { Format::PngSequence => "png", Format::TiffSequence => "tiff", Format::BmpSequence => "bmp", + Format::JpegSequence => "jpg", + Format::TgaSequence => "tga", + Format::DpxSequence => "dpx", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", @@ -159,6 +176,9 @@ impl Format { Format::PngSequence => "png", Format::TiffSequence => "tif", Format::BmpSequence => "bmp", + Format::JpegSequence => "jpg", + Format::TgaSequence => "tga", + Format::DpxSequence => "dpx", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", @@ -177,6 +197,9 @@ impl Format { Format::PngSequence => "PNG", Format::TiffSequence => "TIFF", Format::BmpSequence => "BMP", + Format::JpegSequence => "JPEG", + Format::TgaSequence => "Targa", + Format::DpxSequence => "DPX", Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", @@ -184,6 +207,10 @@ impl Format { Format::MxfOpAtom => "MXF OP-Atom", } } + /// Numbered stills, one per frame ([`image_sequence_path`]). + pub fn is_image_sequence(self) -> bool { + matches!(self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence) + } /// An MXF container format. pub fn is_mxf(self) -> bool { matches!(self, Format::MxfOp1a | Format::MxfOpAtom) @@ -205,7 +232,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 15] = [ + pub const ALL: [Format; 18] = [ Format::H264, Format::Hevc, Format::Av1, @@ -216,6 +243,9 @@ impl Format { Format::PngSequence, Format::TiffSequence, Format::BmpSequence, + Format::JpegSequence, + Format::TgaSequence, + Format::DpxSequence, Format::Gif, Format::Wav, Format::Aiff, @@ -244,7 +274,7 @@ pub struct ExportSettings { /// Output scale (1.0 = sequence frame size). pub scale: f32, pub include_audio: bool, - /// Quality 0–100 for lossy codecs. + /// Quality 0–100 for lossy codecs (Motion-JPEG, JPEG sequences). pub quality: u8, /// Target video bitrate (kbps) for bitrate-driven encoders. pub bitrate_kbps: u32, @@ -272,7 +302,7 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG and TIFF sequences and ProRes 4444 / 4444 XQ QuickTime movies + /// Keep the alpha channel in PNG, TIFF and Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over /// black (#160). Other formats ignore it. #[serde(default)] @@ -1629,11 +1659,30 @@ pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { } /// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha -/// channel it is given; `alpha` keeps it in TIFF too, which is otherwise written as RGB. +/// channel it is given; `alpha` keeps it in TIFF and Targa too, which are otherwise written as RGB. +/// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { + encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) +} + +/// [`encode_still`] with the JPEG quality (1–100; ignored by the lossless formats). +pub fn encode_still_with_quality(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool, quality: u8) -> Result> { let enc = |e: image::ImageError| ExportError::Encode(e.to_string()); let mut out = std::io::Cursor::new(Vec::new()); match format { + Format::JpegSequence => { + let rgb = raster::rgb(&rgba, w, h)?; + image::ImageEncoder::write_image( + image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)), + &rgb, + w, + h, + image::ExtendedColorType::Rgb8, + ) + .map_err(enc)? + } + Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), + Format::DpxSequence => return raster::dpx(&rgba, w, h), Format::PngSequence => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } @@ -1707,7 +1756,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (n, frames) } - Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::Gif => { + f if f.is_image_sequence() || f == Format::Gif => { let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?; let (f0, f1) = frame_span(pipe.rate, range); let count = (f1 - f0) as u64; @@ -1742,7 +1791,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let written: Vec> = (f..end) .into_par_iter() .map(|fi| { - let data = encode_still(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha)?; + let data = encode_still_with_quality(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha, settings.quality)?; write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data) }) .collect(); @@ -1761,7 +1810,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } (total, count) } - Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => { + _ => { // Handled by the stepped exporter above; reaching here would be a dispatch bug. return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format))); } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 4323de77..2a021d49 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -152,6 +152,14 @@ pub fn builtin_presets() -> Vec { ), p("TIFF Sequence", "Image Sequence", "Numbered TIFF stills (RGB, 8-bit)", ExportSettings { format: Format::TiffSequence, ..Default::default() }), p("BMP Sequence", "Image Sequence", "Numbered BMP stills (24-bit)", ExportSettings { format: Format::BmpSequence, ..Default::default() }), + p("JPEG Sequence", "Image Sequence", "Numbered JPEG stills (quality 90)", ExportSettings { format: Format::JpegSequence, ..Default::default() }), + p( + "Targa Sequence", + "Image Sequence", + "Numbered Targa stills (24-bit, uncompressed)", + ExportSettings { format: Format::TgaSequence, ..Default::default() }, + ), + p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }), p( "Animated GIF 640×360", "Animated GIF", diff --git a/crates/export/src/raster.rs b/crates/export/src/raster.rs new file mode 100644 index 00000000..59641319 --- /dev/null +++ b/crates/export/src/raster.rs @@ -0,0 +1,179 @@ +//! Uncompressed still formats written directly from RGBA8 frames: Targa and DPX. +//! +//! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed +//! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268 +//! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit +//! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry. +use crate::{ExportError, Result}; + +/// `w * h * 4` when `rgba` holds exactly that many bytes. +fn checked_len(rgba: &[u8], w: u32, h: u32) -> Result { + let n = (w as usize).checked_mul(h as usize).and_then(|n| n.checked_mul(4)).ok_or_else(|| ExportError::Encode("frame size".into()))?; + if w == 0 || h == 0 || rgba.len() != n { + return Err(ExportError::Encode("frame size".into())); + } + Ok(n) +} + +/// The RGB bytes of an RGBA8 frame (alpha dropped). +pub(crate) fn rgb(rgba: &[u8], w: u32, h: u32) -> Result> { + checked_len(rgba, w, h)?; + Ok(rgba.as_chunks::<4>().0.iter().flat_map(|&[r, g, b, _]| [r, g, b]).collect()) +} + +/// An uncompressed Targa file: 24-bit BGR, or 32-bit BGRA with `alpha` (straight alpha, as the +/// frame carries it). +pub(crate) fn tga(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result> { + checked_len(rgba, w, h)?; + let (tw, th) = match (u16::try_from(w), u16::try_from(h)) { + (Ok(tw), Ok(th)) => (tw, th), + _ => return Err(ExportError::Unsupported(format!("Targa frames are at most 65535×65535 (got {w}×{h})"))), + }; + let bpp: usize = if alpha { 4 } else { 3 }; + let row = w as usize * 4; + let mut out = Vec::with_capacity(18 + w as usize * h as usize * bpp + 26); + out.extend_from_slice(&[0, 0, 2]); // no ID, no colour map, uncompressed true-colour + out.extend_from_slice(&[0; 5]); // colour map specification (unused) + out.extend_from_slice(&[0, 0, 0, 0]); // x / y origin + out.extend_from_slice(&tw.to_le_bytes()); + out.extend_from_slice(&th.to_le_bytes()); + out.push(if alpha { 32 } else { 24 }); + out.push(if alpha { 8 } else { 0 }); // attribute bits; bottom-left origin + // bottom-left origin: the last frame row comes first + for line in rgba.chunks_exact(row).rev() { + for &[r, g, b, a] in line.as_chunks::<4>().0 { + out.extend_from_slice(&[b, g, r]); + if alpha { + out.push(a); + } + } + } + // TGA 2.0 footer without extension or developer areas + out.extend_from_slice(&[0; 8]); + out.extend_from_slice(b"TRUEVISION-XFILE.\0"); + Ok(out) +} + +/// Header size of the DPX files we write (file + image + orientation + film + TV headers). +const DPX_HEADER: usize = 2048; + +/// A DPX file: 10-bit RGB (8-bit values widened by bit replication, so 0 → 0 and 255 → 1023). +pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result> { + let n = checked_len(rgba, w, h)?; + // one 32-bit word per pixel; `n` is already `w * h * 4` + let size = n.checked_add(DPX_HEADER).ok_or_else(|| ExportError::Encode("frame size".into()))?; + let file_size = u32::try_from(size).map_err(|_| ExportError::Unsupported(format!("a {w}×{h} DPX frame exceeds 4 GB")))?; + // SMPTE ST 268: undefined numeric fields are all ones, undefined text fields are NUL + let mut hd = vec![0xFF_u8; DPX_HEADER]; + for (a, b) in [ + (36, 660), // file name, creation time, creator, project, copyright + (664, 768), // reserved + (1356, 1408), // reserved + (1432, 1620), // source file name, time, input device, serial number + (1644, 1664), // reserved + (1664, 1712), // film manufacturer, type, offset, prefix, count, format + (1732, 1920), // frame id, slate, reserved + (1972, 2048), // reserved + ] { + hd[a..b].fill(0); + } + for e in 0..8 { + let d = 780 + e * 72 + 40; + hd[d..d + 32].fill(0); // element descriptions + } + let put32 = |hd: &mut Vec, at: usize, v: u32| hd[at..at + 4].copy_from_slice(&v.to_be_bytes()); + let put16 = |hd: &mut Vec, at: usize, v: u16| hd[at..at + 2].copy_from_slice(&v.to_be_bytes()); + // file information header + hd[0..4].copy_from_slice(b"SDPX"); + put32(&mut hd, 4, DPX_HEADER as u32); // offset to the image data + hd[8..16].copy_from_slice(b"V2.0\0\0\0\0"); + put32(&mut hd, 16, file_size); + put32(&mut hd, 20, 1); // ditto key: new frame + put32(&mut hd, 24, 1664); // generic header length + put32(&mut hd, 28, 384); // industry header length + put32(&mut hd, 32, 0); // user data length + hd[160..169].copy_from_slice(b"FilmCraft"); + // image information header + put16(&mut hd, 768, 0); // orientation: left to right, top to bottom + put16(&mut hd, 770, 1); // one image element + put32(&mut hd, 772, w); + put32(&mut hd, 776, h); + let el = 780; + put32(&mut hd, el, 0); // unsigned + put32(&mut hd, el + 4, 0); // reference low data code + put32(&mut hd, el + 12, 1023); // reference high data code + hd[el + 20] = 50; // descriptor: RGB + hd[el + 21] = 6; // transfer: ITU-R BT.709 + hd[el + 22] = 6; // colorimetric: ITU-R BT.709 + hd[el + 23] = 10; // bit depth + put16(&mut hd, el + 24, 1); // packing: filled to 32-bit words, method A + put16(&mut hd, el + 26, 0); // no encoding (uncompressed) + put32(&mut hd, el + 28, DPX_HEADER as u32); // offset to the element's data + put32(&mut hd, el + 32, 0); // end-of-line padding + put32(&mut hd, el + 36, 0); // end-of-image padding + // image orientation header: no offset, square pixels + put32(&mut hd, 1408, 0); + put32(&mut hd, 1412, 0); + put32(&mut hd, 1424, w); + put32(&mut hd, 1428, h); + put32(&mut hd, 1628, 1); + put32(&mut hd, 1632, 1); + // television header: progressive + hd[1928] = 0; + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&hd); + let ten = |v: u8| (u32::from(v) << 2) | (u32::from(v) >> 6); + for &[r, g, b, _] in rgba.as_chunks::<4>().0 { + out.extend_from_slice(&((ten(r) << 22) | (ten(g) << 12) | (ten(b) << 2)).to_be_bytes()); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn be32(b: &[u8], at: usize) -> u32 { + u32::from_be_bytes(b[at..at + 4].try_into().unwrap()) + } + + #[test] + fn dpx_header_and_packing() { + // 2×1: pure red, then (1, 128, 255) + let px = [255, 0, 0, 255, 1, 128, 255, 7]; + let f = dpx(&px, 2, 1).unwrap(); + assert_eq!(&f[0..4], b"SDPX"); + assert_eq!(f.len(), 2048 + 8); + assert_eq!(be32(&f, 4), 2048); + assert_eq!(be32(&f, 16), f.len() as u32); + assert_eq!((be32(&f, 772), be32(&f, 776)), (2, 1)); + assert_eq!((f[800], f[803]), (50, 10)); + assert_eq!(be32(&f, 2048), 1023 << 22); + let p = be32(&f, 2052); + assert_eq!(((p >> 22) & 1023, (p >> 12) & 1023, (p >> 2) & 1023, p & 3), (4, 514, 1023, 0)); + } + + #[test] + fn tga_rows_are_bottom_up_bgr() { + // 1×2: top red, bottom blue (half transparent) + let px = [255, 0, 0, 255, 0, 0, 255, 128]; + let f = tga(&px, 1, 2, false).unwrap(); + assert_eq!(&f[..3], &[0, 0, 2]); + assert_eq!((f[16], f[17]), (24, 0)); + assert_eq!(&f[18..24], &[255, 0, 0, 0, 0, 255]); // blue row first, as BGR + assert!(f.ends_with(b"TRUEVISION-XFILE.\0")); + let f = tga(&px, 1, 2, true).unwrap(); + assert_eq!((f[16], f[17]), (32, 8)); + assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]); + } + + #[test] + fn hostile_sizes_are_errors() { + assert!(dpx(&[0; 7], 2, 1).is_err()); + assert!(dpx(&[], 0, 0).is_err()); + assert!(tga(&[0; 4], 1, 2, false).is_err()); + assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err()); + assert!(rgb(&[0; 3], 1, 1).is_err()); + assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err()); + } +} diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 90dd595f..4e062d2a 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -378,11 +378,11 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } - /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG and TIFF - /// sequences and ProRes 4444 / 4444 XQ QuickTime movies. + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF and + /// Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies. pub fn supports_alpha(&self) -> bool { match self.format { - Format::PngSequence | Format::TiffSequence => true, + Format::PngSequence | Format::TiffSequence | Format::TgaSequence => true, Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, _ => false, } @@ -395,7 +395,7 @@ impl ExportSettings { /// Whether the output is a numbered image sequence. pub fn is_image_sequence(&self) -> bool { - matches!(self.format, Format::PngSequence | Format::TiffSequence | Format::BmpSequence) + self.format.is_image_sequence() } /// Output size, rate, audio format and bitrate for a sequence of `seq_w`×`seq_h` at `seq_rate`. @@ -482,9 +482,12 @@ impl ExportSettings { }; px * fps * bpp } - Format::Mjpeg => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, + Format::Mjpeg | Format::JpegSequence => px * 8.0 * (0.4 + self.quality as f64 / 100.0 * 2.0) * fps / 8.0, Format::PngSequence => px * 4.0 * 0.45 * 8.0 * fps, Format::TiffSequence | Format::BmpSequence => px * if self.format == Format::BmpSequence { 3.0 } else { 4.0 } * 8.0 * fps, + Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, + // 10-bit RGB in one 32-bit word per pixel + Format::DpxSequence => px * 4.0 * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; @@ -566,7 +569,8 @@ impl ExportSettings { } Format::DnxHr => v += &format!(", DNxHR {}", if self.dnx_profile.is_empty() { "HQ".into() } else { self.dnx_profile.to_ascii_uppercase() }), Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()), - Format::Mjpeg => v += &format!(", quality {}", self.quality), + Format::Mjpeg | Format::JpegSequence => v += &format!(", quality {}", self.quality), + Format::DpxSequence => v += ", 10-bit RGB", _ => {} } v @@ -589,7 +593,7 @@ impl ExportSettings { Format::H264 | Format::Hevc | Format::Av1 => "MP4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime", Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(), - Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence", + f if f.is_image_sequence() => "Image sequence", _ => "", }; let format = if container.is_empty() { self.format.label().to_string() } else { format!("{} ({container})", self.format.label()) }; diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index d507639d..8cfb3170 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -109,6 +109,57 @@ fn image_sequences_are_numbered_like_premiere() { } } +/// JPEG, Targa and DPX sequences (#779): numbered like the others, JPEG at the export quality, +/// Targa with alpha when asked; every still decoded by ffmpeg (external test oracle). +#[test] +fn jpeg_targa_and_dpx_sequences() { + let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); + let mut jpeg_sizes = Vec::new(); + for (fmt, ext, alpha, quality) in [ + (Format::JpegSequence, "jpg", false, 95), + (Format::JpegSequence, "jpg", false, 20), + (Format::TgaSequence, "tga", false, 90), + (Format::TgaSequence, "tga", true, 90), + (Format::DpxSequence, "dpx", false, 90), + ] { + let dir = Scratch::new(&format!("seq3-{ext}-{alpha}-{quality}")); + let mut s = ExportSettings { format: fmt, path: dir.path(&format!("Shot.{ext}")), alpha, quality, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3))); + assert_eq!(Format::from_name(fmt.id()), Some(fmt)); + assert!(fmt.is_image_sequence() && s.is_image_sequence() && !s.has_audio()); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 3); + let want: Vec = (0..3).map(|i| format!("Shot{i:03}.{ext}")).collect(); + assert_eq!(files_in(&dir.0), want, "{fmt:?}"); + let first = dir.0.join(&want[0]); + let bytes = std::fs::read(&first).unwrap(); + match fmt { + Format::JpegSequence => { + let px = image::load_from_memory(&bytes).unwrap().to_rgb8().get_pixel(32, 18).0; + assert!(px[2] > 100 && px[0] < 20, "{px:?}"); + jpeg_sizes.push(bytes.len()); + } + Format::DpxSequence => assert_eq!(bytes.len(), 2048 + 64 * 36 * 4), + _ => assert_eq!(bytes.len(), 18 + 64 * 36 * if alpha { 4 } else { 3 } + 26), + } + let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("jpeg / targa / dpx sequences") else { continue }; + let first = first.to_string_lossy().to_string(); + let out = std::process::Command::new(ffmpeg).args(["-v", "error", "-i", &first, "-f", "rawvideo", "-pix_fmt", "rgba", "-"]).output().unwrap(); + assert!(out.status.success(), "{first}: {}", String::from_utf8_lossy(&out.stderr)); + assert_eq!(out.stdout.len(), 64 * 36 * 4, "{first}"); + let at = (18 * 64 + 32) * 4; + let px = &out.stdout[at..at + 4]; + if alpha { + assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{first} straight alpha: {px:?}"); + } else { + assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 20, "{first} flattened over black: {px:?}"); + } + // the top-left pixel is the top row (Targa is stored bottom-up), and all of it is blue + assert!(out.stdout.as_chunks::<4>().0.iter().all(|p| p[2] > 100 && p[0] < 20), "{first}"); + } + assert!(jpeg_sizes[0] > jpeg_sizes[1], "quality 95 must be larger than quality 20: {jpeg_sizes:?}"); +} + /// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). #[test] fn image_sequences_keep_alpha_when_asked() { @@ -607,7 +658,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index f55b5226..51d1cd03 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3173,6 +3173,15 @@ Nudge Volume -1dB Ajustar volumen -1 dB Nudge Volume -3dB Ajustar volumen -3 dB Numbered BMP stills (24-bit) Imágenes fijas BMP numeradas (24 bits) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Secuencia JPEG + Targa Sequence Secuencia Targa + DPX Sequence Secuencia DPX + Numbered JPEG stills (quality 90) Imágenes fijas JPEG numeradas (calidad 90) + Numbered Targa stills (24-bit, uncompressed) Imágenes fijas Targa numeradas (24 bits, sin comprimir) + Numbered DPX stills (10-bit RGB) Imágenes fijas DPX numeradas (RGB de 10 bits) Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits) Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 65419ebc..79c7d025 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3162,6 +3162,15 @@ Nudge Volume -1dB 音量を -1dB 微調整 Nudge Volume -3dB 音量を -3dB 微調整 Numbered BMP stills (24-bit) 連番 BMP 静止画 (24-bit) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence JPEG 連番画像 + Targa Sequence Targa 連番画像 + DPX Sequence DPX 連番画像 + Numbered JPEG stills (quality 90) 連番 JPEG 静止画 (品質 90) + Numbered Targa stills (24-bit, uncompressed) 連番 Targa 静止画 (24-bit、非圧縮) + Numbered DPX stills (10-bit RGB) 連番 DPX 静止画 (10-bit RGB) Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画 Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit) Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index fc805435..16472ef0 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3156,6 +3156,15 @@ Nudge Volume -1dB 微调音量 -1dB Nudge Volume -3dB 微调音量 -3dB Numbered BMP stills (24-bit) 带编号的 BMP 静帧(24 位) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence JPEG 序列 + Targa Sequence Targa 序列 + DPX Sequence DPX 序列 + Numbered JPEG stills (quality 90) 带编号的 JPEG 静帧(质量 90) + Numbered Targa stills (24-bit, uncompressed) 带编号的 Targa 静帧(24 位,未压缩) + Numbered DPX stills (10-bit RGB) 带编号的 DPX 静帧(10 位 RGB) Numbered PNG stills at the sequence's frame size 带编号的 PNG 静帧(采用序列帧大小) Numbered TIFF stills (RGB, 8-bit) 带编号的 TIFF 静帧(RGB,8 位) Opacity 0 → 100 % over the first second 在最初一秒内不透明度从 0 → 100% diff --git a/crates/ui-egui/src/panels/export_mode.rs b/crates/ui-egui/src/panels/export_mode.rs index 426da87c..30d21268 100644 --- a/crates/ui-egui/src/panels/export_mode.rs +++ b/crates/ui-egui/src/panels/export_mode.rs @@ -743,7 +743,7 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T } }); } - Format::Mjpeg => { + Format::Mjpeg | Format::JpegSequence => { row(ui, t, tl!("Quality"), |ui| { let mut q = s.quality as f64; drag(ui, reg, "export.video.quality", &mut q, 1.0..=100.0, 1.0, "", 0); diff --git a/docs/architecture.md b/docs/architecture.md index 24a62065..ed1486af 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -666,7 +666,8 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `mjpeg` | built in | MOV | | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | -| `png`, `tiff`, `bmp` | `image` | numbered stills `000.`, `001.` … | +| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | +| `tga`, `dpx` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268) | numbered stills, as above | | `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) | - **Reproducible.** The same project and settings give the same file on every machine. Frames render diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b19262c8..58c45cb6 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -52,12 +52,12 @@ only). "Tested" names the evidence. | Format | Premiere | Read | Write (frame / sequence) | % | |---|---|---|---|---| -| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP sequences, GIF; **no JPEG sequence** | 80% | +| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% | | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | no | no | 0% | -| **DPX** | read + write | no | no | 0% | -| Targa | read + write | no | no | 0% | +| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | +| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index 3532ae7b..7ab42d05 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -178,9 +178,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, EXR / DPX / Targa / JPEG sequences, MP3 and AAC-only audio, + AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing From c7ad44bc1987cfc8d7a68429cac254ccae2a5a55 Mon Sep 17 00:00:00 2001 From: Nigel Ihza Firdausy <106657905+Ni-zav@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:55:59 +0700 Subject: [PATCH 048/203] test(menus): normalize regression test spacing --- crates/ui-egui/src/menus.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 7bb32e87..9a9c4011 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -881,7 +881,6 @@ mod parse_shortcut_tests { assert_eq!(label_menu_swatch("edit.label.invalid", &labels), None); } - /// Off macOS `Ctrl` and `Cmd` are the same key, so either spelling parses to the modifiers a /// physical Ctrl press carries and matches a binding written the other way (#245). #[test] From 3b1fa296633d2eec2d9fd01a32fb3f9c7eb39251 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 23:59:15 +0200 Subject: [PATCH 049/203] #790: Matroska AAC / MP3 / AC-3 trims the exact CodecDelay Problem: ffmpeg stores the encoder priming as CodecDelay in ns (1024 AAC samples at 48 kHz = 21.333 ms, 256 AC-3 samples at 44.1 kHz = 5.805 ms) and block timestamps in ms. The demuxer subtracts the delay rounded to 1 ms, and fixed-length packets were anchored on that rounded stamp, so the whole track played up to 16 samples late against ffmpeg's decode. Only Opus undid the rounding. Fix: one stamp-to-samples conversion for every Matroska audio codec that undoes the demuxer's rounding and removes the exact CodecDelay (rounded to the nearest sample; the element holds whole ns), with checked math against hostile timebases. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio_timing_tests.rs | 29 ++++++++++++ crates/codecs/src/mkv.rs | 42 +++++++++++------ crates/codecs/tests/matroska_audio_oracle.rs | 47 ++++++++++++++++++++ docs/file-format-parity.md | 2 +- docs/gaps.md | 2 + 5 files changed, 108 insertions(+), 14 deletions(-) diff --git a/crates/codecs/src/audio_timing_tests.rs b/crates/codecs/src/audio_timing_tests.rs index 40e43a2d..62f25cb1 100644 --- a/crates/codecs/src/audio_timing_tests.rs +++ b/crates/codecs/src/audio_timing_tests.rs @@ -176,3 +176,32 @@ fn playing_at_another_rate_leaves_no_holes() { let tail: Vec> = got.iter().map(|c| c[2048..].to_vec()).collect(); assert_eq!(holes(&tail), 0); } + +/// #790: the encoder priming stored as `CodecDelay` (1024 samples: 21.333 ms at 48 kHz, 23.220 ms +/// at 44.1 kHz) is trimmed exactly. The demuxer takes it off the millisecond timestamps rounded to +/// whole milliseconds; starting the first packet there played the track 16 (48 kHz) or 10 (44.1 kHz) +/// samples late. +#[test] +fn matroska_codec_delay_is_trimmed_to_the_sample() { + for rate in [48_000u32, 44_100] { + let (asc, units) = aac(rate); + let want = continuous(&asc, rate, &units); + let delay_ns = (1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + let mut spec = TrackSpec::new(TrackKind::Audio, "A_AAC"); + spec.codec_private = asc.clone(); + spec.audio = Some((rate as f64, 2, None)); + spec.codec_delay_ns = delay_ns as u64; + let mut w = MkvWriter::new(Cursor::new(Vec::new()), vec![spec], MuxOptions::default()).unwrap(); + for (k, u) in units.iter().enumerate() { + let start_ns = (k as i64 * 1024 * 1_000_000_000 + rate as i64 / 2) / rate as i64; + w.write_frame(0, start_ns - delay_ns, true, u, None).unwrap(); + } + let bytes: Arc<[u8]> = w.finish().unwrap().into_inner().into(); + let src = MkvSource::open("primed.mkv", bytes).unwrap(); + let frames = units.len().saturating_sub(2) * 1024; + let got = played(&src, rate, frames); + let trimmed: Vec> = want.iter().map(|c| c[1024..].to_vec()).collect(); + let (n, first) = mismatches(&got, &trimmed, 0); + assert_eq!(n, 0, "{rate} Hz: {n} samples differ from the decode with the priming trimmed, first (channel, sample) {first:?}"); + } +} diff --git a/crates/codecs/src/mkv.rs b/crates/codecs/src/mkv.rs index 97de6743..c3ca0355 100644 --- a/crates/codecs/src/mkv.rs +++ b/crates/codecs/src/mkv.rs @@ -71,15 +71,14 @@ pub struct MkvSource { /// length (AAC, MPEG audio, AC-3) therefore run on from one another, resynchronising to the /// timestamp only across gaps of more than half a packet (and more than two ticks); see /// [`crate::audio::contiguous_starts`]. Opus packets: [`opus_starts`]. Other codecs start at their -/// timestamps. +/// timestamps. Timestamps are taken with the exact `CodecDelay` off (see [`stamp_to_samples`]). fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec { use crate::audio::{FixedFrames, PacketTime, contiguous_starts, fixed_packet_samples}; let Some(t) = file.tracks.get(ti) else { return Vec::new() }; if let Some(h) = opus_head(&t.codec) { return opus_starts(file, bytes, ti, &h, rate); } - let (n, d) = tb(t); - let at = |pts: i64| (pts as i128 * n as i128 * rate as i128 / d as i128) as i64; + let at = stamp_to_samples(t, rate, codec_delay_samples(t, rate)); let first = || file.read_sample(bytes, ti, 0).unwrap_or_default(); let out_rate = u32::try_from(rate).unwrap_or(0); let frame = match &t.codec { @@ -97,18 +96,13 @@ fn audio_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, rate: i64) -> Vec /// Sample-exact Opus packet start positions (48 kHz frames, pre-skip removed). /// -/// The demuxer also subtracts a rounded `CodecDelay` from the timestamps. Starts are accumulated -/// from each packet's TOC duration, resynchronising to the timestamp only across real gaps (more -/// than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's pre-skip when it is -/// absent). +/// Starts are accumulated from each packet's TOC duration, resynchronising to the timestamp only +/// across real gaps (more than two ticks off). Pre-skip is the exact `CodecDelay` (or the header's +/// pre-skip when it is absent). fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_opus::OpusHead, rate: i64) -> Vec { let Some(t) = file.tracks.get(ti) else { return Vec::new() }; - let (n, d) = tb(t); - let delay_ns = t.codec_delay_ns as i128; - // The demuxer's rounding of CodecDelay to ticks, undone here. - let delay_ticks = codec_delay_ticks(t); - let skip = if t.codec_delay_ns > 0 { (delay_ns * rate as i128 / 1_000_000_000) as i64 } else { head.pre_skip as i64 }; - let at = |pts: i64| ((pts.saturating_add(delay_ticks) as i128 * n as i128 * rate as i128 / d as i128) as i64).saturating_sub(skip); + let skip = if t.codec_delay_ns > 0 { codec_delay_samples(t, rate) } else { head.pre_skip as i64 }; + let at = stamp_to_samples(t, rate, skip); let packets = t.samples.iter().enumerate().map(|(i, s)| crate::audio::PacketTime { stamp: at(s.pts), stamped: own_stamp(s), @@ -117,6 +111,28 @@ fn opus_starts(file: &MkvFile, bytes: &crate::Src, ti: usize, head: &filmcraft_o crate::audio::contiguous_starts(packets, stamp_tolerance(t, rate)) } +/// A block timestamp (with the demuxer's rounded `CodecDelay` taken off) as a position in sample +/// frames at `rate`, `skip` frames of priming removed (#790). The demuxer's rounding is undone first: +/// `CodecDelay` is rarely a whole number of ticks (1024 AAC samples at 48 kHz are 21.333 ms, 256 +/// AC-3 samples at 44.1 kHz 5.805 ms), so starting the first packet at the rounded delay played the +/// whole track up to 16 samples late. +fn stamp_to_samples(t: &filmcraft_matroska::Track, rate: i64, skip: i64) -> impl Fn(i64) -> i64 { + let (n, d) = tb(t); + let delay_ticks = codec_delay_ticks(t); + move |pts: i64| { + let ticks = pts.saturating_add(delay_ticks) as i128; + let at = ticks.checked_mul(n as i128).and_then(|x| x.checked_mul(rate as i128)).map_or(ticks.signum() * i128::MAX, |x| x / d as i128); + i64::try_from(at).unwrap_or(if at < 0 { i64::MIN } else { i64::MAX }).saturating_sub(skip) + } +} + +/// `CodecDelay` in sample frames at `rate`, rounded to the nearest (the element holds whole +/// nanoseconds: 1024 samples at 48 kHz are stored as 21 333 333 ns). +fn codec_delay_samples(t: &filmcraft_matroska::Track, rate: i64) -> i64 { + let ns = (t.codec_delay_ns as i128).saturating_mul(rate.max(0) as i128); + i64::try_from(ns.saturating_add(500_000_000) / 1_000_000_000).unwrap_or(i64::MAX) +} + /// `CodecDelay` in timestamp ticks, rounded as the demuxer rounds it (half away from zero) before /// subtracting it from every timestamp of the track. fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 { diff --git a/crates/codecs/tests/matroska_audio_oracle.rs b/crates/codecs/tests/matroska_audio_oracle.rs index 1298ccd4..ff782274 100644 --- a/crates/codecs/tests/matroska_audio_oracle.rs +++ b/crates/codecs/tests/matroska_audio_oracle.rs @@ -48,3 +48,50 @@ fn matroska_audio_plays_without_holes() { assert!(r_ours < 2.0 * r_theirs + 0.005, "{name}: clicks between packets (|Δ²| {r_ours} vs ffmpeg's {r_theirs})"); } } + +/// The lag in `-max..=max` at which `ours` best matches `theirs` (least mean |difference| over +/// `from..from + len`). +fn best_lag(ours: &[f32], theirs: &[f32], from: usize, len: usize, max: i64) -> i64 { + let err = |lag: i64| -> f64 { (from..from + len).map(|i| (ours[i] as f64 - theirs[(i as i64 + lag) as usize] as f64).abs()).sum::() / len as f64 }; + (-max..=max).min_by(|&a, &b| err(a).total_cmp(&err(b))).unwrap() +} + +/// #790: ffmpeg stores the encoder priming as `CodecDelay` (1024 samples = 21.333 ms for AAC at +/// 48 kHz, 256 = 5.805 ms for AC-3 at 44.1 kHz, 1105 = 25.057 ms for MP3), and block timestamps to +/// the millisecond. Taking the rounded delay as the start of the first packet played the whole +/// track up to 16 samples late; the decode must line up with ffmpeg's to the sample. +#[test] +fn matroska_audio_is_sample_aligned_with_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let cases: [(&str, u32, &[&str]); 5] = [ + ("noise_aac_48k.mkv", 48_000, &["-c:a", "aac", "-b:a", "256k"]), + ("noise_aac_44k.mkv", 44_100, &["-c:a", "aac", "-b:a", "256k"]), + ("noise_ac3_44k.mkv", 44_100, &["-c:a", "ac3", "-b:a", "448k"]), + ("noise_ac3_48k.mkv", 48_000, &["-c:a", "ac3", "-b:a", "448k"]), + ("noise_mp3_44k.mkv", 44_100, &["-c:a", "libmp3lame", "-b:a", "320k"]), + ]; + for (name, rate, codec) in cases { + let input = format!("anoisesrc=color=pink:seed=7:amplitude=0.5:sample_rate={rate}:duration=2"); + let args: Vec<&str> = ["-f", "lavfi", "-i", input.as_str(), "-ac", "1"].iter().copied().chain(codec.iter().copied()).collect(); + let Some(path) = fixture(&ff, name, &args) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + let src = filmcraft_codecs::open_bytes(name, bytes(&path)).unwrap(); + let frames = rate as usize * 3 / 2; + let mut ours = Vec::with_capacity(frames); + while ours.len() < frames { + let n = 1000.min(frames - ours.len()); + ours.extend_from_slice(&src.audio(ours.len() as i64, n, rate).unwrap().channels[0]); + } + let theirs = ffmpeg_audio_f32(&ff, &path, &[]); + assert!(theirs.len() >= frames, "{name}: ffmpeg decoded {} samples", theirs.len()); + let lag = best_lag(&ours, &theirs, rate as usize / 4, rate as usize, 40); + assert_eq!(lag, 0, "{name}: our decode is {lag} samples off ffmpeg's"); + // read from the middle, as a seek does + let at = rate as i64 / 2; + let mid = src.audio(at, 2000, rate).unwrap().channels[0].clone(); + let diff = mid.iter().zip(&ours[at as usize..]).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max); + assert!(diff < 1e-3, "{name}: a read from {at} differs from continuous decoding by {diff}"); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b19262c8..125eec7d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -39,7 +39,7 @@ only). "Tested" names the evidence. | QuickTime MOV | read + write | yes | yes (H.264, HEVC, ProRes, DNxHR, APV, MJPEG, PCM) | ffprobe | 90% | | MXF OP1a / OP-Atom | read + write (XDCAM, AVC-Intra, P2, AS-10 / AS-11, JPEG 2000, DNx) | yes (AVC, DNx, ProRes, MPEG-2, PCM / AES3, timecode) | OP1a (DNxHR, ProRes, H.264), OP-Atom | index-table seeking tests | 70% | | MPEG TS / PS (AVCHD `.mts`, `.m2ts`, `.ts`, `.mpg`, `.vob`, `.mod`) | read; write MPEG-2 / Blu-ray / DVD | yes | **no** | fixtures | 65% | -| Matroska / WebM | read (limited) | yes (H.264, HEVC, VP9, AV1, ProRes, MJPEG + AAC, Opus, FLAC, MP3, Vorbis, PCM); WebM black report (#432), MKV multi-audio (#601) | no | fixtures | 75% | +| Matroska / WebM | read (limited) | yes (H.264, HEVC, VP9, AV1, ProRes, MJPEG + AAC, Opus, FLAC, MP3, Vorbis, PCM); WebM black report (#432), MKV multi-audio (#601) | no | fixtures; audio sample-aligned with ffmpeg (#790) | 75% | | **AVI** | read + write | **no** (extension listed, no demuxer, #598) | no | — | 0% | | WMV / ASF | read; write on Windows | no | no | — | 0% | | FLV / F4V | read + write | no | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index b479a6d1..ba593bcb 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -163,6 +163,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). +- **Have:** Matroska AAC / MP3 / AC-3 sample-aligned with ffmpeg's decode (the exact + `CodecDelay` is trimmed, not the 1 ms-rounded one; #790). ## G11. No plugin hosting From 0b62ebb9ad7bcfdf200adb4210c88247033e756d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:08:40 +0200 Subject: [PATCH 050/203] #577: Effect Controls shows a selected transition's own timeline Problem: with a transition selected, Effect Controls showed only its settings. Premiere puts the transition's own timeline beside them (clip A, clip B and the transition over their cut), where it can be slid over the cut or resized by dragging; FilmCraft could do that only in the Timeline. Fix: right of the settings (the clip view's divider and width), a time ruler over the cut, clip A above, the transition between, clip B below, the cut and the playhead. Dragging the transition's middle slides it, dragging an end changes its duration from that end (the Timeline's rules, transitions::drag_span), measured against the view the drag began in; each drag is one undoable sequence.setTransition. The ruler moves the playhead. Every part has an automation id; no new UI strings. Co-Authored-By: Claude Opus 5.5 --- .../ui-egui/src/panels/transition_controls.rs | 257 +++++++++++++++++- crates/ui-egui/tests/transitions_ui.rs | 70 +++++ docs/gaps.md | 3 +- docs/ui-parity.md | 2 +- 4 files changed, 323 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/transition_controls.rs b/crates/ui-egui/src/panels/transition_controls.rs index 47decaf2..0f5c4760 100644 --- a/crates/ui-egui/src/panels/transition_controls.rs +++ b/crates/ui-egui/src/panels/transition_controls.rs @@ -4,25 +4,45 @@ //! the Timeline opens. Every change is one `sequence.setTransition`; dragging a value is one undo //! step. //! +//! Right of the settings, as in Premiere, is the transition's own timeline (#577): a time ruler over +//! the cut, the outgoing clip (A) above, the incoming clip (B) below and the transition between +//! them. Dragging the transition's middle slides it over the cut; dragging one of its ends changes +//! its duration from that end, the other staying put (the Timeline's rules, +//! [`trn::drag_span`]); each drag is one undo step. Clicking or dragging the ruler moves the +//! playhead. The divider between the settings and the timeline is the clip view's. +//! //! Not here yet: the A/B preview thumbnails with their Start / End sliders, Show Actual Sources, -//! and dragging the transition in Effect Controls' own time ruler (the Timeline does that). +//! and a rolling edit by dragging clip A or B in the transition's timeline. //! //! Automation ids: `effectControls.transition.duration`, `.alignment` (+ `.alignment.option.` while open), `.reverse`, `.reset`, `.param.` (a point's are `.param..x` / -//! `.y`; a list's entries `.param..option.` while open); the dialog's -//! `transitionDuration.value`, `.ok`, `.cancel`. +//! `.y`; a list's entries `.param..option.` while open); its timeline's +//! `effectControls.transition.timeline`, `.timeline.ruler`, `.timeline.a` / `.timeline.b` (the +//! clips, labelled with their names), `.timeline.span` (the transition) with its ends +//! `.timeline.span.in` / `.timeline.span.out`, `.timeline.playhead`; `effectControls.divider`; +//! the dialog's `transitionDuration.value`, `.ok`, `.cancel`. -use egui::{Rect, RichText, vec2}; +use egui::{Align2, Pos2, Rect, RichText, Sense, Stroke, pos2, vec2}; +use filmcraft_edit::Edge; use filmcraft_edit::transitions::{self as trn, Alignment}; use filmcraft_project::{ParamKind, ParamValue, Transition, TransitionId}; -use filmcraft_time::FrameRate; +use filmcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; use crate::FilmcraftApp; use crate::state::TransitionDurationDraft; +use crate::theme::Tokens; type Elems = Vec<(String, Rect, String)>; +// the same sizes as the clip view's (`effect_controls.rs`), so the two look alike +const MIN_LIST_W: f32 = 260.0; +const MIN_LANE_W: f32 = 60.0; +const RULER_H: f32 = 24.0; +const BAR_H: f32 = 18.0; +/// How far into the transition from either end a press grabs that end (the Timeline's width). +const EDGE_PX: f32 = 7.0; + /// The selected transition, when it still exists, with its alignment and how many frames it may /// last at most (the clips it joins). fn lookup(app: &FilmcraftApp, id: TransitionId) -> Option<(Transition, Alignment, i64)> { @@ -71,13 +91,23 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id let one_sided = x.from.is_none() || x.to.is_none(); let mut changes: Vec = Vec::new(); let mut elems: Elems = Vec::new(); - let mut child = ui.new_child(egui::UiBuilder::new().max_rect(rect.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0))); + // the settings on the left, the transition's timeline right of the divider (the clip view's + // divider and width, #643); a panel too narrow for both shows the settings only + let max_list = (rect.width() - MIN_LANE_W - 10.0).max(MIN_LIST_W); + let list_w = if app.ui.effect_controls_split > 0.0 { app.ui.effect_controls_split } else { (rect.width() * 0.58).max(260.0) }; + let list_w = list_w.clamp(MIN_LIST_W, max_list); + let split = rect.min.x + list_w; + let lane = Rect::from_min_max(pos2(split + 4.0, rect.min.y + 4.0), pos2(rect.max.x - 6.0, rect.max.y - 6.0)); + let has_lane = lane.width() >= MIN_LANE_W / 2.0 && lane.height() >= RULER_H + 3.0 * BAR_H; + let list = if has_lane { Rect::from_min_max(rect.min, pos2(split, rect.max.y)) } else { rect }; + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(list.shrink2(vec2(10.0, 6.0))).id_salt(("tr-ec", id.0))); + child.set_clip_rect(list.intersect(ui.clip_rect())); child.label(RichText::new(format!("{seq_name} · {name}")).color(t.text_dim)); child.add_space(4.0); // the line under each row, as for a clip's effects (#640): halfway into the row spacing let row_line = |ui: &egui::Ui| { let y = ui.cursor().top() - 4.0; - ui.painter().line_segment([egui::pos2(rect.min.x, y), egui::pos2(rect.max.x, y)], egui::Stroke::new(1.0, t.separator)); + ui.painter().line_segment([pos2(list.min.x, y), pos2(list.max.x, y)], Stroke::new(1.0, t.separator)); }; egui::ScrollArea::vertical().id_salt(("tr-ec-scroll", id.0)).auto_shrink([false, false]).show(&mut child, |ui| { egui::Grid::new(("tr-ec-grid", id.0)).num_columns(2).spacing([14.0, 8.0]).show(ui, |ui| { @@ -141,6 +171,19 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id changes.push(json!({"reset": true})); } }); + if has_lane { + if let Some(c) = timeline(app, ui, lane, id, &x, &mut elems) { + changes.push(c); + } + let divider = Rect::from_min_max(pos2(split - 2.0, rect.min.y), pos2(split + 4.0, rect.max.y)); + let dresp = ui.interact(divider, egui::Id::new("tr-ec-divider"), Sense::drag()).on_hover_cursor(egui::CursorIcon::ResizeHorizontal); + if dresp.dragged() { + app.ui.effect_controls_split = (list_w + dresp.drag_delta().x).clamp(MIN_LIST_W, max_list); + } + let dcol = if dresp.hovered() || dresp.dragged() { t.accent } else { t.separator }; + ui.painter().line_segment([pos2(split + 1.5, rect.min.y), pos2(split + 1.5, rect.max.y)], Stroke::new(1.0, dcol)); + elems.push(("effectControls.divider".into(), divider, "divider".into())); + } for (eid, r, l) in elems { app.auto.add(&eid, r, &l); } @@ -152,6 +195,206 @@ pub fn effect_controls(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, id } } +/// A drag in the transition's timeline: the end it grabbed (`None`: the middle), where the +/// transition was when it began, and the stretch of time the lane showed then, so the drag is +/// measured against a ruler that holds still while the transition changes under it. `begun` once +/// its first change went through: the rest merge into that undo step. +#[derive(Clone, Copy, Debug)] +struct LaneDrag { + edge: Option, + start: Tick, + duration: Tick, + view: (Tick, Tick), + from_x: f32, + begun: bool, +} + +/// The stretch of time the transition's timeline shows: its cut with one and a half times the +/// transition's duration either side, room for it wherever it sits over the cut. +fn view(x: &Transition, cut: Tick, frame: Tick) -> (Tick, Tick) { + let half = x.duration.max(frame).max(Tick(1)).mul_ratio(3, 2); + (cut - half, cut + half) +} + +/// Where `tk` falls on `lane`, which shows `view`. +fn x_of(lane: Rect, view: (Tick, Tick), tk: Tick) -> f32 { + let span = (view.1 - view.0).0.max(1) as f64; + let f = ((tk - view.0).0 as f64 / span).clamp(-100.0, 100.0) as f32; + // far outside the view stays a finite number of lane widths away + lane.min.x + f * lane.width() +} + +/// The time under `px` on `lane`, which shows `view`. +fn tick_at(lane: Rect, view: (Tick, Tick), px: f32) -> Tick { + let span = (view.1 - view.0).0.max(1) as f64; + // `as` saturates (and a NaN is 0), so a hostile width can't overflow + view.0 + Tick((f64::from((px - lane.min.x) / lane.width().max(1.0)) * span) as i64) +} + +/// Ticks per `px` points of `lane` showing `view`, as a time delta. +fn ticks_for(lane: Rect, view: (Tick, Tick), px: f32) -> Tick { + let span = (view.1 - view.0).0.max(1) as f64; + Tick((f64::from(px / lane.width().max(1.0)) * span) as i64) +} + +/// The selected transition's own timeline, right of its settings: ruler, clip A, the transition, +/// clip B, the cut and the playhead. Returns the change a drag of the transition makes this frame, +/// as `sequence.setTransition` params (without the id); moves the playhead itself. +fn timeline(app: &mut FilmcraftApp, ui: &mut egui::Ui, lane: Rect, id: TransitionId, x: &Transition, elems: &mut Elems) -> Option { + let t = app.tokens; + let seq = app.session.active_sequence()?; + let (rate, drop_frame) = (seq.settings.frame_rate.sane(), seq.settings.drop_frame); + let frame = rate.frame_duration(); + let track = seq.all_tracks().find(|tr| tr.transitions.iter().any(|y| y.id == id))?; + let (_, cut, _) = trn::bounds(track, x)?; + let clip_a = x.from.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); + let clip_b = x.to.and_then(|c| track.item(c)).map(|it| (it.start, it.end(), it.name.clone())); + let mem = egui::Id::new(("tr-ec-lane-drag", id.0)); + let mut drag: Option = ui.data(|d| d.get_temp(mem)); + let v = drag.map_or_else(|| view(x, cut, frame), |d| d.view); + let painter = ui.painter().with_clip_rect(lane.intersect(ui.clip_rect())); + painter.rect_filled(lane, 0.0, t.tl_bg); + let ruler = Rect::from_min_max(lane.min, pos2(lane.max.x, lane.min.y + RULER_H)); + paint_ruler(&painter, ruler, v, rate, drop_frame, &t); + // the rows: A on top, the transition between, B under it, as Premiere stacks them + let row = |i: f32| { + let top = ruler.max.y + 4.0 + i * (BAR_H + 2.0); + Rect::from_min_max(pos2(lane.min.x, top), pos2(lane.max.x, top + BAR_H)) + }; + let (row_a, row_x, row_b) = (row(0.0), row(1.0), row(2.0)); + for ((clip, r), key) in [(&clip_a, row_a), (&clip_b, row_b)].into_iter().zip(["a", "b"]) { + let Some((s, e, name)) = clip else { continue }; + let bar = Rect::from_min_max(pos2(x_of(lane, v, *s).max(lane.min.x), r.min.y), pos2(x_of(lane, v, *e).min(lane.max.x), r.max.y)); + if bar.width() <= 0.0 { + continue; + } + painter.rect_filled(bar, 2.0, t.clip_bar_bg); + painter.with_clip_rect(bar.intersect(lane)).text(pos2(bar.min.x + 4.0, bar.center().y), Align2::LEFT_CENTER, name, Tokens::ui(10.0), t.text); + elems.push((format!("effectControls.transition.timeline.{key}"), bar, name.clone())); + } + // the transition, outlined as selected (it is), with the Timeline's end zones + let (x0, x1) = (x_of(lane, v, x.start), x_of(lane, v, x.end())); + let span = Rect::from_min_max(pos2(x0, row_x.min.y), pos2(x1.max(x0 + 1.0), row_x.max.y)); + painter.rect_filled(span, 0.0, egui::Color32::from_black_alpha(90)); + painter.rect_stroke(span, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + let name = x.effect.def().map_or(x.effect.effect.as_str(), |d| d.name); + if span.width() > 30.0 { + painter.with_clip_rect(span.shrink(2.0).intersect(lane)).text( + pos2(span.min.x + 5.0, span.center().y), + Align2::LEFT_CENTER, + crate::i18n::t(name), + Tokens::ui(10.0), + t.text, + ); + } + let zone = EDGE_PX.min(span.width() / 3.0); + elems.push(("effectControls.transition.timeline.span".into(), span, name.to_string())); + elems.push(("effectControls.transition.timeline.span.in".into(), Rect::from_min_max(span.min, pos2(span.min.x + zone, span.max.y)), "start".into())); + elems.push(("effectControls.transition.timeline.span.out".into(), Rect::from_min_max(pos2(span.max.x - zone, span.min.y), span.max), "end".into())); + // the cut, through all three rows + let cx = x_of(lane, v, cut); + painter.line_segment([pos2(cx, row_a.min.y), pos2(cx, row_b.max.y)], Stroke::new(1.0, t.text_dim)); + // the playhead, while it is in view + let ph = app.session.playhead(); + if ph >= v.0 && ph <= v.1 { + let px = x_of(lane, v, ph); + let (top, tip) = (ruler.max.y - 13.0, ruler.max.y); + let head = vec![pos2(px - 5.0, top), pos2(px + 5.0, top), pos2(px + 5.0, tip - 5.0), pos2(px, tip), pos2(px - 5.0, tip - 5.0)]; + painter.add(egui::Shape::convex_polygon(head, t.playhead, Stroke::NONE)); + painter.line_segment([pos2(px, tip), pos2(px, lane.max.y)], Stroke::new(1.0, t.playhead)); + elems.push(("effectControls.transition.timeline.playhead".into(), Rect::from_min_max(pos2(px - 5.0, top), pos2(px + 5.0, tip)), "playhead".into())); + } + elems.push(("effectControls.transition.timeline".into(), lane, "transition timeline".into())); + elems.push(("effectControls.transition.timeline.ruler".into(), ruler, "time ruler".into())); + // the ruler moves the playhead (once the sequence is no longer borrowed, below) + let mut seek = None; + let rresp = ui.interact(ruler, egui::Id::new(("tr-ec-ruler", id.0)), Sense::click_and_drag()); + if (rresp.dragged() || rresp.clicked()) + && let Some(pos) = rresp.interact_pointer_pos() + { + seek = Some(rate.snap_nearest(tick_at(lane, v, pos.x.clamp(lane.min.x, lane.max.x))).max(Tick::ZERO)); + } + // the transition: its middle slides it, its ends change its duration + let grab = |px: f32| -> Option { + if px < span.min.x + zone { + Some(Edge::In) + } else if px > span.max.x - zone { + Some(Edge::Out) + } else { + None + } + }; + let sresp = ui.interact(span, egui::Id::new(("tr-ec-span", id.0)), Sense::click_and_drag()); + if let Some(h) = sresp.hover_pos() { + ui.ctx().set_cursor_icon(if grab(h.x).is_some() { egui::CursorIcon::ResizeColumn } else { egui::CursorIcon::Grab }); + } + if sresp.drag_started() { + let from = ui.input(|i| i.pointer.press_origin()).or(sresp.interact_pointer_pos()).map_or(span.center().x, |p: Pos2| p.x); + drag = Some(LaneDrag { edge: grab(from), start: x.start, duration: x.duration, view: v, from_x: from, begun: false }); + } + let mut change = None; + if sresp.dragged() + && let Some(d) = drag.as_mut() + && let Some(pos) = sresp.interact_pointer_pos() + { + let delta = rate.snap_nearest(ticks_for(lane, d.view, pos.x - d.from_x)); + let was = Transition { start: d.start, duration: d.duration, ..x.clone() }; + if let Some((st, du)) = trn::drag_span(track, &was, d.edge, delta, frame) + && (st, du) != (x.start, x.duration) + { + change = Some(json!({"start": st.0, "duration": du.0, "merge": true, "begin": !d.begun})); + d.begun = true; + } + } + // released, or a drag that lost its transition (deleted, deselected) while it went on + if !sresp.dragged() && !sresp.drag_started() { + drag = None; + } + if let Some(tk) = seek { + app.stop(); + app.session.set_playhead(tk); + } + ui.data_mut(|m| match drag { + Some(d) => { + m.insert_temp(mem, d); + } + None => m.remove::(mem), + }); + change +} + +/// The timeline's ruler: ticks and sequence timecode across `view` (the clip view's spacing). +fn paint_ruler(p: &egui::Painter, ruler: Rect, view: (Tick, Tick), rate: FrameRate, drop_frame: bool, t: &Tokens) { + let p = p.with_clip_rect(ruler.intersect(p.clip_rect())); + let span = (view.1 - view.0).0.max(1) as f64; + let frame_px = f64::from(ruler.width()) * rate.frame_duration().0.max(1) as f64 / span; + let base = rate.timecode_base(); + // frames, then seconds (a rate's timecode base) multiplied up; saturating, as a damaged rate may be huge + let steps: Vec = [1, 2, 5, 10, base / 2, base].into_iter().chain([2, 5, 10, 30, 60, 300, 600, 3600].map(|m| base.saturating_mul(m))).collect(); + // labels far enough apart to read; the small ticks divide the labelled ones evenly + let label_step = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 80.0); + let minor = steps.iter().copied().find(|s| *s > 0 && *s as f64 * frame_px >= 8.0 && label_step.is_none_or(|l| l % s == 0)); + let (f0, f1) = (rate.frame_at(view.0), rate.frame_at(view.1)); + let base_y = ruler.max.y - 1.0; + for (step, h, labelled) in [(minor, 3.0, false), (label_step, 7.0, true)] { + let Some(step) = step else { continue }; + let mut f = f0.div_euclid(step).saturating_mul(step); + // a step is at least 8 px wide, so this covers any ruler; the cap is for damaged numbers + for _ in 0..4096 { + if f > f1 { + break; + } + let x = x_of(ruler, view, rate.tick_of(f)); + p.line_segment([pos2(x, base_y - h), pos2(x, base_y)], Stroke::new(1.0, t.tl_ruler_tick)); + if labelled && f >= 0 { + let label = filmcraft_time::format_time(rate.tick_of(f), rate, drop_frame, filmcraft_time::TimeDisplay::Timecode, 48000); + p.text(pos2(x, ruler.min.y + 7.0), Align2::CENTER_CENTER, label, Tokens::ui(10.0), t.tl_ruler_text); + } + f = f.saturating_add(step); + } + } +} + /// One of a transition's own settings. Returns the change, as `sequence.setTransition` params. fn param_widget(ui: &mut egui::Ui, elems: &mut Elems, pid: &str, kind: &ParamKind, value: &ParamValue, frame: (f64, f64)) -> Option { let eid = format!("effectControls.transition.param.{pid}"); diff --git a/crates/ui-egui/tests/transitions_ui.rs b/crates/ui-egui/tests/transitions_ui.rs index fbcbabb5..bae155c9 100644 --- a/crates/ui-egui/tests/transitions_ui.rs +++ b/crates/ui-egui/tests/transitions_ui.rs @@ -274,3 +274,73 @@ fn transition_settings_in_effect_controls() { let want = serde_json::to_value(&def.instance().params.get("direction").unwrap().value).unwrap(); assert_eq!(d.transition(id).unwrap()["params"]["direction"], want, "Reset puts Push's own defaults back"); } + +/// Effect Controls' own timeline for a selected transition (#577): clip A, clip B and the +/// transition over their cut. Dragging its end changes its duration from that end, dragging its +/// middle slides it over the cut, each drag one undo step; the ruler moves the playhead. +#[test] +fn transition_timeline_in_effect_controls() { + let mut d = Driver::demo(); + let c = d.crossing(); + let frame = frame_ticks(d.rate()); + d.click(&format!("timeline.transition.{}", c.id)); + // both clips are shown, by name + let q = d.exec("sequence.inspect", json!({})); + let names: Vec = q["video"] + .as_array() + .unwrap() + .iter() + .flat_map(|t| t["items"].as_array().unwrap().clone()) + .filter(|i| i["start"].as_i64().unwrap() < c.cut + c.duration && i["start"].as_i64().unwrap() + i["duration"].as_i64().unwrap() > c.cut - c.duration) + .map(|i| i["name"].as_str().unwrap_or_default().to_string()) + .collect(); + let (a, b) = (d.label("effectControls.transition.timeline.a"), d.label("effectControls.transition.timeline.b")); + assert!(names.contains(&a) && names.contains(&b), "A {a:?} and B {b:?} are the clips at the cut: {names:?}"); + d.snapshot("transition-effect-controls-timeline"); + // its end: drag it right + let (x, y, w, h, _) = d.find("effectControls.transition.timeline.span.out").expect("the transition's end in Effect Controls"); + let (fx, fy) = (x + w / 2.0, y + h / 2.0); + d.ok("ui.drag", json!({"from": {"x": fx, "y": fy}, "to": {"x": fx + 30.0, "y": fy}, "steps": 8})); + d.frames(3); + let x1 = d.transition(c.id).unwrap(); + let (s1, d1) = (x1["start"].as_i64().unwrap(), x1["duration"].as_i64().unwrap()); + assert!(d1 > c.duration, "dragging the end out lengthens it: {} -> {d1}", c.duration); + assert_eq!(s1, c.start, "the start stays where it was"); + assert_eq!((d1 - c.duration) % frame, 0, "by whole frames"); + let (num, den) = d.rate(); + let rate = filmcraft_engine::time::FrameRate { num, den }; + assert_eq!( + d.label("effectControls.transition.duration"), + filmcraft_engine::time::format_timecode_frames(d1 / frame, rate, false), + "the Duration field follows" + ); + // its middle: slide it left (more than egui's click tolerance) + let (x, y, w, h, _) = d.find("effectControls.transition.timeline.span").unwrap(); + let (mx, my) = (x + w / 2.0, y + h / 2.0); + d.ok("ui.drag", json!({"from": {"x": mx, "y": my}, "to": {"x": mx - 14.0, "y": my}, "steps": 6})); + d.frames(3); + let x2 = d.transition(c.id).unwrap(); + assert_eq!(x2["duration"].as_i64(), Some(d1), "sliding keeps the duration"); + let s2 = x2["start"].as_i64().unwrap(); + assert!(s2 < s1, "and moves it left: {s1} -> {s2}"); + assert!(s2 + d1 >= c.cut, "still over the cut"); + assert_eq!((s1 - s2) % frame, 0, "by whole frames"); + // two drags, two undo steps + d.exec("edit.undo", json!({})); + let back = d.transition(c.id).unwrap(); + assert_eq!((back["start"].as_i64(), back["duration"].as_i64()), (Some(s1), Some(d1)), "the slide is one step"); + d.exec("edit.undo", json!({})); + let back = d.transition(c.id).unwrap(); + assert_eq!((back["start"].as_i64(), back["duration"].as_i64()), (Some(c.start), Some(c.duration)), "the resize is the other"); + // the ruler: a click at its left end puts the playhead before the cut, at its right end after + let (x, y, w, h, _) = d.find("effectControls.transition.timeline.ruler").unwrap(); + d.ok("ui.click", json!({"x": x + 3.0, "y": y + h / 2.0})); + d.frames(3); + let ph = d.exec("sequence.inspect", json!({}))["playhead"].as_i64(); + assert!(ph.is_some_and(|p| p < c.cut), "{ph:?}"); + d.ok("ui.click", json!({"x": x + w - 3.0, "y": y + h / 2.0})); + d.frames(3); + let ph = d.exec("sequence.inspect", json!({}))["playhead"].as_i64(); + assert!(ph.is_some_and(|p| p > c.cut), "{ph:?}"); + assert!(d.find("effectControls.transition.timeline.playhead").is_some(), "the playhead shows in the transition's timeline"); +} diff --git a/docs/gaps.md b/docs/gaps.md index b479a6d1..09461410 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -141,7 +141,8 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** rearrangement (#493); clicking empty space to deselect (#683); filmstrip thumbnails (#614); marquee selection in the Project panel (#578); Freeform bins and middle-mouse pan (#579); Program monitor Button Editor (+) (#431); keyframe curves for Position (#448); transition - editing controls (#577); track renaming (#654); window controls on macOS (#637); UI scale on + editing controls (#577: Effect Controls has the transition's own timeline, drag to slide or resize; + the A/B previews with Start / End sliders remain); track renaming (#654); window controls on macOS (#637); UI scale on X11 (#457). - **Estimate:** 120–200 h. Doc: [ui-parity.md](ui-parity.md). diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 611bb827..75844919 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -24,7 +24,7 @@ open interaction reports (about 40 of the 188 open issues). | Panels and docking | 17 workspaces, dock / undock / float, drop zones | 11 workspaces, docking; rearranging windows reported missing (#493); multi-monitor floating windows (#466) | 60% | #493, #466 | 20–35 h | | Project panel | List / Icon / Freeform, hover scrub, marquee | all three views, hover scrub, view presets; no marquee (#578), Freeform bins and pan (#579), bins can't be left on Windows (#587), duplicate imports allowed (#356) | 75% | #578, #579, #587, #456 | 10–20 h | | Timeline display | filmstrip thumbnails, waveforms, track heights, renaming | clip thumbnails, waveforms, heights; no continuous filmstrip (#614), no track rename (#654), wheel scroll in audio area (#400) | 70% | #614, #654, #400 | 8–15 h | -| Transitions | drag, resize, alignment, edit in Effect Controls | drag / select / resize (#503); transition controls (#577) | 70% | #577 | 5–10 h | +| Transitions | drag, resize, alignment, edit in Effect Controls | drag / select / resize (#503); settings and the transition's own timeline in Effect Controls, drag to slide or resize (#577); no A/B previews with Start / End sliders | 70% | #577 | 5–10 h | | Numeric entry and scrubbing | scrubbable hot text, typed values | scrubbable values, timecode scrub-edit (#576); audio clip mixer values cannot be typed (#481) | 80% | #481 | 3–6 h | | Drag and drop | OS files into Project / Timeline, clipboard paste | OS drops into the timeline (#517); no clipboard paste of images / files (#611) | 70% | #519, #611 | 5–10 h | | Theme and appearance | dark / light, brightness | dark / light / system (#505, #555) | 85% | macOS window controls (#637), Wayland title bar (#433), X11 scale (#457) | 5–10 h | From 73f9982c0c781d92486c34230c8245a2c5edf146 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:22:08 +0200 Subject: [PATCH 051/203] #498: French (fr) interface translation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: French, one of Premiere's interface languages and one of the 12 key languages in docs/gaps.md G14, had no catalog; #498 asked for it. Fix: a new fr.tsv catalog with every entry Spanish has (menus, panels, dialogs, Settings pages, the Effects / Effect Controls registry, the Keyboard Shortcuts dialog, status messages) plus the native macOS Settings item, written from the plain meaning of the English text with ordinary French editing vocabulary (infinitive commands, "vous", French sentence case and spacing). Language::Fr is persisted as `fr`; app.language.french joins Edit > Language (checkmark, native macOS menu), Settings > General offers Français, and System Language picks French for any fr-* locale. Like Japanese, tests require only the core menus, so later strings show in English until translated; a French entry without a Spanish one fails. A new test checks the bundled fonts cover every French letter (the glyph check is shared with Ukrainian). Docs: G14, localization-parity, ROADMAP language row, ui-egui README. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 +- apps/filmcraft/src/native_menu.rs | 2 + crates/engine/src/settings.rs | 3 +- crates/engine/src/settings_tests.rs | 4 +- crates/ui-egui/README.md | 4 +- crates/ui-egui/src/i18n/fr.tsv | 3544 ++++++++++++++++++++ crates/ui-egui/src/i18n/fr.tsv.attribution | 8 + crates/ui-egui/src/i18n/mod.rs | 113 +- crates/ui-egui/src/menus.rs | 4 + docs/gaps.md | 6 +- docs/localization-parity.md | 13 +- 12 files changed, 3672 insertions(+), 32 deletions(-) create mode 100644 crates/ui-egui/src/i18n/fr.tsv create mode 100644 crates/ui-egui/src/i18n/fr.tsv.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index ce7bd609..0c1793ed 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -9,6 +9,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| +| `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | diff --git a/ROADMAP.md b/ROADMAP.md index 4c5c2be9..9566bc59 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -78,7 +78,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Spanish | partial (whole interface) | 99.9% | | Hindi | none (no Devanagari shaping in the interface) | 0% | | Arabic | none (no RTL interface) | 0% | -| French | none | 0% | +| French | partial (whole interface; native review welcome) | ~100% | | Portuguese (Brazil) | menus only | 9.9% | | Indonesian | none | 0% | | Japanese | partial (whole interface) | 99.7% | diff --git a/apps/filmcraft/src/native_menu.rs b/apps/filmcraft/src/native_menu.rs index 562575f2..61241a8a 100644 --- a/apps/filmcraft/src/native_menu.rs +++ b/apps/filmcraft/src/native_menu.rs @@ -107,6 +107,8 @@ pub fn install(app: &FilmcraftApp, ctx: egui::Context) -> (Receiver, Sho filmcraft_ui_egui::i18n::Language::De } else if checked("app.language.russian") { filmcraft_ui_egui::i18n::Language::Ru + } else if checked("app.language.french") { + filmcraft_ui_egui::i18n::Language::Fr } else { filmcraft_ui_egui::i18n::Language::En }; diff --git a/crates/engine/src/settings.rs b/crates/engine/src/settings.rs index 5a14d32c..e6f91d78 100644 --- a/crates/engine/src/settings.rs +++ b/crates/engine/src/settings.rs @@ -34,7 +34,7 @@ pub const PREFS_VERSION: u32 = 2; #[serde(rename_all = "camelCase", default)] pub struct GeneralPrefs { /// Interface language (persisted independently of projects): `en`, `ja`, `es`, `pt-br`, `uk`, - /// `zh-cn`, `de`, or `system` (the default: the operating system's preferred language when the + /// `zh-cn`, `de`, `ru`, `fr`, or `system` (the default: the operating system's preferred language when the /// interface has it, otherwise English). pub interface_language: String, /// "At Startup": `showHome` (FilmCraft: the demo project), `openMostRecent`, `emptyProject`. @@ -776,6 +776,7 @@ static CATEGORIES: &[Category] = &[ ("zh-cn", "简体中文"), ("de", "Deutsch"), ("ru", "Русский"), + ("fr", "Français"), ]), true, ), diff --git a/crates/engine/src/settings_tests.rs b/crates/engine/src/settings_tests.rs index fd442cef..6df9e246 100644 --- a/crates/engine/src/settings_tests.rs +++ b/crates/engine/src/settings_tests.rs @@ -29,13 +29,15 @@ fn interface_language_persists_and_rejects_hostile_values() { let mut s = Session { prefs_path: Some(path.clone()), ..Session::default() }; set(&mut s, "general.interfaceLanguage", json!("es")); assert_eq!(Preferences::load(&path).general.interface_language, "es"); - for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { + for value in [json!("xx"), json!("es-MX"), json!("de-DE"), json!("fr-FR"), json!(null), json!(-1), json!({}), json!("x".repeat(4096))] { assert!(s.execute("prefs.set", json!({"key": "general.interfaceLanguage", "value": value})).is_err()); assert_eq!(s.prefs.general.interface_language, "es"); } assert_eq!(Preferences::load(&path).general.interface_language, "es"); set(&mut s, "general.interfaceLanguage", json!("de")); assert_eq!(Preferences::load(&path).general.interface_language, "de"); + set(&mut s, "general.interfaceLanguage", json!("fr")); + assert_eq!(Preferences::load(&path).general.interface_language, "fr"); // back to following the operating system (#218) set(&mut s, "general.interfaceLanguage", json!("system")); assert_eq!(Preferences::load(&path).general.interface_language, "system"); diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 1c723fea..69ccdc1e 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -18,7 +18,7 @@ the committed span and undo. Set `FILMCRAFT_UI_SNAPSHOT_DIR` to render screensho ## Localisation Edit > Language offers English, Japanese, Spanish, Brazilian Portuguese, Ukrainian, Simplified -Chinese and German. The language is stored in the engine's `general.interfaceLanguage` preference, +Chinese, German, Russian and French. The language is stored in the engine's `general.interfaceLanguage` preference, restored on startup, and also available in Settings > General. Its default, System Language (`system`), follows the operating system: the first of the user's preferred languages that the interface has (the host supplies them through @@ -32,7 +32,7 @@ fills translated templates, and `i18n::t` translates names from registries. Plac (including user filenames containing braces) are inserted literally. Catalog translations are original work using ordinary language, without proprietary localisation resources. -Spanish and Japanese cover menus, panels, dialogs, settings and registry labels. Searches accept both the +Spanish, Japanese, Simplified Chinese and French cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; they diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv new file mode 100644 index 00000000..f9f8c660 --- /dev/null +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -0,0 +1,3544 @@ +# French (fr) catalog. Same format as `es.tsv`: context source translation. +# +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# French video-editing vocabulary. No proprietary localisation resource, string table or product UI +# was consulted or copied; add entries the same way. Commands use the infinitive ("Enregistrer +# sous…"), messages address the user as "vous", and labels use French sentence case. Product, +# format and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS, EDL, AAF…) keep their +# spelling. French spacing: a space before ":", ";", "?" and "!". This catalog has the same entries +# as `es.tsv` plus the native macOS Settings menu; strings added later show in English until they +# are translated. A native-speaker review is welcome. +# +# Terms: clip = clip, track = piste, sequence = séquence, timeline = timeline, bin = chutier, +# playhead = tête de lecture, In/Out = point d'entrée / de sortie, marker = marqueur, keyframe = +# image clé, frame hold = image figée, edit point = point de montage, panel = panneau, workspace = +# espace de travail, render = rendu, preset = préréglage, captions = sous-titres, ripple = avec +# décalage, overwrite = écraser, scopes = oscilloscopes, preferences (also the native macOS +# Settings menu) = Préférences, settings = paramètres. + + About FilmCraft À propos de FilmCraft + Add Audio Submix Track Ajouter une piste de sous-mixage audio + Add Caption at Playhead Ajouter un sous-titre à la tête de lecture + Add Chapter Marker… Ajouter un marqueur de chapitre… + Add Edit Ajouter un point de montage + Add Edit to All Tracks Ajouter un point de montage à toutes les pistes + Add Flash Cue Marker… Ajouter un marqueur de point de repère Flash… + Add Frame Hold Ajouter une image figée + Add Guide… Ajouter un repère… + Add Marker Ajouter un marqueur + Add New Caption Track… Ajouter une piste de sous-titres… + Add Range Marker Ajouter un marqueur de plage + Add Range Marker to In and Out Ajouter un marqueur de plage de l'entrée à la sortie + Add Tracks… Ajouter des pistes… + Adjustment Layer… Calque d'effets… + Align to Selection Aligner sur la sélection + Align to Video Frame Aligner sur l'image vidéo + Align to Video Frame as Group Aligner sur l'image vidéo en tant que groupe + All Panels Tous les panneaux + Alpha Alpha + Appearance Apparence + Appearance… Apparence… + Apply Audio Transition Appliquer une transition audio + Apply Default Transitions to Selection Appliquer les transitions par défaut à la sélection + Apply Video Transition Appliquer une transition vidéo + Arrange Disposition + ArtCraft Website Site web d'ArtCraft + Assembly Assemblage + Attach Proxies… Associer des proxys… + Audio Channels… Canaux audio… + Audio Clip Mixer Mixeur de clips audio + Audio Gain… Gain audio… + Audio Hardware… Matériel audio… + Audio In Entrée audio + Audio Meters Vumètres audio + Audio Options Options audio + Audio Out Sortie audio + Audio Track Mixer Mixeur de pistes audio + Audio Waveform Forme d'onde audio + Auto Save… Enregistrement automatique… + Automate to Sequence… Automatiser vers la séquence… + Bars and Tone… Barres et tonalité… + Bin Chutier + Bin From Selection Chutier à partir de la sélection + Black Video… Vidéo noire… + Blue Bleu + Bottom Bas + Breakout to Mono Séparer en mono + Bring Forward Avancer + Bring to Front Mettre au premier plan + Brown Marron + Captions Sous-titres + Captions and Graphics Sous-titres et graphiques + Captions… Sous-titres… + Caribbean Caraïbes + Center Horizontally Centrer horizontalement + Center Vertically Centrer verticalement + Cerulean Céruléen + Clear Effacer + Clear Guides Effacer les repères + Clear In Effacer l'entrée + Clear In and Out Effacer l'entrée et la sortie + Clear Markers Effacer les marqueurs + Clear Out Effacer la sortie + Clear Selected Marker Effacer le marqueur sélectionné + Close Fermer + Close All Other Projects Fermer tous les autres projets + Close All Projects Fermer tous les projets + Close Gap Combler l'espace + Close Project Fermer le projet + Color Management… Gestion des couleurs… + Color Matte… Cache couleur… + Comparison View Vue de comparaison + Composite Video Vidéo composite + Consolidate Duplicates Regrouper les doublons + Copy Copier + Copy Paste Includes Sequence Markers Copier-coller inclut les marqueurs de séquence + Create Captions from Transcript… Créer des sous-titres à partir de la transcription… + Create Multi-Camera Source Sequence… Créer une séquence source multicaméra… + Create Proxies… Créer des proxys… + Cut Couper + Darkest Le plus sombre + Delete Render Files Supprimer les fichiers de rendu + Delete Render Files In to Out Supprimer les fichiers de rendu de l'entrée à la sortie + Delete Tracks… Supprimer des pistes… + Delete Transcript Supprimer la transcription + Demo Footage Rushes de démonstration + Demo Project Projet de démonstration + Deselect All Tout désélectionner + Detach Proxies Dissocier les proxys + Display Mode Mode d'affichage + Distribute Répartir + Distribute Horizontally Répartir horizontalement + Distribute Space Horizontally Répartir l'espacement horizontalement + Distribute Space Vertically Répartir l'espacement verticalement + Distribute Vertically Répartir verticalement + Duplicate Dupliquer + Dynamic Audio Waveforms Formes d'onde audio dynamiques + Edit Édition + Edit Offline… Modifier hors ligne… + Edit Original Modifier l'original + Edit Subclip… Modifier le sous-clip… + Editing Montage + Effect Controls Paramètres d'effet + Effects Effets + Ellipse Ellipse + Enable Activer + Enable Remix Activer le remix + Essential Graphics Graphiques essentiels + Essential Sound Son essentiel + Events Événements + Export Exporter + Export As Motion Graphics Template… Exporter en tant que modèle d'animation graphique… + Extract Extraire + Extract Audio Extraire l'audio + Field Options… Options de trame… + File Fichier + File… Fichier… + Fill frame Remplir l'image + FilmCraft Help… Aide de FilmCraft… + FilmCraft on GitHub FilmCraft sur GitHub + FilmCraft on getartcraft.com FilmCraft sur getartcraft.com + Find Next Rechercher le suivant + Find… Rechercher… + Fit Adapter + Fit to frame Adapter à l'image + Flatten Aplatir + Forest Forêt + Frame Blending Fusion d'images + Frame Hold Options… Options d'image figée… + Frame Sampling Échantillonnage d'images + From Bin Depuis le chutier + From Source Monitor Depuis le moniteur source + From Source Monitor, Match Frame Depuis le moniteur source, faire correspondre l'image + From file… Depuis un fichier… + Full Intégrale + Generate Audio Waveform Générer la forme d'onde audio + Get Media File Properties for Obtenir les propriétés du fichier média pour + Go to Gap Atteindre l'espace vide + Go to In Atteindre l'entrée + Go to Next Caption Segment Atteindre le segment de sous-titre suivant + Go to Next Marker Atteindre le marqueur suivant + Go to Out Atteindre la sortie + Go to Previous Caption Segment Atteindre le segment de sous-titre précédent + Go to Previous Marker Atteindre le marqueur précédent + Go to Split Atteindre la division + Graphics and Titles Graphiques et titres + Graphics… Graphiques… + Green Vert + Group Grouper + Guide Templates Modèles de repères + Help Aide + Hide All Caption Tracks Masquer toutes les pistes de sous-titres + High Quality Playback Lecture haute qualité + History Historique + Import From Importer depuis + Import Image Sequence… Importer une séquence d'images… + Import from Media Browser Importer depuis l'Explorateur de médias + Import… Importer… + Info Informations + Ingest Settings… Paramètres d'ingestion… + Insert Insérer + Insert Frame Hold Segment Insérer un segment d'image figée + Install Motion Graphics Template… Installer un modèle d'animation graphique… + Interpret Footage… Interpréter le métrage… + Join the ArtCraft Discord… Rejoindre le Discord d'ArtCraft… + Keyboard Shortcuts… Raccourcis clavier… + Label Étiquette + Labels… Étiquettes… + Language Langue + Lavender Lavande + Learning Apprentissage + Left Gauche + Libraries Bibliothèques + Lift Soulever + Light Clair + Link Lier + Link Media… Lier le média… + Refresh Changed Media Actualiser les médias modifiés + Linked Selection Sélection liée + Lock Guides Verrouiller les repères + Lumetri Color Couleur Lumetri + Lumetri Scopes Oscilloscopes Lumetri + Magnification Agrandissement + Make Offline… Rendre hors ligne… + Make Subclip… Créer un sous-clip… + Make Subsequence Créer une sous-séquence + Manage Guides… Gérer les repères… + Mark Clip Marquer le clip + Mark In Marquer l'entrée + Mark Out Marquer la sortie + Mark Selection Marquer la sélection + Mark Split Marquer la division + Markers Marqueurs + Match Frame Faire correspondre l'image + Media Analysis & Transcription… Analyse et transcription des médias… + Media Browser Explorateur de médias + Media Cache… Cache des médias… + Media… Média… + Medium Moyenne + Memory… Mémoire… + Merge Clips… Fusionner des clips… + Metadata Métadonnées + Modify Modifier + Motion Graphics Template… Modèle d'animation graphique… + Multi-Camera Multicaméra + Multi-Camera View Vue multicaméra + Nest… Imbriquer… + New Nouveau + New Layer Nouveau calque + New Project Panel Nouveau panneau Projet + Next in Sequence Suivant dans la séquence + Next in Track Suivant dans la piste + Normalize Mix Track… Normaliser la piste de mixage… + Offline File… Fichier hors ligne… + Open Project… Ouvrir un projet… + Optical Flow Flux optique + Overwrite Écraser + Paste Coller + Paste Attributes… Coller les attributs… + Paste Insert Coller en insertion + Paused Resolution Résolution en pause + Playback Resolution Résolution de lecture + Playback… Lecture… + Plugins… Plug-ins… + Polygon Polygone + Preferences Préférences + Previous in Sequence Précédent dans la séquence + Previous in Track Précédent dans la piste + Program Programme + Progress Progression + Project Projet + Project Manager… Gestionnaire de projets… + Project Settings Paramètres du projet + Project… Projet… + Properties Propriétés + Purple Violet + Reconnect Full Resolution Media… Reconnecter les médias en pleine résolution… + Recover Unsaved Changes… Récupérer les modifications non enregistrées… + Rectangle Rectangle + Red Rouge + Redo Rétablir + Reference Monitor Moniteur de référence + Remix Remix + Remix Properties… Propriétés du remix… + Remove Attributes… Supprimer les attributs… + Remove Filler Words Supprimer les mots parasites + Remove Pauses Supprimer les pauses + Remove Unused Supprimer les éléments inutilisés + Rename… Renommer… + Render Audio Effectuer le rendu audio + Render Effects In to Out Rendu des effets de l'entrée à la sortie + Render In to Out Rendu de l'entrée à la sortie + Render Selection Effectuer le rendu de la sélection + Replace Fonts in Projects… Remplacer les polices dans les projets… + Replace With Clip Remplacer par le clip + Report an Issue… Signaler un problème… + Reset All Parameters Réinitialiser tous les paramètres + Reset Duration Réinitialiser la durée + Reset to Saved Layout Réinitialiser la disposition enregistrée + Restore Captions from Source Clip Restaurer les sous-titres du clip source + Reveal Log Files… Afficher les fichiers journaux… + Reverse Match Frame Correspondance d'image inverse + Revert Revenir à la version enregistrée + Revert Remix Annuler le remix + Review Révision + Right Droite + Ripple Delete Supprimer avec décalage + Ripple Sequence Markers Décaler les marqueurs de séquence + Rose Rose + Safe Margins Marges de sécurité + Save Enregistrer + Save All Tout enregistrer + Save As… Enregistrer sous… + Save Guides as Template… Enregistrer les repères comme modèle… + Save a Copy… Enregistrer une copie… + Save as Template… Enregistrer comme modèle… + Scale to Frame Size Adapter à la taille de l'image + Scene Edit Detection… Détection des changements de scène… + Scratch Disks… Disques de travail… + Search Bin Chutier de recherche + Select Sélectionner + Select All Tout sélectionner + Select All Matching Sélectionner toutes les correspondances + Select Label Group Sélectionner le groupe d'étiquettes + Select Next Graphic Sélectionner le graphique suivant + Select Next Layer Sélectionner le calque suivant + Select Previous Graphic Sélectionner le graphique précédent + Select Previous Layer Sélectionner le calque précédent + Selection Follows Playhead La sélection suit la tête de lecture + Selection as FilmCraft Project… Sélection en tant que projet FilmCraft… + Selection… Sélection… + Send Backward Reculer + Send to Back Mettre à l'arrière-plan + Sequence Séquence + Sequence From Clip Séquence à partir du clip + Sequence Settings… Paramètres de séquence… + Sequence… Séquence… + Show Active Caption Tracks Only Afficher uniquement les pistes de sous-titres actives + Show All Caption Tracks Afficher toutes les pistes de sous-titres + Show All Marker Colors Afficher toutes les couleurs de marqueur + Show Guides Afficher les repères + Show Rulers Afficher les règles + Show Through Edits Afficher les points de montage traversants + Simplify Sequence… Simplifier la séquence… + Snap in Program Monitor Magnétisme dans le moniteur programme + Snap in Timeline Magnétisme dans la timeline + Source Source + Source Settings… Paramètres de la source… + Speed/Duration… Vitesse/durée… + Synchronize… Synchroniser… + System Compatibility Report… Rapport de compatibilité système… + Tan Beige + Teal Bleu canard + Text Texte + Time Interpolation Interpolation temporelle + Timecode Timecode + Timecode… Timecode… + Timeline Timeline + Timeline… Timeline… + Toggle Proxies Activer/désactiver les proxys + Tools Outils + Top Haut + Transcribe Sequence… Transcrire la séquence… + Transcribe… Transcrire… + Transcript Transcription + Transparent Video… Vidéo transparente… + Trim Edit Rogner le point de montage + Trim… Rogner… + Undo Annuler + Ungroup Dissocier le groupe + Universal Counting Leader… Amorce de décompte universelle… + Update Metadata… Mettre à jour les métadonnées… + Upgrade Caption to Graphic Convertir le sous-titre en graphique + Upgrade to Source Graphic Convertir en graphique source + Vertical Text Texte vertical + Video In Entrée vidéo + Video Options Options vidéo + Video Out Sortie vidéo + Video and Audio Waveform Split Vidéo et forme d'onde audio séparées + View Affichage + Violet Violet + Window Fenêtre + Workspaces Espaces de travail + Yellow Jaune + Zoom In Zoom avant + Zoom Out Zoom arrière + Zoom to Sequence Zoom sur la séquence + Home Accueil + Import Importer + {name} - Edited {name} - Modifié + Full screen Plein écran + Volume Volume + Master volume: use the Audio Track Mixer Volume principal : utilisez le Mixeur de pistes audio + Search Rechercher + Quick Export Exportation rapide + Join the ArtCraft Discord (discord.gg/artcraft) Rejoindre le Discord d'ArtCraft (discord.gg/artcraft) + Cancel Annuler + OK OK + Interpret Footage Interpréter le métrage + Color Management · {n} item Gestion des couleurs · {n} élément + Color Management · {n} items Gestion des couleurs · {n} éléments + Media colour space (from file metadata): {detected} Espace colorimétrique du média (d'après les métadonnées du fichier) : {detected} + Override media colour space: Remplacer l'espace colorimétrique du média : + Use file metadata Utiliser les métadonnées du fichier + Sequence Color Management Gestion des couleurs de la séquence + Working Color Space Espace colorimétrique de travail + Wide gamut color (composite in BT.2020) Couleurs à gamut étendu (composition en BT.2020) + Auto tone map media (HDR and log into SDR) Mappage automatique des tons des médias (HDR et log vers SDR) + Audio Gain Gain audio + Set Gain to: Définir le gain sur : + Adjust Gain by: Ajuster le gain de : + Normalize Max Peak to: Normaliser le pic maximal à : + Normalize All Peaks to: Normaliser tous les pics à : + Peak Amplitude: {peak} Amplitude de crête : {peak} + Standard Standard + Adaptive Adaptatif + Mono Mono + Stereo Stéréo + Add Tracks Ajouter des pistes + Amount Nombre + Placement Emplacement + Track type Type de piste + Add video tracks Ajouter des pistes vidéo + Add audio tracks Ajouter des pistes audio + Add audio submix tracks Ajouter des pistes de sous-mixage audio + After {name} Après {name} + Before First Track Avant la première piste + Delete Tracks Supprimer des pistes + Video Tracks Pistes vidéo + Audio Tracks Pistes audio + Delete Video Tracks Supprimer des pistes vidéo + Delete Audio Tracks Supprimer des pistes audio + All Empty Tracks Toutes les pistes vides + Caption Tracks Pistes de sous-titres + Delete Caption Tracks Supprimer des pistes de sous-titres + Delete Caption Track Supprimer la piste de sous-titres + Delete Empty Caption Tracks Supprimer les pistes de sous-titres vides + Version {version} Version {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Un éditeur vidéo non linéaire en pur Rust, conçu en salle blanche. Membre de la famille ArtCraft. + Join the ArtCraft Discord Rejoindre le Discord d'ArtCraft + Source code on GitHub Code source sur GitHub + Report an issue Signaler un problème + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Polices : Inter, Noto Serif et JetBrains Mono (SIL OFL 1.1). + just now à l'instant + {n} s ago il y a {n} s + {n} min ago il y a {n} min + {n} h ago il y a {n} h + All Events Tous les événements + Warnings and Errors Avertissements et erreurs + Errors Only Erreurs uniquement + Clear All Tout effacer + No events. Aucun événement. + Show finished jobs Afficher les tâches terminées + Cancelled Annulée + Failed: {e} Échec : {e} + Done Terminée + No background jobs. Aucune tâche en arrière-plan. + Recover Unsaved Changes Récupérer les modifications non enregistrées + Untitled Sans titre + FilmCraft was closed while “{name}” had unsaved changes. FilmCraft a été fermé alors que « {name} » contenait des modifications non enregistrées. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. FilmCraft s'est fermé de manière inattendue alors que « {name} » contenait des modifications non enregistrées. + Recover unsaved changes from {when}? Récupérer les modifications non enregistrées de {when} ? + The project had not been saved yet. Le projet n'avait pas encore été enregistré. + {n} sessions have unsaved changes: {n} sessions contiennent des modifications non enregistrées : + Discard Ignorer + Recover Récupérer + Not Now Pas maintenant + Are you sure you want to discard your changes to '{file}'? Voulez-vous vraiment ignorer vos modifications de « {file} » ? + Yes Oui + No Non + Add media to your project. Drop files anywhere in the window, or choose a source below. Ajoutez des médias à votre projet. Déposez des fichiers n'importe où dans la fenêtre ou choisissez une source ci-dessous. + Browse files… Parcourir les fichiers… + Movies, audio and stills from disk Films, audio et images fixes depuis le disque + Demo footage Rushes de démonstration + Six procedural 1080p clips with sound Six clips procéduraux 1080p avec son + Demo project Projet de démonstration + A cut sequence with transitions, effects and music Une séquence montée avec transitions, effets et musique + Bars and Tone Barres et tonalité + SMPTE HD bars with 1 kHz reference tone Barres SMPTE HD avec tonalité de référence à 1 kHz + Community Communauté + Join us on Discord Rejoignez-nous sur Discord + OMF Export Settings Paramètres d'exportation OMF + AAF Export Settings Paramètres d'exportation AAF + OMF Title: Titre OMF : + Mixdown video Mixage final de la vidéo + Audio: Audio : + Encapsulate Encapsuler + Separate Audio Audio séparé + Embed Incorporer + Separate Files Fichiers séparés + Link to Media Lien vers les médias + Audio File Format: Format de fichier audio : + Sample Rate: Fréquence d'échantillonnage : + Bits per Sample: Bits par échantillon : + Rendering: Rendu : + Trim audio files Rogner les fichiers audio + Handle Frames: Images de marge : + Render audio clip effects Effectuer le rendu des effets de clips audio + Breakout to mono Séparer en mono + Exported {path} Exporté : {path} + Select a clip to view its metadata. Sélectionnez un clip pour afficher ses métadonnées. + Clip: {name} Clip : {name} + Open Ouvrir + (no sequence) (aucune séquence) + Show or hide {color} markers Afficher ou masquer les marqueurs {color} + Marker Marqueur + {tc} Chapter {tc} Chapitre + No markers. Press M to add one. Aucun marqueur. Appuyez sur M pour en ajouter un. + All markers are hidden by the colour filter. Tous les marqueurs sont masqués par le filtre de couleur. + Type: Type : + Video: Vidéo : + Codec: Codec : + Duration: Durée : + Settings: Paramètres : + Playhead: Tête de lecture : + Video {n}: Vidéo {n} : + Audio {n}: Audio {n} : + UI: Interface : + {fps} fps · {n} frames queued {fps} fps · {n} images en file d'attente + {panel} — coming in a later milestone {panel} — disponible dans un jalon ultérieur + Close Panel Fermer le panneau + Close Other Timeline Panels Fermer les autres panneaux Timeline + Close All Tout fermer + Maximize Frame Agrandir le cadre + Restore Frame Restaurer le cadre + Restore Workspace Restaurer l'espace de travail + Reveal Sequence in Project Afficher la séquence dans le projet + Video Thumbnails Vignettes vidéo + Audio Waveforms Formes d'onde audio + Delete Preset Supprimer le préréglage + Export Preset… Exporter le préréglage… + Import Presets… Importer des préréglages… + Effect presets Préréglages d'effets + Save Preset Enregistrer le préréglage + Name: Nom : + Type Type + Scale Échelle + Anchor to In Point Ancrer au point d'entrée + Anchor to Out Point Ancrer au point de sortie + Without keyframes Sans images clés + Description: Description : + Saved preset “{name}” Préréglage « {name} » enregistré + Current duration {seconds} s Durée actuelle : {seconds} s + Remix Properties Propriétés du remix + Target Duration: Durée cible : + Segments: Segments : + Fewer · More Moins · Plus + Variations: Variantes : + Remixed to {seconds} s Remixé à {seconds} s + {mode} · no clip {mode} · aucun clip + {mode} · no sequence {mode} · aucune séquence + Remove Row Supprimer la ligne + Add a timecode row Ajouter une ligne de timecode + Panner Panoramique + Mute Couper le son + Send {n} Level Niveau du départ {n} + Show Keyframes Afficher les images clés + Clip Keyframes Images clés du clip + Track Keyframes Images clés de la piste + Delete Supprimer + Untitled Workspace Espace de travail sans titre + New Workspace Nouvel espace de travail + Edit Workspaces Modifier les espaces de travail + Rename Renommer + Restore Original Restaurer l'original + Search effects Rechercher des effets + Apply to Selected Clips Appliquer aux clips sélectionnés + Metadata Display Affichage des métadonnées + Save As New View Preset Enregistrer comme nouveau préréglage d'affichage + Manage Saved View Presets Gérer les préréglages d'affichage enregistrés + Freeform View Options Options de la vue libre + Save Arrangement Enregistrer la disposition + Saves the view, columns, sort, thumbnail and font size. Enregistre la vue, les colonnes, le tri, la vignette et la taille de police. + Grid spacing: Espacement de la grille : + Default clip size: Taille de clip par défaut : + Snap to grid Magnétisme sur la grille + Show clip names Afficher les noms des clips + Show durations Afficher les durées + Show: Afficher : + Search fields Rechercher dans les champs + Project Metadata Métadonnées du projet + Add Property… Ajouter une propriété… + Column order Ordre des colonnes + {n}. (empty) {n}. (vide) + Restore Restaurer + Overwrite with Current View Remplacer par la vue actuelle + Graphics text search arrives with M10.1–M10.2 La recherche de texte dans les graphiques arrive avec M10.1–M10.2 + Open a sequence to work with captions Ouvrez une séquence pour travailler sur les sous-titres + Add captions Ajouter des sous-titres + Create a caption track or import a caption file. Créez une piste de sous-titres ou importez un fichier de sous-titres. + Create new caption track Créer une piste de sous-titres + Import captions file… Importer un fichier de sous-titres… + New {format} track Nouvelle piste {format} + Add caption at playhead Ajouter un sous-titre à la tête de lecture + Split caption at playhead Scinder le sous-titre à la tête de lecture + Merge selected captions Fusionner les sous-titres sélectionnés + Delete selected captions Supprimer les sous-titres sélectionnés + No captions on this track. Press + to add one at the playhead. Aucun sous-titre sur cette piste. Appuyez sur + pour en ajouter un à la tête de lecture. + No matching captions. Aucun sous-titre correspondant. + Open a sequence to see its transcript Ouvrez une séquence pour voir sa transcription + Transcribe sequence Transcrire la séquence + Speech-to-text turns the dialogue into editable text. La reconnaissance vocale convertit les dialogues en texte modifiable. + This build has no speech-to-text; import a transcript with transcript.set. Cette version n'inclut pas la reconnaissance vocale ; importez une transcription avec transcript.set. + Transcribe Transcrire + Extract selected text Extraire le texte sélectionné + Lift selected text Soulever le texte sélectionné + Remove filler words Supprimer les mots parasites + Create captions Créer des sous-titres + Speaker Intervenant + Box Cadre + Align Aligner + Align left Aligner à gauche + Align center Centrer + Align right Aligner à droite + Position Position + Position: top Position : haut + Position: middle Position : milieu + Position: bottom Position : bas + (gap) (espace vide) + Out: {name} Sortie : {name} + In: {name} Entrée : {name} + Play Around Edit (Space loops, Shift+K once) Lire autour du point de montage (Espace en boucle, Maj+K une fois) + Trim Backward Many Rogner de plusieurs images en arrière + Trim Backward Rogner en arrière + Apply Default Transitions Appliquer les transitions par défaut + Apply Default Transitions to Selection (Shift+D) Appliquer les transitions par défaut à la sélection (Maj+D) + Trim Forward Rogner en avant + Trim Forward Many Rogner de plusieurs images en avant + Exit Trim Mode Quitter le mode de rognage + Voice-over record (right-click: Voice-Over Record Settings…) Enregistrer une voix off (clic droit : Paramètres d'enregistrement de voix off…) + Voice-Over Record Settings… Paramètres d'enregistrement de voix off… + Recording voice-over… Enregistrement de la voix off… + Voice-over recorded: {p} Voix off enregistrée : {p} + Voice-over recording stopped (nothing recorded) Enregistrement de la voix off arrêté (rien n'a été enregistré) + Post-roll Post-roll + ● Recording ● Enregistrement + Voice-Over Record Settings Paramètres d'enregistrement de voix off + Source: Source : + Default Input Entrée par défaut + Input: Entrée : + Channel {n} Canal {n} + Countdown Sound Cues Bips de compte à rebours + Pre-roll: Pré-roll : + seconds secondes + Post-roll: Post-roll : + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. La lecture démarre au pré-roll avant la tête de lecture (ou le point d'entrée) ; si l'entrée et la sortie sont définies, l'enregistrement démarre et s'arrête à ces points. + Effect Effet + Clip Fx Editor Éditeur d'effets de clip + Track Fx Editor Éditeur d'effets de piste + Master Gain Gain principal + Reset Réinitialiser + Band {n} Bande {n} + Solo Solo + Bypass Bypass + Thr Seuil + Ratio Ratio + Att Att + Rel Rel + Gain Gain + Output Gain Gain de sortie + Limiter Limiteur + Link Channels Lier les canaux + Limiter Threshold Seuil du limiteur + Limiter Release Relâchement du limiteur + Auto Gate Gate automatique + Threshold Seuil + Attack Attaque + Release Relâchement + Hold Maintien + Compressor Compresseur + Auto Makeup Compensation auto + Makeup Compensation + Expander Expandeur + Soft Clip Écrêtage doux + (no sequences) (aucune séquence) + (no clips) (aucun clip) + Show Transport Controls Afficher les commandes de transport + Loop Boucle + Multi-Camera Audio Follows Video L'audio multicaméra suit la vidéo + Multi-Camera Record Enregistrement multicaméra + Multi-Camera Selection Top Down Sélection multicaméra de haut en bas + Show Multi-Camera Preview Monitor Afficher le moniteur d'aperçu multicaméra + Auto-Adjust Multi-Camera Playback Quality Ajuster automatiquement la qualité de lecture multicaméra + Transmit Multi-Camera View Transmettre la vue multicaméra + Multi-Camera Layout Disposition multicaméra + Automatic Automatique + Edit Cameras… Modifier les caméras… + Add Marker (M) Ajouter un marqueur (M) + Mark In (I) Marquer l'entrée (I) + Mark Out (O) Marquer la sortie (O) + Go to In (Shift+I) Aller à l'entrée (Maj+I) + Step Back 1 Frame (Left) Reculer d'une image (Gauche) + Play-Stop Toggle (Space) Lecture/arrêt (Espace) + Step Forward 1 Frame (Right) Avancer d'une image (Droite) + Go to Out (Shift+O) Aller à la sortie (Maj+O) + Insert (,) Insérer (,) + Overwrite (.) Écraser (.) + Export Frame (Shift+E) Exporter l'image (Maj+E) + Lift (;) Soulever (;) + Extract (') Extraire (') + Export Frame: use Export mode (M6) Exporter l'image : utilisez le mode Exportation (M6) + Button Editor Éditeur de boutons + Multi-Camera Record on Enregistrement multicaméra activé + Multi-Camera Record off Enregistrement multicaméra désactivé + {first} - Merged {first} - Fusionné + {first} Multicam {first} Multicaméra + In Points Points d'entrée + Out Points Points de sortie + Clip Marker Marqueur de clip + Audio Audio + Merge Clips Fusionner les clips + Create Multi-Camera Source Sequence Créer une séquence source multicaméra + Synchronize Clips Synchroniser les clips + Clip Name: Nom du clip : + Sequence Name: Nom de la séquence : + {n} clip(s): {names} {n} clip(s) : {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} clip(s) sélectionné(s) ; le clip de la piste de référence reste en place. + Synchronize Point Point de synchronisation + Ignore Hours Ignorer les heures + first marker premier marqueur + Offset Audio by: Décaler l'audio de : + Offset: Décalage : + frames images + Reference track: Piste de référence : + lowest (V1…) la plus basse (V1…) + Remove Audio from AV Clip Retirer l'audio du clip AV + Camera 1 Caméra 1 + All Cameras Toutes les caméras + Switch Audio Changer d'audio + Camera Names Noms des caméras + Clip Names Noms des clips + Enumerate Cameras Numéroter les caméras + Camera Angle Metadata Métadonnées d'angle de caméra + Move source clips to Processed Clips bin Déplacer les clips source dans le chutier Clips traités + Synchronized {n} clip group(s) {n} groupe(s) de clips synchronisé(s) + Merged clip created Clip fusionné créé + Multi-camera source sequence with {n} camera(s) Séquence source multicaméra avec {n} caméra(s) + No multi-camera clip at the playhead Aucun clip multicaméra à la tête de lecture + Camera {n} — {name} Caméra {n} — {name} + Page {page} of {pages} Page {page} sur {pages} + Previous page Page précédente + Next page Page suivante + Multi-Camera Record On/Off (0) Activer/désactiver l'enregistrement multicaméra (0) + Record Enregistrer + Edit Cameras Modifier les caméras + Cameras shown in the Multi-Camera view (uncheck to hide). Caméras affichées dans la vue multicaméra (décochez pour masquer). + Recorded {n} multi-camera cut(s) {n} coupe(s) multicaméra enregistrée(s) + Clamp Signal Limiter le signal + Presets Préréglages + Parade ({kind}) Parade ({kind}) + Waveform ({kind}) Forme d'onde ({kind}) + Parade Type Type de parade + Waveform Type Type de forme d'onde + Vectorscope Targets Cibles du vectorscope + Colour Space Espace colorimétrique + Brightness Luminosité + Signal Scale Échelle du signal + Histogram Histogramme + Paste Attributes Coller les attributs + Remove Attributes Supprimer les attributs + New Offline File Nouveau fichier hors ligne + Nested Sequence Name Nom de la séquence imbriquée + Make Subclip Créer un sous-clip + Edit Subclip Modifier le sous-clip + Modify Clip: Audio Channels Modifier le clip : canaux audio + Modify Clip: Timecode Modifier le clip : timecode + Frame Hold Options Options d'image figée + Field Options Options de trame + Save Project Enregistrer le projet + Motion Mouvement + Opacity Opacité + Time Remapping Remappage temporel + Channel Volume Volume du canal + In Point Point d'entrée + Out Point Point de sortie + Playhead Tête de lecture + Source Timecode Timecode source + Sequence Time Temps de la séquence + Video Attributes Attributs vidéo + Audio Attributes Attributs audio + No effects Aucun effet + Scale Attribute Times Adapter le minutage des attributs + File Name: Nom du fichier : + Tape Name: Nom de la bande : + Contains: Contient : + Video Vidéo + Width: Largeur : + Height: Hauteur : + Frame Rate: Fréquence d'images : + Media Start: Début du média : + Subclip: {name} Sous-clip : {name} + Start: Début : + End: Fin : + Restrict Trims To Subclip Boundaries Limiter le rognage aux limites du sous-clip + Convert to Master Clip Convertir en clip maître + Clip Channel Format: Format des canaux du clip : + Number of Audio Clips: Nombre de clips audio : + Media Source Channels Canaux source du média + Ch. {n} Can. {n} + Clip {n} Clip {n} + Source channels the selected audio clips play: Canaux source lus par les clips audio sélectionnés : + Set Timecode: Définir le timecode : + Use the file's timecode Utiliser le timecode du fichier + Hold On Figer sur + Timecode: Timecode : + Hold Filters Figer les filtres + Reverse Field Dominance Inverser la dominance de trame + Processing Options Options de traitement + None Aucun + Always Deinterlace Toujours désentrelacer + Flicker Removal Suppression du scintillement + Save changes to “{project_name}” before closing? Enregistrer les modifications de « {project_name} » avant de fermer ? + Don't Save Ne pas enregistrer + All media is online. Tous les médias sont en ligne. + {name} (copy) {name} (copie) + Offline Hors ligne + Missing Introuvable + Link Media Lier les médias + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clip(s) ne trouvent pas leurs médias. Localisez-les, recherchez dans un dossier ou laissez-les hors ligne. + Clip Name Nom du clip + File Name Nom du fichier + File Path Chemin du fichier + Status État + Match file properties: Propriétés de fichier à faire correspondre : + File Extension Extension du fichier + Clip ID (fingerprint) ID du clip (empreinte) + Duration Durée + Media Start Début du média + Frame Size / Rate Taille/fréquence d'images + Align Timecode Aligner le timecode + Relink others automatically Relier les autres automatiquement + Search in: Rechercher dans : + Browse… Parcourir… + Exact name matches only Correspondances exactes du nom uniquement + No candidates yet — Search a folder or Locate the file. Aucun candidat pour l'instant — recherchez dans un dossier ou localisez le fichier. + ✔ same file ✔ même fichier + ✔ matches ✔ correspond + ✖ different file ✖ fichier différent + Preview Aperçu + Offline All Tout mettre hors ligne + Locate… Localiser… + Linked {n} clip(s) {n} clip(s) lié(s) + Nothing found with that name. Aucun élément trouvé avec ce nom. + Make Offline Mettre hors ligne + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Mettre hors ligne {n} clip(s) sélectionné(s). Ces clips affichent le carton hors ligne jusqu'à ce que vous les liiez à nouveau. + Media files remain on disk Les fichiers média restent sur le disque + Media files are deleted Les fichiers média sont supprimés + Create Proxies Créer des proxys + {n} clip(s) selected. {n} clip(s) sélectionné(s). + Format and size Format et taille + Destination Destination + Next to the original media, in a Proxies folder À côté des médias d'origine, dans un dossier Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Les proxys sont créés en arrière-plan et associés une fois terminés. L'exportation utilise toujours les médias en pleine résolution. + Creating {n} proxy file(s)… Création de {n} fichier(s) proxy… + Project Manager Gestionnaire de projets + Sequences Séquences + Resulting Project Projet résultant + Collect Files and Copy to New Location Rassembler les fichiers et copier vers un nouvel emplacement + Consolidate and Transcode Consolider et transcoder + Options Options + Exclude Unused Clips Exclure les clips inutilisés + Include Handles: Inclure les marges : + Include Proxies Inclure les proxys + Include Preview Files Inclure les fichiers d'aperçu + Destination Path Chemin de destination + Disk space: original {original} · resulting {result} ({n} files) Espace disque : original {original} · résultat {result} ({n} fichiers) + Disk space: — Espace disque : — + Calculate Calculer + Project Manager: writing {path} Gestionnaire de projets : écriture de {path} + Timeline: (no sequences) Timeline : (aucune séquence) + Browse Parcourir + Select audio clips, then click a preset to apply it. Sélectionnez des clips audio, puis cliquez sur un préréglage pour l'appliquer. + {name} (user) {name} (utilisateur) + Select audio clips to edit them here Sélectionnez des clips audio pour les modifier ici + Multiple Clips Selected Plusieurs clips sélectionnés + The selected clips have different audio types. Assign one type to edit them together. Les clips sélectionnés ont des types audio différents. Attribuez-leur un même type pour les modifier ensemble. + Assign an audio type to the selection: Attribuez un type audio à la sélection : + Clear Audio Type Effacer le type audio + Preset: Préréglage : + (Custom) (Personnalisé) + Preset name Nom du préréglage + Reduce Noise Réduire le bruit + Reduce Rumble Réduire les grondements + DeHum Supprimer le bourdonnement + DeEss Atténuer les sibilances + Reduce Reverb Réduire la réverbération + Dynamics Dynamique + EQ Égaliseur + Enhance Speech Améliorer la voix + Low Tone Graves + High Tone Aigus + Mix Mixage + Reverb Réverbération + Stereo Width Largeur stéréo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Le remixage de la musique à une durée cible n'est pas encore disponible. Rognez plutôt le clip et ajoutez un fondu enchaîné. + Pan Panoramique + Auto-Match Correspondance auto + Not matched yet Pas encore mis en correspondance + Matches each of the {n} clips on its own. Met en correspondance chacun des {n} clips séparément. + Duck against: Atténuer en fonction de : + Untagged Non classé + Sensitivity Sensibilité + Reduce By Réduire de + Fades Fondus + Generate Keyframes Générer des images clés + Clip Volume Volume du clip + (no clip selected) (aucun clip sélectionné) + Master · {name} Maître · {name} + Add Lumetri Color to clip Ajouter l'effet Couleur Lumetri au clip + Input LUT LUT d'entrée + Color Couleur + Temperature Température + Tint Nuance + Saturation Saturation + Exposure Exposition + Contrast Contraste + Highlights Tons clairs + Shadows Tons foncés + Whites Blancs + Blacks Noirs + HDR HDR + HDR White Blanc HDR + HDR Specular Spéculaire HDR + Look Look + Look LUT LUT de look + Intensity Intensité + Adjustments Réglages + Faded Film Film délavé + Sharpen Netteté + Vibrance Vibrance + RGB Curves Courbes RGB + HDR Range Plage HDR + Hue Saturation Curves Courbes de teinte et saturation + Hue vs Sat Teinte vs saturation + Hue vs Hue Teinte vs teinte + Hue vs Luma Teinte vs luminance + Luma vs Sat Luminance vs saturation + Sat vs Sat Saturation vs saturation + Midtones Tons moyens + Key Incrustation + Hue Teinte + Hue Range Plage de teinte + Saturation Min Saturation min. + Luma Min Luminance min. + Luma Max Luminance max. + Soften Adoucir + Show Mask Afficher le masque + Off Désactivé + Color/Gray Couleur/gris + Color/Black Couleur/noir + White/Black Blanc/noir + Refine Affiner + Denoise Débruiter + Blur Flou + Correction Correction + Hue Shift Décalage de teinte + Midpoint Point médian + Roundness Arrondi + Feather Contour progressif + LUT LUT + Color Match Correspondance des couleurs + Reference Référence + Face Detection (protect skin tones) Détection des visages (protéger les tons chair) + Apply Match Appliquer la correspondance + Create ellipse mask Créer un masque elliptique + Create 4-point polygon mask Créer un masque polygonal à 4 points + Free draw bezier Bézier à main levée + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Cliquez dans le moniteur programme pour placer les points du masque (faites glisser pour créer des courbes) ; cliquez sur le premier point ou appuyez sur Entrée pour fermer + Delete Mask Supprimer le masque + Inverted Inversé + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Cmd+clic : supprimer + Track selected mask backward Suivre le masque sélectionné en arrière + Track selected mask backward 1 frame Suivre le masque sélectionné d'une image en arrière + Track selected mask forward 1 frame Suivre le masque sélectionné d'une image en avant + Track selected mask forward Suivre le masque sélectionné en avant + Stop tracking Arrêter le suivi + Tracking method: {method} Méthode de suivi : {method} + Composite Composite + Comparison Comparaison + Video and Waveform Vidéo et forme d'onde + System Default Par défaut du système + Default location Emplacement par défaut + Font family Famille de polices + Label Colors Couleurs d'étiquette + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Images décodées conservées pour les moniteurs et les vignettes : {used} sur {budget}. + FilmCraft keeps the rest of the RAM free for other applications. FilmCraft laisse le reste de la RAM libre pour les autres applications. + not available in this session non disponible dans cette session + Currently: {loc} — {files} file(s), {size} Actuellement : {loc} — {files} fichier(s), {size} + ({ch} output channels) ({ch} canaux de sortie) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Programme gauche → sortie {l} du périphérique, droite → sortie {r}{dev}. Les sorties au-delà des canaux du périphérique reviennent à 1 et 2. + Auto-saves go to: {dir} Enregistrements automatiques dans : {dir} + Recovery copy: {dir} Copie de récupération : {dir} + Last auto-save {a} · last recovery copy {j} Dernier enregistrement automatique {a} · dernière copie de récupération {j} + Last auto-save {a} Dernier enregistrement automatique {a} + Last recovery copy {j} Dernière copie de récupération {j} + Remove Media Cache Files Supprimer les fichiers du cache des médias + Deleted {n} file(s), {size}. {n} fichier(s) supprimé(s), {size}. + Reset… Réinitialiser… + Source · {name} Source · {name} + Reset Effect Réinitialiser l'effet + Reset Parameter Réinitialiser le paramètre + Save Preset… Enregistrer le préréglage… + Custom Setup Configuration personnalisée + Edit… Modifier… + Show graphs Afficher les graphiques + Toggle animation Activer/désactiver l'animation + Eyedropper (pick a color from the Program monitor) Pipette (prélever une couleur dans le moniteur programme) + Eyedropper: no picture to sample Pipette : aucune image à échantillonner + Add/Remove Keyframe Ajouter/supprimer une image clé + Temporal Interpolation Interpolation temporelle + Linear Linéaire + Bezier Bézier + Auto Bezier Bézier auto + Continuous Bezier Bézier continu + Ease In Lissage en entrée + Ease Out Lissage en sortie + Select a clip to grade Sélectionnez un clip à étalonner + Select a clip to see its properties Sélectionnez un clip pour voir ses propriétés + Anchor point Point d'ancrage + Rotation Rotation + Level Niveau + Speed {n}% Vitesse {n} % + Value Valeur + Velocity Vélocité + Default Par défaut + Custom ({n} points) Personnalisé ({n} points) + Select a graphic clip, or use the Type tool (T) on the Program monitor Sélectionnez un clip graphique ou utilisez l'outil Texte (T) dans le moniteur programme + Create new graphic Créer un graphique + New Text Layer Nouveau calque de texte + New Text Nouveau texte + New Rectangle Nouveau rectangle + New Ellipse Nouvelle ellipse + Delete Layer Supprimer le calque + Select a layer to edit its properties. Sélectionnez un calque pour modifier ses propriétés. + Align Left Aligner à gauche + Align Center Horizontally Centrer horizontalement + Align Right Aligner à droite + Align Top Aligner en haut + Align Center Vertically Centrer verticalement + Align Bottom Aligner en bas + Font Police + Font Style Style de police + Font Size Taille de police + Alignment Alignement + Left Align Text Aligner le texte à gauche + Center Align Text Centrer le texte + Right Align Text Aligner le texte à droite + Justify Justifier + Tracking Approche + Leading Interlignage + Baseline Shift Décalage de la ligne de base + Text Box Width Largeur de la zone de texte + Style Style + Faux Bold Faux gras + Faux Italic Faux italique + All Caps Tout en capitales + Small Caps Petites capitales + Underline Souligné + OpenType OpenType + Kerning Crénage + Ligatures Ligatures + Shape Forme + Size Taille + Corner Radius Rayon des angles + Polygon Sides Côtés du polygone + Fill Remplissage + Stroke Contour + Stroke 2 Contour 2 + Background Arrière-plan + Opacity Opacité + Size Taille + Corner Radius Rayon des angles + Shadow Ombre + Angle Angle + Distance Distance + Blur Flou + Search templates Rechercher des modèles + All Categories Toutes les catégories + Install… Installer… + Install Motion Graphics Template (.fcgt) Installer un modèle d'animation graphique (.fcgt) + No templates match. Aucun modèle ne correspond. + {category} · My Template {category} · Mon modèle + Apply Appliquer + Remove Retirer + {n} editable properties {n} propriétés modifiables + Template: {name} Modèle : {name} + Intro Duration Durée de l'introduction + Outro Duration Durée de la conclusion + Roll Défilement + Start Off Screen Commencer hors de l'écran + End Off Screen Terminer hors de l'écran + Preroll Pré-roll + Postroll Post-roll + Video Frame Cadre vidéo + Pin To Épingler à + Pinned Edges Bords épinglés + FilmCraft Graphics Template Modèle de graphiques FilmCraft + {n} Font Police de {n} + {n} Size Taille de {n} + {n} Color Couleur de {n} + {n} Position Position de {n} + Show {n} Afficher {n} + My Templates Mes modèles + Export As Motion Graphics Template Exporter en tant que modèle d'animation graphique + Category: Catégorie : + Editable properties: Propriétés modifiables : + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Enregistré en tant que modèle de graphiques FilmCraft (.fcgt) dans votre dossier de modèles. + Exported graphics template to {path} Modèle de graphiques exporté vers {path} + Replace Fonts in Projects Remplacer les polices dans les projets + Fonts used in the project: Polices utilisées dans le projet : + {f} (missing) {f} (manquante) + Replace: Remplacer : + Replaced {n} font uses {n} utilisations de polices remplacées + Show/Hide Effects and Sends Afficher/masquer les effets et les envois + Audio Track Mixer menu Menu du mixeur de pistes audio + Show/Hide Tracks… Afficher/masquer les pistes… + Meter Input(s) Only Mesurer uniquement les entrées + Show/Hide Tracks Afficher/masquer les pistes + Show All Tout afficher + Post-Fader Post-fader + Remove Effect Retirer l'effet + Fill the slots above first Remplissez d'abord les emplacements ci-dessus + pre pré + Pre-Fader Pré-fader + Mute Send Couper le son de l'envoi + Remove Send Retirer l'envoi + New Submix Nouveau sous-mixage + Pan / balance (drag; double-click: centre) Panoramique / balance (faites glisser ; double-clic : centre) + Solo safe Protection solo + Enable track for recording Activer la piste pour l'enregistrement + Volume (drag; double-click: 0 dB) Volume (faites glisser ; double-clic : 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panoramique 5.1 (faites glisser le point ; double-clic : centre avant) + Center Centre + Go to In Point Atteindre le point d'entrée + Go to Out Point Atteindre le point de sortie + Play In to Out Lire de l'entrée à la sortie + Record (voice-over on the record-enabled track) Enregistrer (voix off sur la piste activée pour l'enregistrement) + Write keyframes Écrire les images clés + Something went wrong while drawing the window. Your project is still open; save it to be safe. Une erreur s'est produite lors de l'affichage de la fenêtre. Votre projet est toujours ouvert ; enregistrez-le par précaution. + Continue Continuer + Go Back Précédent + Go Forward Suivant + Up One Level Remonter d'un niveau + File Types Displayed Types de fichiers affichés + List View Affichage en liste + Thumbnail View Affichage en vignettes + Ingest Ingestion + Open Ingest Settings Ouvrir les paramètres d'ingestion + Import as Image Sequence Importer en tant que séquence d'images + {sel} of {total} items selected {sel} éléments sélectionnés sur {total} + {total} items {total} éléments + Cannot open {dir}: {e} Impossible d'ouvrir {dir} : {e} + Favorites Favoris + Local Drives Disques locaux + Network Réseau + Recent Directories Dossiers récents + (none) (aucun) + Remove from Favorites Retirer des favoris + Add to Favorites Ajouter aux favoris + Clear Recent Directories Effacer les dossiers récents + Open In Source Monitor Ouvrir dans le moniteur source + Reveal in Finder Afficher dans le gestionnaire de fichiers + Edit Columns… Modifier les colonnes… + Hover Scrub Défilement au survol + New Media Browser Panel Nouveau panneau Explorateur de médias + Create Folder For Imported Projects Créer un dossier pour les projets importés + Allow Duplicate Media During Project Import Autoriser les médias en double lors de l'importation de projets + Refresh Actualiser + Edit Columns Modifier les colonnes + Guides Repères + (no audio) (aucun audio) + Generating waveform… Génération de la forme d'onde… + Reference {tc} Référence {tc} + Set Définir + Set the reference to the playhead Placer la référence sur la tête de lecture + Reference: next frame Référence : image suivante + Reference: previous frame Référence : image précédente + RGBA Channels Canaux RGBA + Add Guide Ajouter un repère + Save Guides as Template Enregistrer les repères comme modèle + Manage Guides Gérer les repères + Orientation: Orientation : + Vertical Vertical + Horizontal Horizontal + Position: Position : + No saved guide templates. Aucun modèle de repères enregistré. + Project: {name} Projet : {name} + Bin: {name} Chutier : {name} + Up one level Remonter d'un niveau + Video Usage: {vu} Audio Usage: {au} Utilisation vidéo : {vu} Utilisation audio : {au} + Icon View Affichage en icônes + Freeform View Vue libre + Sort Icons Trier les icônes + Clear (Delete) Effacer (Suppr) + New Item Nouvel élément + New Bin Nouveau chutier + Find Rechercher + Automate to Sequence Automatiser vers la séquence + Select items to clear Sélectionnez les éléments à effacer + User Order Ordre utilisateur + Descending Décroissant + Refresh Project Actualiser le projet + Reveal Project in Finder… Afficher le projet dans le gestionnaire de fichiers… + New Bin From Selection Nouveau chutier à partir de la sélection + New Search Bin Nouveau chutier de recherche + New Project Shortcut Nouveau raccourci de projet + List Liste + Icon Icône + Freeform Libre + Preview Area Zone d'aperçu + Thumbnails Vignettes + Thumbnails show effects applied Les vignettes affichent les effets appliqués + Thumbnail controls for all pointing devices Commandes de vignettes pour tous les périphériques de pointage + Refresh Sort Order Actualiser l'ordre de tri + Metadata Display… Affichage des métadonnées… + Save Current View Preset Enregistrer le préréglage d'affichage actuel + Restore View Preset Restaurer le préréglage d'affichage + Project View Preset {n} Préréglage d'affichage du projet {n} + Freeform View Options… Options de la vue libre… + Media missing Média manquant + Unreadable Illisible + Open in Source Monitor Ouvrir dans le moniteur source + Open in Timeline Ouvrir dans la timeline + New Sequence From Clip Nouvelle séquence à partir du clip + Set Poster Frame Définir l'image d'affiche + Proxy Proxy + Open in Place Ouvrir sur place + Open in New Tab Ouvrir dans un nouvel onglet + Open in New Window Ouvrir dans une nouvelle fenêtre + {n} items {n} éléments + Stack Empiler + Unstack Désempiler + Clip Size Taille des clips + Larger Plus grand + Smaller Plus petit + Align to Grid Aligner sur la grille + Reset to Grid Réinitialiser sur la grille + Save Arrangement… Enregistrer la disposition… + Restore Arrangement Restaurer la disposition + Delete Arrangement Supprimer la disposition + Drop media here to create sequence. Déposez des médias ici pour créer une séquence. + Double-click a sequence in the Project panel to open it here. Double-cliquez sur une séquence dans le panneau Projet pour l'ouvrir ici. + Open Demo Project Ouvrir le projet de démonstration + Insert and overwrite sequences as nests or individual clips Insérer et écraser des séquences en tant que séquences imbriquées ou clips individuels + Snap in Timeline (S) Magnétisme dans la timeline (S) + Caption track options Options de la piste de sous-titres + Timeline Display Settings Paramètres d'affichage de la timeline + Show Video Thumbnails Afficher les vignettes vidéo + Head Début + Head and Tail Début et fin + Continuous Continu + Show Audio Waveform Afficher la forme d'onde audio + Expand All Tracks Développer toutes les pistes + Minimize All Tracks Réduire toutes les pistes + Unrendered: should play back in real time Sans rendu : devrait être lu en temps réel + Unrendered: render to play back in real time Sans rendu : effectuez le rendu pour une lecture en temps réel + Rendered preview Aperçu rendu + ✓ Enable ✓ Activer + Unlink Dissocier + (Default) (Par défaut) + (RGB) Gamma (RGB) Gamma + (RGB) Input Black Level (RGB) Niveau de noir en entrée + (RGB) Input White Level (RGB) Niveau de blanc en entrée + (RGB) Output Black Level (RGB) Niveau de noir en sortie + (RGB) Output White Level (RGB) Niveau de blanc en sortie + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Point noir 10 bits + 10 Bit White Point Point blanc 10 bits + 105mm Prime Focale fixe 105 mm + 29.97 fps Drop-Frame 29,97 fps drop frame + 29.97 fps Non Drop-Frame 29,97 fps non-drop frame + 35mm Prime Focale fixe 35 mm + 3D Roll Roulement 3D + 3D Rotate Rotation 3D + 3D Spin Pivotement 3D + 3D Spinback Pivotement inverse 3D + 4-Color Gradient Dégradé 4 couleurs + 5.1 Media Médias 5.1 + 5.1 Mixdown Type Type de mixage final 5.1 + 50-300mm Zoom Zoom 50-300 mm + 59.94 fps Drop-Frame 59,94 fps drop frame + 59.94 fps Non Drop-Frame 59,94 fps non-drop frame + ASC CDL ASC CDL + Accessible color contrast Contraste des couleurs accessible + Action-Safe Margin Marge de sécurité de l'action + Active Monitor Moniteur actif + Add Ajouter + Add tracks automatically when editing source clips onto the Timeline Ajouter automatiquement des pistes lors du montage de clips source dans la timeline + Place files dropped onto the Timeline directly on the Timeline Placer les fichiers déposés sur la timeline directement dans la timeline + 1 file 1 fichier + {n} files {n} fichiers + Additional Scale Échelle supplémentaire + Additive Dissolve Fondu additif + Additive Dissolve (Legacy) Fondu additif (ancien) + Adjust Régler + Advanced Avancé + Aggressive Agressif + Align and Transform Aligner et transformer + All lines Toutes les lignes + Allow Selection tool to choose Roll and Ripple trims without modifier key Autoriser l'outil Sélection à choisir les rognages par roulement et avec décalage sans touche de modification + Allow current tool to change trim type of previously selected edit point Autoriser l'outil actuel à modifier le type de rognage du point de montage précédemment sélectionné + Allow duplicate media during project import Autoriser les médias en double lors de l'importation de projets + Alpha Adjust Réglage alpha + Alpha Blurriness Flou alpha + Alpha Channel Canal alpha + Alpha Glow Lueur alpha + Always import embedded CEA-708 closed captions with wide aspect. Toujours importer les sous-titres codés CEA-708 incorporés au format large. + Always override audio tags Toujours remplacer les balises audio + Ambience Ambiance + Ambience Intensity Intensité de l'ambiance + Ambience Light Color Couleur de la lumière ambiante + Amount of Noise Quantité de bruit + Amount to Decolor Quantité de décoloration + Amount to Tint Quantité de teinte + Amplifier Amplificateur + Amplify Amplifier + Amplitude Amplitude + Amplitude and Compression Amplitude et compression + Analog Analogique + Analog Delay Retard analogique + Analyze all imported media to search for visuals or audio Analyser tous les médias importés pour rechercher des éléments visuels ou audio + Anamorphic Anamorphique + Anchor Ancre + Anchor Point Point d'ancrage + Angle Angle + Animation Animation + Anti-aliasing Quality Qualité de l'anticrénelage + Anti-flicker Filter Filtre anti-scintillement + Antialiasing for Best Quality Anticrénelage pour une qualité optimale + Asymmetry Asymétrie + At Startup Au démarrage + At playback end, return to beginning when restarting playback En fin de lecture, revenir au début lors de la reprise de la lecture + Attempt to force hardware to document sample rate Tenter de forcer le matériel à la fréquence d'échantillonnage du document + Audio Codec Codec audio + Audio Duration Durée audio + Audio Effects Effets audio + Audio Hardware Matériel audio + Audio In Point Point d'entrée audio + Audio Info Informations audio + Audio Out Point Point de sortie audio + Audio Samples Échantillons audio + Audio Time Units Unités de temps audio + Audio Transition Default Duration Durée par défaut des transitions audio + Audio Transitions Transitions audio + Audio Usage Utilisation audio + Auto Auto + Auto Align Alignement automatique + Auto Reframe Recadrage automatique + Auto Save Enregistrement automatique + Auto Save also saves the current project(s) L'enregistrement automatique enregistre aussi le ou les projets actuels + Auto-tag audio types in the timeline Baliser automatiquement les types audio dans la timeline + Auto-transcribe all imported clips Transcrire automatiquement tous les clips importés + Auto-transcribe only clips in sequences Transcrire automatiquement uniquement les clips des séquences + Automatch Time Durée de correspondance automatique + Automatic Click Remover Suppression automatique des clics + Automatically Hide Dependent Clips Masquer automatiquement les clips dépendants + Automatically Save Every Enregistrer automatiquement toutes les + Automatically delete cache files older than Supprimer automatiquement les fichiers de cache datant de plus de + Automatically delete oldest cache files when cache exceeds Supprimer automatiquement les fichiers de cache les plus anciens lorsque le cache dépasse + Automatically refresh growing files Actualiser automatiquement les fichiers en croissance + Automatically resume playback for growing files in Source Monitor Reprendre automatiquement la lecture des fichiers en croissance dans le moniteur source + Automatically save projects Enregistrer automatiquement les projets + Automatically transcribe clips Transcrire automatiquement les clips + Automation Keyframe Optimization Optimisation des images clés d'automatisation + Background Color Couleur d'arrière-plan + Background Corner Radius Rayon des angles de l'arrière-plan + Background Opacity Opacité de l'arrière-plan + Background Size Taille de l'arrière-plan + Background fill Remplissage de l'arrière-plan + Balance Balance + Balanced Voice Voix équilibrée + Band 1 Bande 1 + Band 2 Bande 2 + Band 3 Bande 3 + Band 4 Bande 4 + Band 5 Bande 5 + Band Pass Passe-bande + Band Slide Coulissement de bandes + Band Stop Coupe-bande + Bandpass Passe-bande + Barn Doors Volets + Basic De base + Basic 3D 3D de base + Basic Correction Correction de base + Bass Basses + Behind Derrière + Bessel Bessel + Bevel Alpha Biseau alpha + Bevel join Jointure biseautée + Binauralizer - Ambisonics Binauraliseur - Ambisonics + Bins Chutiers + Black Noir + Black & White Noir et blanc + Black Input Level Niveau d'entrée du noir + Black and white Noir et blanc + Black and white with crushed blacks and strong contrast Noir et blanc avec noirs écrasés et contraste marqué + Bleach Bypass Sans blanchiment + Bleach-bypass style: desaturated with hard contrast Style sans blanchiment : désaturé avec un contraste dur + Blend Fusion + Blend Mode Mode de fusion + Blend With Original Fusion avec l'original + Blending Mode Mode de fusion + Block Dissolve Fondu en blocs + Block Height Hauteur des blocs + Block Motion Mouvement des blocs + Block Size Taille des blocs + Block Width Largeur des blocs + Blocks Blocs + Blue Blurriness Flou du bleu + Blue Curve Courbe du bleu + Blue Offset Décalage du bleu + Blue Power Puissance du bleu + Blue Slope Pente du bleu + Blue-Blue Bleu-bleu + Blue-Const Bleu-constante + Blue-Green Bleu-vert + Blue-Red Bleu-rouge + Blur & Sharpen Flou et netteté + Blur Amount Quantité de flou + Blur Angle Angle du flou + Blur Dimensions Dimensions du flou + Blur Dissolve Fondu avec flou + Blur Layer Calque de flou + Blur Length Longueur du flou + Blurriness Flou + Bokeh Blur Flou bokeh + Boost Renforcement + Border Bordure + Border Color Couleur de la bordure + Border Width Largeur de la bordure + Borders Bordures + Bottom Center En bas au centre + Bottom Left En bas à gauche + Bottom Right En bas à droite + Bounce Rebond + Branching Ramification + Bright Lumineux + Bright Air Air lumineux + Bright Studio Studio lumineux + Brightness & Contrast Luminosité et contraste + British Stack Pile britannique + Brush Hardness Dureté du pinceau + Brush Opacity Opacité du pinceau + Brush Position Position du pinceau + Brush Size Taille du pinceau + Brush Spacing (secs) Espacement du pinceau (s) + Brush Strokes Coups de pinceau + Bubbles Bulles + Bulge Renflement + Bulge Smoother Lissage du renflement + Bump Height Hauteur du relief + Burn Alpha Brûlure alpha + Burn Chroma Brûlure chrominance + Burn Color Brûlure couleur + Butt cap Extrémité plate + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Mettre en cache les résultats d'analyse pour les réutiliser + Camera Angle Angle de caméra + Camera Blur Flou de caméra + Camera Shake Tremblement de caméra + Capitalisation Casse + Cathedral Cathédrale + Cell Pattern Motif de cellules + Center Channel Pan Panoramique du canal central + Center Gradient Dégradé central + Center Input Entrée centrale + Center Out Du centre vers l'extérieur + Center Point (Latitude) Point central (latitude) + Center Point (Longitude) Point central (longitude) + Center Split Division centrale + Center of Sphere Centre de la sphère + Cents Cents + Change to Color Changer en couleur + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. Les changements sont copiés en arrière-plan peu après chaque opération de montage. Si FilmCraft se ferme de manière inattendue, ils vous sont proposés au démarrage suivant. + Channel Canal + Channel Blur Flou de canal + Channel Mix Mixage des canaux + Channel Mixer Mixeur de canaux + Chaos Chaos + Chebyshev Tchebychev + Checker Wipe Balayage en damier + Checkerboard Damier + Chinese Chinois + Choke Rétrécir + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Chrominance + Chroma Leak Fuite de chrominance + Chromatic Aberration (Blue) Aberration chromatique (bleu) + Chromatic Aberration (Green) Aberration chromatique (vert) + Chromatic Aberration (Red) Aberration chromatique (rouge) + Cineon Converter Convertisseur Cineon + Circle Cercle + Clarity Clarté + Clean Combo Combo propre + Clean Up Noisy Dialogue Nettoyer les dialogues bruités + Clear Muddiness Supprimer l'effet boueux + Client Client + Clip Clip + Clip Level Niveau du clip + Clipping Écrêtage + Clock Wipe Balayage horloge + Clock Wipe (Legacy) Balayage horloge (ancien) + Clockwise Sens horaire + Clone Cloner + Closed Captions Sous-titres codés + Color & Alpha Couleur et alpha + Color 1 Couleur 1 + Color 2 Couleur 2 + Color 3 Couleur 3 + Color 4 Couleur 4 + Color Balance Balance des couleurs + Color Burn Brûlure de couleur + Color Channel Canal de couleur + Color Correction Correction colorimétrique + Color Distortion Distorsion des couleurs + Color Dodge Éclaircissement de couleur + Color Emboss Relief en couleur + Color Key Incrustation couleur + Color Pass Isolation de couleur + Color Replace Remplacement de couleur + Color Space Espace colorimétrique + Color Theme Thème de couleurs + Color To Leave Couleur à conserver + Color Tolerance Tolérance de couleur + Color Wheels & Match Roues chromatiques et correspondance + Colorize Coloriser + Columns Colonnes + Comment Commentaire + Complexity Complexité + Composite In Back Composer à l'arrière + Composite In Front Composer à l'avant + Composite Using Composer avec + Composite on Original Composer sur l'original + Compound Blur Flou composé + Compression Before Clipping Compression avant écrêtage + Constant Gain Gain constant + Constant Power Puissance constante + Conversion Type Type de conversion + Convolution Reverb Réverbération à convolution + Cool Blue Bleu froid + Cool, desaturated and punchy Froid, désaturé et percutant + Core Width Largeur du noyau + Corner Coin + Corner Pin Épinglage des coins + Count Nombre + Counter-clockwise Sens antihoraire + Crash Recovery Récupération après plantage + Crawl Left Défilement vers la gauche + Crawl Right Défilement vers la droite + Create folder for imported projects Créer un dossier pour les projets importés + Creative Créatif + Crop Recadrage + Crop Less <-> Smooth More Recadrer moins <-> Lisser plus + Cross Displacement Déplacement croisé + Cross Dissolve Fondu enchaîné + Cross Dissolve (Legacy) Fondu enchaîné (ancien) + Cross Zoom Zoom croisé + Cross Zoom (Legacy) Zoom croisé (ancien) + Crossfade Fondu croisé + Crossovers Fréquences de croisement + Crystallize Cristalliser + Crystals Cristaux + Cube Spin Rotation de cube + Curl Radius Rayon de courbure + Current Time Temps actuel + Cursor Curseur + Curvature Courbure + Curve Courbe + Curves Courbes + Custom Personnalisé + Custom Color Couleur personnalisée + Cutoff Coupure + Cyan-teal shadows and warm highlights with a little more contrast Tons foncés cyan-bleu canard et tons clairs chauds avec un peu plus de contraste + Cycle (in Revolutions) Cycle (en tours) + Cycle Evolution Évolution cyclique + Damping Amortissement + Damping HF Amortissement HF + Damping LF Amortissement BF + Dark Sombre + Darken Assombrir + Darker Color Couleur plus sombre + Date Created Date de création + Date Modified Date de modification + Day for night: darker, cooler and less saturated Nuit américaine : plus sombre, plus froid et moins saturé + DeEsser Dé-esseur + DeHummer Suppresseur de bourdonnement + DeNoise Réducteur de bruit + DeReverb Réduction de réverbération + Decay Déclin + Decay Time (RT60) Temps de déclin (RT60) + Default Audio Tracks Pistes audio par défaut + Default Emoji font Police d'emoji par défaut + Default Media Scaling Mise à l'échelle des médias par défaut + Default Output Sortie par défaut + Default closed caption font Police par défaut des sous-titres codés + Default language Langue par défaut + Default paragraph direction Sens de paragraphe par défaut + Default subtitle and text font Police par défaut des sous-titres et du texte + Delay Retard + Delay 1 Retard 1 + Delay 2 Retard 2 + Delay 3 Retard 3 + Delay 4 Retard 4 + Delay and Echo Retard et écho + Delete files older than Supprimer les fichiers datant de plus de + Delete oldest files when the cache exceeds Supprimer les fichiers les plus anciens lorsque le cache dépasse + Delete… Supprimer… + Depth Profondeur + Desaturate Désaturer + Description Description + Detail Amplitude Amplitude des détails + Detail Level Niveau de détail + Detailed Analysis Analyse détaillée + Device Class Classe de périphérique + Dialogue Dialogue + Diamond Losange + Difference Différence + Dimensions Dimensions + Dimmed Atténué + Dip to Black Fondu au noir + Dip to Black (Legacy) Fondu au noir (ancien) + Dip to Color Fondu à la couleur + Dip to White Fondu au blanc + Dip to White (Legacy) Fondu au blanc (ancien) + Direction Direction + Directional Directionnel + Directional Blur Flou directionnel + Directional Blur (Legacy) Flou directionnel (ancien) + Disable video output when in the background Désactiver la sortie vidéo en arrière-plan + Disperse Disperser + Displacement Déplacement + Display Color Management Gestion des couleurs de l'écran + Display Color Settings Paramètres de couleur de l'écran + Display out of sync indicators for unlinked clips Afficher les indicateurs de désynchronisation des clips dissociés + Dissolve Fondu enchaîné + Dissolve Blocks Blocs de fondu + Distance Distance + Distance to Image Distance à l'image + Distort Déformer + Distortion Distorsion + Distortion Complexity Complexité de la distorsion + Distortion Rate Vitesse de la distorsion + Distortion Type Type de distorsion + Divide Diviser + Do not delete cache files automatically Ne pas supprimer automatiquement les fichiers de cache + Don't label speakers Ne pas étiqueter les intervenants + Double-click Double-clic + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Décodage brouillon à résolution de lecture réduite (H.264 : plus rapide, certaines images moins filtrées) + Drive Saturation + Drop Shadow Ombre portée + Dry Signal sec + Dry Out Sortie sèche + Dry Up a Roomy Voice Assécher une voix trop réverbérée + Duck Deep Under Dialogue Atténuer fortement sous les dialogues + Duck Under Dialogue Atténuer sous les dialogues + Duck Under Voice and Effects Atténuer sous la voix et les effets + Ducking Atténuation automatique + Dutch Néerlandais + Dynamic Link Dynamic Link + Dynamics Processing Traitement de la dynamique + Early Reflections Premières réflexions + Earthquake Tremblement de terre + Ease In and Out Lissage à l'entrée et à la sortie + Easing Lissage + Echo Écho + Echo Glow Lueur d'écho + Echo Operator Opérateur d'écho + Echo Time (seconds) Temps d'écho (secondes) + Echoes Échos + Edge Color Couleur du contour + Edge Feather Contour progressif des bords + Edge Glow Lueur des contours + Edge Sharpness Netteté du bord + Edge Softness Adoucissement du bord + Edge Thickness Épaisseur du bord + Edge Thin Amincir le bord + Edge Threshold Seuil du bord + Edge Type Type de bord + Edge Width Largeur du bord + Elliptical Elliptique + Emboss Estampage + Enable HSL Secondary Activer HSL secondaire + Enable Mercury Transmit Activer Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Activer l'encodage ProRes accéléré par le matériel, si disponible + Enable clip and XMP metadata linking Activer la liaison des métadonnées de clip et XMP + Enable developer mode Activer le mode développeur + Enable hardware accelerated decoding (requires restart) Activer le décodage accéléré par le matériel (redémarrage requis) + Enable language auto-detection Activer la détection automatique de la langue + Enable proxies Activer les proxys + End Color Couleur de fin + End Point Point de fin + End of Ramp Fin de la rampe + Error Erreur + Evolution Évolution + Evolution Options Options d'évolution + Exciter Amount Quantité d'excitation + Exciter Mode Mode d'excitation + Exclusion Exclusion + Exponential Fade Fondu exponentiel + Extended Dynamic Range Monitoring Contrôle en plage dynamique étendue + FFT Filter Filtre FFT + Fade Fondu + Fade Out Fondu de sortie + Faded Matte Cache délavé + Falloff Atténuation + Far Away Lointain + Fast Rapide + Faster Motion Mouvement plus rapide + Feedback Réinjection + Feedback 1 Réinjection 1 + Feedback 2 Réinjection 2 + Feedback 3 Réinjection 3 + Feedback 4 Réinjection 4 + Feet + Frames 16mm Pieds + images 16 mm + Feet + Frames 35mm Pieds + images 35 mm + Female Femme + File Size Taille du fichier + Fill Background Remplir l'arrière-plan + Fill Color Couleur de remplissage + Fill Left with Right Remplir la gauche avec la droite + Fill Point Point de remplissage + Fill Right with Left Remplir la droite avec la gauche + Fill Selector Sélecteur de remplissage + Film Dissolve Fondu enchaîné film + Film Dissolve (Legacy) Fondu enchaîné film (ancien) + Film Roll Bobine de film + Filter Filtre + Filter Frequency Fréquence du filtre + Filter Resonance Résonance du filtre + Filter and EQ Filtre et égaliseur + Final Delay Retard final + Find Edges Détection des contours + Flanger Flanger + Flare Reflet + Flare Brightness Luminosité du reflet + Flare Center Centre du reflet + Flare Color Couleur du reflet + Flash Flash + Flicker Scintillement + Flickers Scintillements + Flip Motion Mouvement de retournement + Flip Over Retourner + Float Virgule flottante + Focus Mise au point + Focus Blur Flou de mise au point + Focus Point Point de mise au point + Focus Shape Forme de la mise au point + Focus Size Taille de la mise au point + Fold Motion Mouvement de pliage + Foldback Retour + For color management parameters, go to the Settings tab in the Lumetri Color panel. Pour les paramètres de gestion des couleurs, accédez à l'onglet Paramètres du panneau Couleur Lumetri. + Format Format + Four Scopes (RGB, 8 bit) Quatre oscilloscopes (RGB, 8 bits) + Four Scopes (YUV, float, no clamp) Quatre oscilloscopes (YUV, virgule flottante, sans écrêtage) + Fractal Influence Influence fractale + Fractal Type Type de fractale + Frame Image + Frame Color Couleur du cadre + Frame Count Nombre d'images + Frame Layout Disposition de l'image + Frame Rate Fréquence d'images + Frame Width Largeur du cadre + Frame cache for monitors and thumbnails Cache d'images pour les moniteurs et les vignettes + Frames Images + Framing Cadrage + French Français + Frequency Fréquence + From De + From East Depuis l'est + From North Depuis le nord + From South Depuis le sud + From West Depuis l'ouest + From the Left Depuis la gauche + From the Right Depuis la droite + Front + LFE Avant + LFE + Front + Rear Avant + arrière + Front + Rear + LFE Avant + arrière + LFE + Front Only Avant uniquement + Fuzz Fuzz + GB Go + Gamma Gamma + Gamma Correction Correction gamma + Gamut Warning Avertissement de gamut + Gamut Warning Color Couleur d'avertissement de gamut + Gap Espace vide + Gap Color Couleur des espaces vides + Gaussian Blur Flou gaussien + Gaussian Blur (Legacy) Flou gaussien (ancien) + General Général + Generate Générer + Generate Timecode Générer le timecode + Generate waveforms and conform audio using multithreading Générer les formes d'onde et conformer l'audio en multithread + Generate waveforms automatically during import Générer automatiquement les formes d'onde lors de l'importation + German Allemand + Ghosts Fantômes + Glass Verre + Glint Scintillement lumineux + Glitch Glitch + Glow Lueur + Glow Brightness Luminosité de la lueur + Glow Color Couleur de la lueur + Glow Only Lueur uniquement + Glow Radius Rayon de la lueur + Glow Saturation Saturation de la lueur + Glow Width Largeur de la lueur + Golden Hour Heure dorée + Good Bonne + Gradient Dégradé + Gradient End Fin du dégradé + Gradient Layer Calque de dégradé + Gradient Placement Placement du dégradé + Gradient Scatter Dispersion du dégradé + Gradient Shape Forme du dégradé + Gradient Start Début du dégradé + Gradient Wipe Balayage en dégradé + Gradient Wipe (Legacy) Balayage en dégradé (ancien) + Grain Size Taille du grain + Graphic Equalizer (10 Bands) Égaliseur graphique (10 bandes) + Graphic Equalizer (20 Bands) Égaliseur graphique (20 bandes) + Graphic Equalizer (30 Bands) Égaliseur graphique (30 bandes) + Graphics Graphiques + Green Blurriness Flou du vert + Green Curve Courbe du vert + Green Offset Décalage du vert + Green Power Puissance du vert + Green Slope Pente du vert + Green-Blue Vert-bleu + Green-Const Vert-constante + Green-Green Vert-vert + Green-Red Vert-rouge + Grid Grille + Grow Croissance + Growing Files Fichiers en croissance + Grunge Grunge + Grunge & Distort Grunge et distorsion + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Blanc des graphiques HDR (nits) + HLS Vectorscope + Histogram Vectorscope HLS + histogramme + HSL Secondary HSL secondaire + Hard Dur + Hard Clip Écrêtage dur + Hard Light Lumière dure + Hard Limiter Limiteur dur + Hard Mix Mélange dur + Hardware decoding Décodage matériel + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Décodage matériel : Auto utilise le décodeur vidéo du système (VideoToolbox sur macOS, Media Foundation sur Windows, VA-API ou NVDEC pour H.264 et HEVC sous Linux) pour les flux qu'il prend en charge, et le décodeur propre à FilmCraft pour tout le reste ou en cas d'échec du matériel. Les médias déjà ouverts conservent leur décodeur jusqu'à leur réouverture. + Head Size Taille de la tête + Heart Cœur + Height Hauteur + Hexagon Hexagone + Hide Back Face Masquer la face arrière + High Élevée + High Cutoff Coupure des aigus + High Frequency Cut Coupure des hautes fréquences + High Frequency Damping Amortissement des hautes fréquences + High Pass Passe-haut + High Shelf Plateau aigu + High Shelf Gain Gain du plateau aigu + High contrast with held highlights and deep shadows Contraste élevé avec tons clairs préservés et tons foncés profonds + Highlight Ton clair + Highlight Blue Balance Balance du bleu des tons clairs + Highlight Brightness Luminosité des tons clairs + Highlight Color Couleur de surbrillance + Highlight Green Balance Balance du vert des tons clairs + Highlight Red Balance Balance du rouge des tons clairs + Highlight Rolloff Atténuation des tons clairs + Highlight Threshold Seuil des tons clairs + Highlight Tint Nuance des tons clairs + Highlights Lightness Luminosité des tons clairs + Highpass Passe-haut + Hindi Hindi + Hindi digits Chiffres hindis + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Alignement horizontal + Horizontal Blocks Blocs horizontaux + Horizontal Decentering Décentrage horizontal + Horizontal Displacement Déplacement horizontal + Horizontal Flip Retournement horizontal + Horizontal Margin Marge horizontale + Horizontal and Vertical Horizontal et vertical + Hue Center Centre de teinte + Hue Tolerance Tolérance de teinte + I/O Buffer Size Taille du tampon d'E/S + Ignore Alpha Ignorer l'alpha + Image Control Contrôle de l'image + Immersive Video Vidéo immersive + Import numbered stills as image sequences Importer les images fixes numérotées comme séquences d'images + Impulse Impulsion + In a Room Dans une pièce + In the Media Cache Dans le cache des médias + In the project Dans le projet + In/Out Duration Durée de l'entrée à la sortie + Include captions on import Inclure les sous-titres lors de l'importation + Incoming Entrant + Indeterminate Media Timebase Base temporelle des médias indéterminée + Initial Delay Retard initial + Inner Intérieur + Inner Radius Rayon intérieur + Input Boost Amplification d'entrée + Inset Encart + Inside Intérieur + Inside Color Couleur intérieure + Inter Inter + Interior Room Tone Ambiance de pièce intérieure + Internal Black Point Point noir interne + Internal White Point Point blanc interne + Interpolation Interpolation + Intersect Intersection + Inverse Falloff Atténuation inverse + Invert Inverser + Invert Alpha Inverser l'alpha + Invert Blur Inverser le flou + Invert Fill Inverser le remplissage + Invert Gradient Inverser le dégradé + Invert Left Inverser la gauche + Invert Right Inverser la droite + Iris Iris + Iris Box Iris carré + Iris Cross Iris en croix + Iris Diamond Iris en losange + Iris Radius Rayon de l'iris + Iris Rotation Rotation de l'iris + Iris Round Iris rond + Iris Shape Forme de l'iris + Italian Italien + Japanese Japonais + JetBrains Mono JetBrains Mono + Join Through Edits Joindre les points de montage traversants + Ripple Trim In Rogner l'entrée avec décalage + Ripple Trim Out Rogner la sortie avec décalage + Roll Edit Montage par roulement + Trim In Rogner l'entrée + Trim Out Rogner la sortie + Kaleidoscope Kaléidoscope + Keep a recovery copy of unsaved changes Conserver une copie de récupération des modifications non enregistrées + Key Color Couleur d'incrustation + Keying Incrustation + Korean Coréen + Label Defaults Étiquettes par défaut + Label speakers Étiqueter les intervenants + Labels Étiquettes + Large Hall Grande salle + Large Trim Offset Grand décalage de rognage + Large Volume Adjustment Réglage de volume important + Latch Verrouillage + Layer Name Nom du calque + Layers Calques + Leave Color Conserver une couleur + Left - Left Gauche - gauche + Left - Right Gauche - droite + Left channel to device output Canal gauche vers la sortie du périphérique + Left to Right De gauche à droite + Left to right De gauche à droite + Legacy Anciens + Length Longueur + Lens Blur Flou de l'objectif + Lens Distortion Distorsion de l'objectif + Lens Flare Reflet d'objectif + Lens Type Type d'objectif + Level 1 Niveau 1 + Level 2 Niveau 2 + Level 3 Niveau 3 + Level 4 Niveau 4 + Levels Niveaux + Lifted blacks, softer contrast, muted colour Noirs relevés, contraste plus doux, couleurs atténuées + Light 1 Lumière 1 + Light 2 Lumière 2 + Light 3 Lumière 3 + Light 4 Lumière 4 + Light 5 Lumière 5 + Light Angle Angle de la lumière + Light Color Couleur de la lumière + Light Intensity Intensité de la lumière + Light Leak Fuite de lumière + Light Leaks Fuites de lumière + Light Sweep Balayage lumineux + Light Type Type de lumière + Lighten Éclaircir + Lighter Color Couleur plus claire + Lighting Effects Effets d'éclairage + Lightning Éclair + Lights & Blurs Lumières et flous + Lights & Glows Lumières et lueurs + Line Thickness Épaisseur de ligne + Linear Burn Assombrissement linéaire + Linear Dodge (Add) Éclaircissement linéaire (ajout) + Linear Light Lumière linéaire + Linear Ramp Rampe linéaire + Linear Wipe Balayage linéaire + Linear Wipe (Legacy) Balayage linéaire (ancien) + Linear keyframe thinning Réduction linéaire des images clés + Linear to Log Linéaire vers log + Liquid Distortion Distorsion liquide + Local Projects Projets locaux + Location Emplacement + Log Note Note de journal + Log to Linear Log vers linéaire + Log to Log Log vers log + Logo Cutout Découpe de logo + Long Shadow Ombre longue + Look-Ahead Time Temps d'anticipation + Loudness Sonie + Loudness Maximizer Maximiseur de sonie + Loudness Meter Sonomètre + Louver Persienne + Low Faible + Low Frequency Cut Coupure des basses fréquences + Low Pass Passe-bas + Low Shelf Plateau grave + Low Shelf Gain Gain du plateau grave + Lower Left En bas à gauche + Lower Right En bas à droite + Lowpass Passe-bas + Luma Luma + Luma Curve Courbe de luma + Luma Fade Fondu de luma + Luma Key Incrustation en luma + Luma Source Source de luma + Luma and Chroma Luma et chroma + Lumetri Presets Préréglages Lumetri + Luminance Luminance + Luminance Threshold Seuil de luminance + Luminosity Luminosité + MB Mo + Magenta Magenta + Magnify Loupe + Magnify (Legacy) Loupe (ancien) + Maintain pitch while shuttling Conserver la hauteur tonale pendant le défilement + Major Radius Grand rayon + Male Homme + Mango Mangue + Map Black To Associer le noir à + Map White To Associer le blanc à + Maps full-range levels into 16–235 (video range) with a luma curve Convertit les niveaux pleine plage en 16–235 (plage vidéo) avec une courbe de luma + Mask Only Masque uniquement + Master Amplitude Amplitude générale + Mastering Mastering + Match frame sets in point L'image correspondante définit le point d'entrée + Matte Cache + Matte Alpha Cache alpha + Matte Cleanup Nettoyage du cache + Matte Generation Génération du cache + Matte Luma Cache de luma + Maximum Maximum + Maximum Amplitude Amplitude maximale + Maximum Block Size Taille de bloc maximale + Maximum Blur Flou maximal + Maximum Project Versions Nombre maximal de versions du projet + Maximum Reduction Réduction maximale + Maximum Scale Échelle maximale + Media Médias + Media Analysis Analyse des médias + Media Analysis & Transcription Analyse et transcription des médias + Media Cache Cache des médias + Media Cache Database Base de données du cache des médias + Media Cache Files Fichiers du cache des médias + Media Cache Management Gestion du cache des médias + Media Duration Durée du média + Media End Fin du média + Media Time Temps du média + Media Timecode Timecode du média + Media Type Type de média + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. Les analyses et transcriptions des médias seront mises en cache dans le dossier du cache des médias. Aucune nouvelle analyse ne sera nécessaire si les médias sont importés dans un autre projet sur cet ordinateur. + Median Médiane + Medium Room Pièce moyenne + Memory Mémoire + Metadata & Timecode Burn-in Incrustation des métadonnées et du timecode + Method Méthode + Metrics Kerning Crénage métrique + Mid Médiums + Mid Point Point médian + Midtone Blue Balance Balance du bleu des tons moyens + Midtone Green Balance Balance du vert des tons moyens + Midtone Red Balance Balance du rouge des tons moyens + Midtones Lightness Luminosité des tons moyens + Minimum Minimum + Minimum time Durée minimale + Minimum time interval thinning Réduction par intervalle de temps minimal + Minor Radius Petit rayon + Mirror Miroir + Mirror Alternate Miroir alterné + Missing font replacement Remplacement des polices manquantes + Miter join Jointure en onglet + Miter limit Limite de l'onglet + Mixed Crystals Cristaux mélangés + Mode Mode + Modern High Gain Gain élevé moderne + Modulation Modulation + Modulation Rate Vitesse de modulation + Mono Media Médias mono + Monochrome Monochrome + Monochrome toned cyan-blue Monochrome viré bleu cyan + Monochrome toned warm brown Monochrome viré brun chaud + Monoscopic Monoscopique + Monospace only Chasse fixe uniquement + More colour in the less saturated areas Plus de couleur dans les zones moins saturées + Morph Cut Fondu morphologique + Mosaic Mosaïque + Mosaic (Legacy) Mosaïque (ancien) + Motion Blur Flou de mouvement + Motion Camera Caméra en mouvement + Motion Preset Préréglage de mouvement + Motion Tween Interpolation de mouvement + Move Déplacer + Movie Film + Movie (audio and video) Film (audio et vidéo) + Multiband Compressor Compresseur multibande + Multichannel Mono Media Médias mono multicanaux + Multiply Multiplier + Multitap Delay Retard multiprise + Music Musique + Mute input during timeline recording Couper le son de l'entrée pendant l'enregistrement dans la timeline + Name Nom + Narrow Étroit + Neon Color Couleur néon + Neon Wipe Balayage néon + Next to original media files À côté des fichiers médias d'origine + Night Nuit + No Motion Aucun mouvement + No Scroll Aucun défilement + No plugins are installed. Changes will take effect the next time you start FilmCraft. Aucun plug-in n'est installé. Les modifications prendront effet au prochain démarrage de FilmCraft. + Noise Bruit + Noise & Grain Bruit et grain + Noise (Legacy) Bruit (ancien) + Noise Level Niveau de bruit + Noise Reduction/Restoration Réduction du bruit/restauration + Noise Type Type de bruit + Non-Additive Dissolve Fondu enchaîné non additif + Normal Normal + Notch 1 Coupe-bande 1 + Notch 2 Coupe-bande 2 + Notch 3 Coupe-bande 3 + Notch 4 Coupe-bande 4 + Notch 5 Coupe-bande 5 + Notch 6 Coupe-bande 6 + Notch Filter Filtre coupe-bande + Notch Width Largeur du coupe-bande + Number Of Echoes Nombre d'échos + Number of Harmonics Nombre d'harmoniques + Obsolete Obsolètes + Octagon Octogone + Offset Décalage + Offset (Turbulence) Décalage (turbulence) + Offset From Décaler depuis + Offset To Décaler jusqu'à + Omni Omni + On Activé + On Original Image Sur l'image d'origine + On Transparent Sur fond transparent + Open Air Plein air + Open Most Recent Ouvrir le plus récent + Open in new window Ouvrir dans une nouvelle fenêtre + Open in place Ouvrir sur place + Open new tab Ouvrir un nouvel onglet + Opens up the shadows Débouche les tons foncés + Order Ordre + Orientation Orientation + Outer Extérieur + Outgoing Sortant + Output Sortie + Output Mapping Affectation des sorties + Outside Extérieur + Outside Color Couleur extérieure + Overflow Dépassement + Overlay Superposition + Overshoot Dépassement de crête + Page Peel Page décollée + Page Scroll Défilement de page + Page Turn Page tournée + Paint Bucket Pot de peinture + Paint On Black Peindre sur du noir + Paint On Original Image Peindre sur l'image d'origine + Paint On Transparent Peindre sur fond transparent + Paint On White Peindre sur fond blanc + Paint Style Style de peinture + Paint Surface Surface de peinture + Pan (Y Axis) Panoramique (axe Y) + Panel Wipe Balayage par panneaux + Panels Panneaux + Panner - Ambisonics Panoramique - Ambisonics + Parade Parade + Parade (RGB) Parade (RGB) + Parametric Equalizer Égaliseur paramétrique + Passband Ripple Ondulation de la bande passante + Path Tracé + Path Points Points du tracé + Pause Media Encoder queue during playback Suspendre la file d'encodage pendant la lecture + Peak Frequency Fréquence de crête + Peak Gain Gain de crête + Pedestal Piédestal + Pentagon Pentagone + Per line Par ligne + Percent Blur Pourcentage de flou + Perspective Perspective + Phase Difference Différence de phase + Phaser Phaser + Phone Call Appel téléphonique + Phosphor Phosphore + Phosphor Color Couleur du phosphore + Photocopy Photocopie + Photocopy Color Couleur de photocopie + Pillow Coussin + Pin All Tout épingler + Pin Horizontal Épingler horizontalement + Pin Light Lumière ponctuelle + Pin Vertical Épingler verticalement + Pinning Épinglage + Pitch Shifter Modificateur de hauteur + Plate Plaque + Plateau Wipe Balayage en plateaux + Plateaus Plateaux + Plates Plaques + Play after rendering previews Lire après le rendu des aperçus + Play audio while scrubbing in Source and Program Monitors Lire l'audio pendant le défilement dans les moniteurs source et programme + Playback Lecture + Playhead position determines trim monitor loop playback La position de la tête de lecture détermine la lecture en boucle du moniteur de rognage + Plugins Modules externes + Podcast Voice Voix de podcast + Point 1 Point 1 + Point 1 Color Couleur du point 1 + Point 1 Latitude Latitude du point 1 + Point 1 Longitude Longitude du point 1 + Point 2 Point 2 + Point 2 Color Couleur du point 2 + Point 2 Latitude Latitude du point 2 + Point 2 Longitude Longitude du point 2 + Point 3 Point 3 + Point 3 Color Couleur du point 3 + Point 3 Latitude Latitude du point 3 + Point 3 Longitude Longitude du point 3 + Point 4 Point 4 + Point 5 Point 5 + Point 6 Point 6 + Point 7 Point 7 + Point 8 Point 8 + Points Points + Policy Politique + Pop Motion Mouvement surgissant + Portuguese Portugais + Position & Rotation Position et rotation + Position Amount Quantité de position + Position, Scale & Rotation Position, échelle et rotation + Position, Scale, Rotation Position, échelle, rotation + Posterize Postériser + Posterize Time Postériser le temps + Pre-Delay Pré-retard + Prefix Préfixe + Presence Lift Accentuation de la présence + Preserve Luminosity Conserver la luminosité + Preserve Scale Conserver l'échelle + ProcAmp ProcAmp + Projecting cap Extrémité en saillie + Projects Projets + Pull Motion Mouvement de traction + Pulls highlights and whites down Abaisse les tons clairs et les blancs + Push Pousser + Push (Legacy) Pousser (ancien) + Q Q + RAM reserved for other applications RAM réservée aux autres applications + RGB RGB + RGB Split Séparation RGB + RGB-White RGB-blanc + Radial Radial + Radial Blur Flou radial + Radial Ramp Dégradé radial + Radial Wipe Balayage radial + Radial Wipe (Legacy) Balayage radial (ancien) + Radio Announcer Animateur radio + Radio Voice Voix radio + Radius Rayon + Ramp Dégradé + Ramp Shape Forme du dégradé + Random Aléatoire + Random Seed Graine aléatoire + Range Plage + Ray Rayon lumineux + Ray Color Couleur des rayons + Ray Length Longueur des rayons + Rays Rayons + Rays Only Rayons uniquement + Read Lecture + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Flou du rouge + Red Curve Courbe du rouge + Red Offset Décalage du rouge + Red Power Puissance du rouge + Red Slope Pente du rouge + Red-Blue Rouge-bleu + Red-Const Rouge-constante + Red-Green Rouge-vert + Red-Red Rouge-rouge + Reduce Axis Réduire l'axe + Reduce Interlace Flicker Réduire le scintillement d'entrelacement + Reflected Réfléchi + Reflection Angle Angle de réflexion + Reflection Center Centre de réflexion + Refraction Réfraction + Reframe Offset Décalage du cadrage + Reframe Zoom Zoom du cadrage + Refresh growing Files Every Actualiser les fichiers en croissance toutes les + Relaxed Détendu + Release Time Temps de relâchement + Relief Relief + Remaining Restant + Remove Fringe Supprimer la frange + Render Edit in Audition files to: Effectuer le rendu des fichiers modifiés dans Audition vers : + Render audio when rendering video Effectuer le rendu de l'audio lors du rendu de la vidéo + Repair Réparer + Repeat Edge Pixels Répéter les pixels du bord + Replace Color Remplacer la couleur + Replace With Clip From Source Monitor Remplacer par le clip du moniteur source + Replicate Répliquer + Responsive Design - Position Conception adaptative - position + Responsive Design - Time Conception adaptative - temps + Restore open sequences when opening projects Restaurer les séquences ouvertes à l'ouverture des projets + Result Résultat + Retro Rétro + Reveal Nested Sequence Afficher la séquence imbriquée + Reveal Original Image Afficher l'image d'origine + Reveal in Project Afficher dans le projet + Reverb Amount Quantité de réverbération + Reverse Inverser + Right - Left Droite - gauche + Right - Right Droite - droite + Right channel to device output Canal droit vers la sortie du périphérique + Right to left De droite à gauche + Ripple trim adds edits to keep both sides of trim in sync Le rognage avec décalage ajoute des points de montage pour garder les deux côtés synchronisés + Roll (Z Axis) Roulis (axe Z) + Room Size Taille de la pièce + Rotation Amount Quantité de rotation + Roughen Rugosité + Roughen Color Couleur de rugosité + Roughen Edges Bords rugueux + Round cap Extrémité arrondie + Round join Jointure arrondie + Rounded Crop Recadrage arrondi + Rows Lignes + Russian Russe + Rusty Rouillé + Rusty Color Couleur rouille + SFX Effets sonores + Sample Rate Fréquence d'échantillonnage + Saturation at 50 % Saturation à 50 % + Save .cfa and .pek media cache files next to original media files when possible Enregistrer les fichiers de cache média .cfa et .pek à côté des fichiers média d'origine si possible + Scale (Degrees) Échelle (degrés) + Scale Amount Quantité d'échelle + Scale From Mettre à l'échelle depuis + Scale Height Échelle de la hauteur + Scale Match Correspondance d'échelle + Scale To Mettre à l'échelle vers + Scale Width Échelle de la largeur + Scale to frame size Mettre à l'échelle selon la taille de l'image + Scan Lines Lignes de balayage + Scene Scène + Scientific Filter Filtre scientifique + Scratch disk location for Captured Audio Emplacement du disque de travail pour l'audio capturé + Screen Écran + Seconds Secondes + Segments Segments + Semi-tones Demi-tons + Sequence Name Nom de la séquence + Sequence Timecode Timecode de la séquence + Set focus on the Timeline when performing Insert/Overwrite edits Placer le focus sur la timeline lors des montages par insertion/écrasement + Set to frame size Définir sur la taille de l'image + Setting Paramètre + Shadow Angle Angle de l'ombre + Shadow Blue Balance Balance du bleu dans les tons foncés + Shadow Blur Flou de l'ombre + Shadow Color Couleur de l'ombre + Shadow Distance Distance de l'ombre + Shadow Green Balance Balance du vert dans les tons foncés + Shadow Only Ombre seule + Shadow Opacity Opacité de l'ombre + Shadow Red Balance Balance du rouge dans les tons foncés + Shadow Size Taille de l'ombre + Shadow Tint Teinte des tons foncés + Shadows Lightness Luminosité des tons foncés + Shake Tremblement + Shape Dissolve Fondu par formes + Shape Flow Flux de formes + Shape Size Taille de la forme + Shapes Formes + Shard Size Taille des éclats + Sharp Colors Couleurs nettes + Sharpen Amount Quantité de netteté + Shift Center To Déplacer le centre vers + Shift clips that overlap trim point during ripple trimming Décaler les clips qui chevauchent le point de rognage lors d'un rognage avec décalage + Shot Plan + Show "Project:" and "Bin:" in Project panel tab Afficher « Projet : » et « Chutier : » dans l'onglet du panneau Projet + Show Clip Mismatch Warning dialog Afficher l'avertissement de non-concordance de clip + Show Event Indicator Afficher l'indicateur d'événement + Show Focus Area Afficher la zone de mise au point + Show Home Afficher l'accueil + Show Mask Tracker Preview Afficher l'aperçu du suivi de masque + Show Only Noise Afficher uniquement le bruit + Show Open Dialog Afficher la boîte de dialogue Ouvrir + Show Project Load Error Dialog Afficher la boîte de dialogue d'erreur de chargement du projet + Show Rich Tool Tips Afficher les info-bulles enrichies + Show Tool Tips Afficher les info-bulles + Show Workspace Reset Warning dialog on double click Afficher l'avertissement de réinitialisation de l'espace de travail lors d'un double-clic + Show system compatibility issues at startup Afficher les problèmes de compatibilité système au démarrage + Shrink Rétrécir + Shutter Angle Angle d'obturation + Similarity Similarité + Simple Notch Filter Filtre coupe-bande simple + Simple Parametric EQ Égaliseur paramétrique simple + Simple Text Texte simple + Single-band Compressor Compresseur monobande + Sinusoidal Sinusoïdal + Skew Biais + Skew Axis Axe du biais + Slats Lamelles + Slice Tranche + Slices Tranches + Slide Coulissement + Slide (Legacy) Coulissement (ancien) + Slope Pente + Slow Lent + Slower Motion Ralenti accentué + Small Room Petite pièce + Smart Limit Limitation intelligente + Smart Tools Outils intelligents + Smart quotes Guillemets typographiques + Smooth Lisse + Smooth Motion Mouvement fluide + Smooth Scroll Défilement fluide + Smoothness Lissage + Snap playhead in Timeline when Snap is enabled Aligner la tête de lecture dans la timeline lorsque le magnétisme est activé + Soft Doux + Soft Clamp Écrêtage doux + Soft Edges (Best Quality) Bords adoucis (qualité optimale) + Soft Light Lumière tamisée + Soft Wipe Balayage doux + Soften Harshness Atténuer la dureté + Softness Adoucissement + Solarize Solariser + Solid Colors Couleurs unies + Source Point Point source + Source Text Texte source + Spacer Espaceur + Spanish Espagnol + Speaker Angle Angle du haut-parleur + Speaker Labeling Identification des intervenants + Special Spécial + Special Effects Effets spéciaux + Speech model Modèle de parole + Speed Vitesse + Spherize Sphériser + Spiky Hérissé + Spill Débordement + Spill Suppression Suppression du débordement + Spin Tournoiement + Spin Direction Sens du tournoiement + Spin Motion Mouvement de tournoiement + Split Séparation + Split (Legacy) Séparation (ancien) + Spotlight Projecteur + Spread Étalement + Spring Motion Mouvement élastique + Square Carré + Square (Width Only) Carré (largeur uniquement) + Square 1:1 Carré 1:1 + Stabilization Stabilisation + Stabilize Only Stabiliser uniquement + Stabilize, Crop Stabiliser, recadrer + Stabilize, Crop, Auto-scale Stabiliser, recadrer, mise à l'échelle auto + Stabilize, Synthesize Edges Stabiliser, synthétiser les bords + Stages Étapes + Star Étoile + Star Wipe Balayage en étoile + Start Angle Angle de départ + Start Color Couleur de départ + Start Point Point de départ + Start at 0 Commencer à 0 + Start at 1 Commencer à 1 + Start of Ramp Début du dégradé + Start with an Empty Project Commencer avec un projet vide + Starting Intensity Intensité de départ + Static Plates Plaques statiques + Step forward/back many Avancer/reculer de plusieurs images + Stereo Expand Élargissement stéréo + Stereo Expander Élargisseur stéréo + Stereo Imagery Image stéréo + Stereo Media Médias stéréo + Stereo Phasing Phase stéréo + Stereoscopic - Over/Under Stéréoscopique - dessus/dessous + Stereoscopic - Side by Side Stéréoscopique - côte à côte + Still Image fixe + Still Image Default Duration Durée par défaut des images fixes + Stone Church Église en pierre + Stopband Attenuation Atténuation de la bande coupée + Straight Color Couleur directe + Strength Intensité + Stretch Étirement + Stretch Gradient to Fit Étirer le dégradé pour l'adapter + Stretch Map to Fit Étirer la carte pour l'adapter + Stretch Width or Height Étirer la largeur ou la hauteur + Stretch Wipe Balayage étiré + Stretch to Fit Frame Étirer à la taille de l'image + Stripe Rayure + Stripes Rayures + Strobe Color Couleur du stroboscope + Strobe Duration (secs) Durée du stroboscope (s) + Strobe Light Lumière stroboscopique + Strobe Period (secs) Période du stroboscope (s) + Stroke 2 Color Couleur du contour 2 + Stroke 2 Type Type du contour 2 + Stroke 2 Width Largeur du contour 2 + Stroke Angle Angle du trait + Stroke Color Couleur du contour + Stroke Density Densité des traits + Stroke Length Longueur du trait + Stroke Length (secs) Longueur du trait (s) + Stroke Only Contour uniquement + Stroke Randomness Caractère aléatoire des traits + Stroke Type Type de contour + Stroke Width Largeur du contour + Stroke line cap Extrémité du contour + Stroke line join Jointure du contour + Studio Reverb Réverbération de studio + Stylize Styliser + Subclip End Fin du sous-clip + Subclip Start Début du sous-clip + Subspace Warp Déformation de sous-espace + Subtitle Sous-titre + Subtract Soustraire + Sum multichannel outputs to mono in Source Monitor Mixer les sorties multicanaux en mono dans le moniteur source + Super Narrow Extra-étroit + Surface Gloss Brillance de la surface + Surface Material Matériau de la surface + Surround Reverb Réverbération surround + Swap Permuter + Swap Channels Permuter les canaux + Swivel Pivotement + Synthetic Synthétique + TV Power Allumage TV + Tape Bande + Tape Name Nom de la bande + Tape/Tube Bande/lampe + Target Aspect Format cible + Target Color Couleur cible + Target Field of View Champ de vision cible + Target Loudness Sonie cible + Teal & Orange Bleu canard et orange + Telephone Téléphone + Teletext Télétexte + Template Properties Propriétés du modèle + Text Animator Animateur de texte + Text Color Couleur du texte + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. Le cache média contient les aperçus de rendu des projets non enregistrés et d'autres fichiers que FilmCraft peut recréer. Vous pouvez toujours les supprimer sans risque ; FilmCraft les reconstruit au besoin. + Thickness Épaisseur + Tile Gradient Dégradé en mosaïque + Tilt Inclinaison + Tilt (X Axis) Inclinaison (axe X) + Time Temps + Time and Pitch Temps et hauteur + Timecode Conversion Conversion du timecode + Timeline Mouse Scrolling Défilement à la souris dans la timeline + Timeline Playback Auto-Scrolling Défilement automatique de la timeline pendant la lecture + Tint Color Couleur de teinte + To À + Tolerance Tolérance + Tone Ton + Top Center En haut au centre + Top Left En haut à gauche + Top Right En haut à droite + Touch Toucher + Track Matte Key Incrustation par cache de piste + Transcription Transcription + Transcription preferences Préférences de transcription + Transform Transformation + Transformers Transformateurs + Transience Transitoires + Transition Completion Achèvement de la transition + Transition Softness Adoucissement de la transition + Transparency Transparence + Trash Corbeille + Travel Motion Mouvement de déplacement + Treble Aigus + Triangle Triangle + Trim Rognage + Tube Lampe + Tube-modeled Compressor Compresseur à modélisation de lampe + Tubular Tubulaire + Turbulent Turbulent + Turbulent Displace Déplacement turbulent + Turbulent Sharp Turbulent net + Turbulent Smooth Turbulent lisse + Turbulent Smoother Turbulent plus lisse + Tweed Tweed + Twirl Tourbillon + Twirl (Legacy) Tourbillon (ancien) + Twirl Center Centre du tourbillon + Twirl Radius Rayon du tourbillon + Twist Torsion + Twist Smoother Torsion plus lisse + Typewriter Machine à écrire + Ultra Key Ultra Key + Uniform Scale Échelle uniforme + Uniform Spacing Espacement uniforme + Unsharp Mask Masque flou + Update it at least every Mettre à jour au moins toutes les + Update timeline dynamically during ripple edits. Mettre à jour la timeline dynamiquement pendant les montages avec décalage. + Upper Frequency Fréquence supérieure + Upper Left En haut à gauche + Upper Right En haut à droite + Use Color Noise Utiliser un bruit coloré + Use End Color Utiliser la couleur de fin + Use File Utiliser un fichier + Use Glow Color Utiliser la couleur de lueur + Use Media Source Utiliser la source du média + Use Tint Color Utiliser la couleur de teinte + Utility Utilitaire + VHS Damage Dégradation VHS + VR Blur Flou VR + VR Chroma Leaks Fuites chromatiques VR + VR Chromatic Aberrations Aberrations chromatiques VR + VR Color Gradients Dégradés de couleur VR + VR De-Noise Réduction du bruit VR + VR Digital Glitch Glitch numérique VR + VR Fractal Noise Bruit fractal VR + VR Glow Lueur VR + VR Gradient Wipe Balayage en dégradé VR + VR Iris Wipe Balayage en iris VR + VR Light Leaks Fuites de lumière VR + VR Light Rays Rayons lumineux VR + VR Mobius Zoom Zoom de Möbius VR + VR Plane to Sphere Plan vers sphère VR + VR Projection Projection VR + VR Random Blocks Blocs aléatoires VR + VR Rotate Sphere Rotation de sphère VR + VR Sharpen Netteté VR + VR Spherical Blur Flou sphérique VR + Validate Content Credentials on import Vérifier les identifiants de contenu à l'importation + Vectorscope (YUV) Vectorscope (YUV) + Vectorscope + Waveform YC Vectorscope + forme d'onde YC + Vectorscope HLS Vectorscope HLS + Vectorscope YUV Vectorscope YUV + Venetian Blinds Store vénitien + Vertical 4:5 Vertical 4:5 + Vertical 9:16 Vertical 9:16 + Vertical Alignment Alignement vertical + Vertical Blocks Blocs verticaux + Vertical Decentering Décentrage vertical + Vertical Displacement Déplacement vertical + Vertical Flip Retournement vertical + Vertical Margin Marge verticale + Very Narrow Très étroit + Video 1 Vidéo 1 + Video 2 Vidéo 2 + Video 3 Vidéo 3 + Video 4 Vidéo 4 + Video 5 Vidéo 5 + Video 6 Vidéo 6 + Video 7 Vidéo 7 + Video 8 Vidéo 8 + Video Codec Codec vidéo + Video Duration Durée de la vidéo + Video Effects Effets vidéo + Video In Point Point d'entrée vidéo + Video Info Informations vidéo + Video Limiter Limiteur vidéo + Video Out Point Point de sortie vidéo + Video Transition Default Duration Durée par défaut des transitions vidéo + Video Transitions Transitions vidéo + Video Usage Utilisation vidéo + Vignette Vignettage + Vivid Light Lumière vive + Vocal Booth Cabine vocale + Vocal Enhancer Optimiseur vocal + Voice Voix + Voice in a Hall Voix dans une salle + Volumetric Rays Rayons volumétriques + Warm Film Pellicule chaude + Warm Room Pièce chaleureuse + Warm Voice Voix chaleureuse + Warm print stock with gentle roll-off Pellicule de tirage chaude avec une atténuation douce + Warm, soft late-afternoon light Lumière chaude et douce de fin d'après-midi + Warning Avertissement + Warp Stabilizer Stabilisateur de déformation + Wave Vague + Wave Height Hauteur de la vague + Wave Speed Vitesse de la vague + Wave Warp Déformation ondulée + Wave Width Largeur de la vague + Waveform Forme d'onde + Waveform (Luma) Forme d'onde (luma) + Waveform (RGB) Forme d'onde (RGB) + Wavelength Longueur d'onde + Wet Signal traité + Wet Out Sortie traitée + When Opening a Project À l'ouverture d'un projet + Whip Fouetté + Whip (Legacy) Fouetté (ancien) + Whisper base (balanced) Whisper base (équilibré) + Whisper small (most accurate) Whisper small (le plus précis) + Whisper tiny (fastest) Whisper tiny (le plus rapide) + White Blanc + White Input Level Niveau d'entrée du blanc + White Is High Le blanc est haut + Wide Outdoor Grand extérieur + Widener Élargisseur + Width Largeur + Wiggle Oscillation + Wipe Balayage + Wipe Angle Angle du balayage + Wonder Glow Lueur merveilleuse + Wrap Back Enroulement arrière + Write Écriture + Write XMP ID to files on import Écrire l'ID XMP dans les fichiers à l'importation + Write clip markers to XMP Écrire les marqueurs de clip dans XMP + Write-on Écriture progressive + X Rotation Rotation X + Y Rotation Rotation Y + YC YC + YC no Chroma YC sans chrominance + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Tons clairs jaune-vert et tons foncés relevés vers le bleu par des courbes de canaux croisées + Z Position Position Z + Z Rotation Rotation Z + Zoom Zoom + Zoom Amount Quantité de zoom + Zoom Blur Flou de zoom + Zoom Strength Intensité du zoom + days jours + frames images + milliseconds millisecondes + minute(s) minute(s) + second(s) seconde(s) + seconds secondes + AAF… AAF… + Audio… Audio… + Avid Log Exchange… Avid Log Exchange… + Color… Couleur… + EDL… EDL… + Edit Workspaces… Modifier les espaces de travail… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… Général… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Enregistrer les modifications de cet espace de travail + Save as New Workspace… Enregistrer comme nouvel espace de travail… + Create Search Bin Créer un chutier de recherche + Edit Offline File Modifier le fichier hors ligne + Source Settings Paramètres de la source + Update Metadata Mettre à jour les métadonnées + Automate To Sequence Automatiser vers la séquence + Scene Edit Detection Détection des changements de scène + Analyze Analyser + Normalize Mix Track Normaliser la piste de mixage + Simplify Sequence Simplifier la séquence + Simplify Simplifier + Transcribe Sequence Transcrire la séquence + Save as Template Enregistrer comme modèle + Flash Cue Marker Marqueur de repère Flash + System Compatibility Report Rapport de compatibilité système + no GPU adapter: the CPU compositor is used aucun adaptateur GPU : le compositeur CPU est utilisé + Avid Log Exchange Avid Log Exchange + FilmCraft Project Projet FilmCraft + {name} Selection.fcproj {name} Sélection.fcproj + {name} (Simplified) {name} (simplifiée) + Find in: Rechercher dans : + Find: Rechercher : + Search: Rechercher : + Clip Names and Markers Noms de clips et marqueurs + Match: Correspondance : + All Tous + Any N'importe lequel + Case Sensitive Respecter la casse + Scratch Disks Disques de travail + Ingest Settings Paramètres d'ingestion + Captured and Generated: Capturés et générés : + Video Previews: Aperçus vidéo : + Audio Previews: Aperçus audio : + Project Auto Save: Enregistrement automatique du projet : + Same as Project Identique au projet + Path: {path} Chemin : {path} + Transcode Transcoder + Copy and Create Proxies Copier et créer des proxys + Action: Action : + Destination: Destination : + Next to the media À côté des médias + Video Rendering and Playback Rendu et lecture vidéo + Renderer: Moteur de rendu : + Display Format: Format d'affichage : + Milliseconds Millisecondes + Capture Capture + Capture Format: Format de capture : + Action and Title Safe Areas Zones de sécurité action et titre + Title Safe Area Zone de sécurité titre + Action Safe Area Zone de sécurité action + Total Duration Durée totale + Average Data Rate Débit moyen + Colour Couleur + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} canal(aux) + Media Name: Nom du média : + Scene: Scène : + Shot/Take: Plan/prise : + Log Note: Note de dérushage : + Codec: {codec} Codec : {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? Écrire les métadonnées des clips sélectionnés ({n}) dans des fichiers XMP à côté de leurs médias ? + Existing XMP files written by other applications are left unchanged. Les fichiers XMP existants écrits par d'autres applications ne sont pas modifiés. + To {name} Vers {name} + Ordering: Ordre : + Sort Order Ordre de tri + Selection Order Ordre de sélection + Placement: Placement : + Sequentially Séquentiellement + At Unnumbered Markers Aux marqueurs non numérotés + Method: Méthode : + Insert Edit Montage par insertion + Overwrite Edit Montage par écrasement + Clip Overlap: Chevauchement des clips : + Frames per Still: Images par image fixe : + Transitions Transitions + Apply Default Audio Transition Appliquer la transition audio par défaut + Apply Default Video Transition Appliquer la transition vidéo par défaut + Ignore Options Options d'exclusion + Ignore Audio Ignorer l'audio + Ignore Video Ignorer la vidéo + Apply a cut at each detected cut point Appliquer une coupe à chaque point de coupe détecté + Create a subclip for each detected cut point Créer un sous-clip pour chaque point de coupe détecté + Generate clip markers at each detected cut point Générer des marqueurs de clip à chaque point de coupe détecté + Sensitivity: Sensibilité : + Normalize Mix Track to: Normaliser la piste de mixage à : + Disabled clips Clips désactivés + Empty tracks Pistes vides + Gaps Espaces vides + Video effects Effets vidéo + Audio effects Effets audio + Text (graphics and captions) Texte (graphiques et sous-titres) + Clips Clips + Move clips to the lowest track possible Déplacer les clips vers la piste la plus basse possible + Keep: Conserver : + Video and Audio Vidéo et audio + Video Only Vidéo uniquement + Audio Only Audio uniquement + Language: Langue : + Audio on track {t} Audio de la piste {t} + Audio analysis: Analyse audio : + Recognize when different speakers are talking Reconnaître les changements d'intervenant + Template Name: Nom du modèle : + Comments: Commentaires : + (unavailable) (indisponible) + Decoders Décodeurs + Containers Conteneurs + Export formats Formats d'exportation + Match {n} of {total} Résultat {n} sur {total} + Saved template {path} Modèle enregistré : {path} + Scene Edit Detection: analysing in the background (see Progress) Détection des changements de scène : analyse en arrière-plan (voir Progression) + Delete Search Bin Supprimer le chutier de recherche + (removed from {list}) (retiré de {list}) + {key} can't be used as a shortcut {key} ne peut pas être utilisé comme raccourci + Keyboard Shortcuts Raccourcis clavier + {active} (modified) {active} (modifié) + Loaded “{name}” « {name} » chargé + My Shortcuts Mes raccourcis + Deleted preset “{active}” Préréglage « {active} » supprimé + Export… Exporter… + Exported to {path} Exporté vers {path} + Export: no file chosen (agents: shortcuts.export {path}) Exporter : aucun fichier choisi (agents : shortcuts.export {path}) + Imported “{name}” « {name} » importé + Import: no file chosen (agents: shortcuts.import {path}) Importer : aucun fichier choisi (agents : shortcuts.import {path}) + Keyboard: US (macOS) Clavier : américain (macOS) + Keyboard: US Clavier : américain + Save preset as: Enregistrer le préréglage sous : + Commands: Commandes : + {panel} Panel Panneau {panel} + Application shortcuts work whatever panel has focus Les raccourcis d'application fonctionnent quel que soit le panneau actif + Panel shortcuts override application shortcuts while that panel has focus Les raccourcis de panneau remplacent ceux de l'application tant que ce panneau est actif + Modifier held / selected Modificateur maintenu / sélectionné + Search commands or keys Rechercher des commandes ou des touches + Selected: {command} Sélection : {command} + Command Commande + Shortcut Raccourci + Type a shortcut… (Esc cancels) Saisissez un raccourci… (Échap pour annuler) + Add another shortcut Ajouter un autre raccourci + No matching commands Aucune commande correspondante + Key: Touche : + Modifiers Modificateurs + Nothing is assigned to this key Rien n'est attribué à cette touche + Click a key on the keyboard to see its shortcuts Cliquez sur une touche du clavier pour voir ses raccourcis + Assign {key} to {command} Attribuer {key} à {command} + Conflict: {key} is assigned to {commands} ({ctx}) Conflit : {key} est attribué à {commands} ({ctx}) + and et + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Cliquez sur la cellule Raccourci d'une commande et appuyez sur des touches pour l'attribuer ; + ajoute un autre raccourci. + Cleared {n} shortcut(s) of {command} {n} raccourci(s) de {command} effacé(s) + No export preset named `{name}` Aucun préréglage d'exportation nommé `{name}` + Open a sequence to export Ouvrez une séquence à exporter + Destinations Destinations + Media File Fichier média + Queue ({n}) File d'attente ({n}) + Send exports here with Send to Queue, then start the queue. Envoyez des exportations ici avec Envoyer vers la file d'attente, puis lancez la file d'attente. + Ready Prêt + Encoding {n}% Encodage {n} % + Start Queue Lancer la file d'attente + Stop Arrêter + Clear Finished Effacer les éléments terminés + Not supported by FilmCraft's encoders yet Pas encore pris en charge par les encodeurs de FilmCraft + Sequence name Nom de la séquence + Folder Dossier + Preset Préréglage + More presets… Plus de préréglages… + Multiplexer Multiplexeur + The sequence has no caption track. La séquence ne comporte aucune piste de sous-titres. + Export Options Options d'exportation + Burn Captions Into Video Incruster les sous-titres dans la vidéo + Create Sidecar File Créer un fichier annexe + File Format Format de fichier + SubRip Subtitle (.srt) Sous-titres SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Titre + Creator Créateur + Copyright Copyright + Image Image + Frame Size Taille d'image + Match Source Identique à la source + Field Order Ordre des trames + Aspect Format + Profile Profil + Bitrate Encoding Encodage du débit + Target Bitrate Débit cible + Adaptive ({bpp} bits per pixel) Adaptatif ({bpp} bits par pixel) + Maximum Bitrate Débit maximal + Quality (CRF) Qualité (CRF) + Key Frame Distance Intervalle entre images clés + Quality Qualité + Render at Maximum Depth Rendu à la profondeur maximale + Use Maximum Render Quality Utiliser la qualité de rendu maximale + Export Audio Exporter l'audio + Audio Format Format audio + Uncompressed (PCM) Non compressé (PCM) + Match Source ({seq_sr} Hz) Identique à la source ({seq_sr} Hz) + Channels Canaux + Audio Bitrate Débit audio + Sample Size Taille d'échantillon + 16 bit 16 bits + 24 bit 24 bits + Size / Opacity Taille / opacité + Image Overlay Superposition d'image + PNG or JPEG file Fichier PNG ou JPEG + Name Overlay Superposition du nom + Timecode Overlay Superposition du timecode + Luma Min / Max Luminance min. / max. + Loudness Normalization Normalisation de la sonie + True Peak Limit Limite de crête réelle + Audio only Audio uniquement + Scaling Mise à l'échelle + Scale To Fit Mettre à l'échelle pour adapter + Scale To Fill Mettre à l'échelle pour remplir + Stretch To Fill Étirer pour remplir + Summary Résumé + Sequence, {w}x{h}, {fps} fps, {rate} Hz Séquence, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Taille de fichier estimée : {size} + Send to Queue Envoyer vers la file d'attente + Exporting {path} Exportation de {path} + Added to the export queue Ajouté à la file d'attente d'exportation + Preset Manager Gestionnaire de préréglages + Search presets Rechercher des préréglages + Favorites only Favoris uniquement + Show in the Preset menu Afficher dans le menu Préréglage + User Utilisateur + Save current settings as: Enregistrer les paramètres actuels sous : + Export presets Préréglages d'exportation + Import: no file chosen (agents: export.presets.import {path}) Importer : aucun fichier choisi (agents : export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Exporter : aucun fichier choisi (agents : export.presets.export {path, names}) + Quick Export: open a sequence first Exportation rapide : ouvrez d'abord une séquence + File Name & Location Nom et emplacement du fichier + Quick Export: {path} Exportation rapide : {path} + Graphics problem: switched to software compositing Problème graphique : passage à la composition logicielle + GPU acceleration is unavailable; preview renders on the CPU L'accélération GPU n'est pas disponible ; l'aperçu est rendu par le CPU + Exporting Exportation + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par un rectangle. Utilisez Maj, Option et Cmd pour d'autres options. + Click to select all clips to the right in all tracks. Shift-click for a single track. Cliquez pour sélectionner tous les clips à droite sur toutes les pistes. Maj+clic pour une seule piste. + Click to select all clips to the left in all tracks. Shift-click for a single track. Cliquez pour sélectionner tous les clips à gauche sur toutes les pistes. Maj+clic pour une seule piste. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Faites glisser un point de montage pour un montage avec décalage ; les clips suivants se déplacent pour ne laisser aucun espace vide. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Faites glisser un point de montage pour un montage par roulement : la sortie d'un clip et l'entrée du suivant se déplacent ensemble. + Drag an edge to change the clip's speed so it fills the new duration. Faites glisser un bord pour modifier la vitesse du clip afin qu'il remplisse la nouvelle durée. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Faites glisser le bord d'un clip musical pour le remixer à la nouvelle durée sur des temps musicalement cohérents. + Click to split a clip. Shift-click to split all tracks. Cliquez pour scinder un clip. Maj+clic pour scinder toutes les pistes. + Drag a clip to slip its source in/out without moving it. Faites glisser un clip pour décaler ses points d'entrée/sortie source sans le déplacer. + Drag a clip to slide it between its neighbours. Faites glisser un clip pour le faire coulisser entre ses voisins. + Drag to scroll the timeline. Faites glisser pour faire défiler la timeline. + Click to zoom in; Opt-click to zoom out. Cliquez pour un zoom avant ; Option+clic pour un zoom arrière. + FilmCraft hit an error FilmCraft a rencontré une erreur + Details: {path} Détails : {path} + Opened {url} {url} ouvert + (empty) (vide) + Step Back 1 Frame Reculer d'une image + Step Forward 1 Frame Avancer d'une image + Match Playhead Faire correspondre la tête de lecture + Gang to Program Coupler au moniteur programme + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits + AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamorphique 2:1 (2.0) + Animated GIF GIF animé + Animated GIF 640×360 GIF animé 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 avec alpha + Apple ProRes 4444 XQ Apple ProRes 4444 XQ + Application Application + Apply Guide Template Appliquer un modèle de repères + Auto Save Now Enregistrer automatiquement maintenant + Auto-Match Loudness Harmoniser automatiquement la sonie + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) OP-Atom de type Avid : fichier image DNxHR HQ plus un fichier PCM 24 bits 48 kHz par canal (_A1, _A2…) + BMP BMP + BMP Sequence Séquence BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Commencer l'édition du texte d'un calque graphique + Begins With Commence par + Blur In Entrée floue + Blur Out Sortie floue + Brightens inside a feathered circular mask Éclaircit l'intérieur d'un masque circulaire à contour progressif + Broadcast Diffusion + CBR CBR + Cancel Dynamic Trim Annuler le rognage dynamique + Cancel Queued Export Annuler l'exportation en file d'attente + Center Left Centre gauche + Center Right Centre droit + Center align text Centrer le texte + Change Mask Modifier le masque + Clear All Events Effacer tous les événements + Clear Edit Point Selection Effacer la sélection de points de montage + Clear Finished Exports Effacer les exportations terminées + Clear Poster Frame Effacer l'image d'affiche + Close Bin Fermer le chutier + Close Sequence Fermer la séquence + Contains Contient + Cut to Camera 1 Couper sur la caméra 1 + Cut to Camera 2 Couper sur la caméra 2 + Cut to Camera 3 Couper sur la caméra 3 + Cut to Camera 4 Couper sur la caméra 4 + Cut to Camera 5 Couper sur la caméra 5 + Cut to Camera 6 Couper sur la caméra 6 + Cut to Camera 7 Couper sur la caméra 7 + Cut to Camera 8 Couper sur la caméra 8 + Cut to Camera 9 Couper sur la caméra 9 + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask Assombrit l'extérieur d'un masque ovale à contour progressif + Decrease Audio Tracks Height Réduire la hauteur des pistes audio + Decrease Clip Volume Baisser le volume du clip + Decrease Clip Volume Many Baisser fortement le volume du clip + Decrease Font Size by Five Units Réduire la taille de police de cinq unités + Decrease Font Size by One Unit Réduire la taille de police d'une unité + Decrease Leading by Five Units Réduire l'interlignage de cinq unités + Decrease Leading by One Unit Réduire l'interlignage d'une unité + Decrease Video Tracks Height Réduire la hauteur des pistes vidéo + Delete Captions Supprimer les sous-titres + Delete Graphic Layer Supprimer le calque graphique + Delete Guide Template Supprimer le modèle de repères + Delete Media Cache Files Supprimer les fichiers du cache des médias + Discard Unsaved Changes Abandonner les modifications non enregistrées + Does Not Contain Ne contient pas + Download Speech Model Télécharger le modèle vocal + Dynamic Trim Stop Arrêter le rognage dynamique + Edit Caption Text Modifier le texte du sous-titre + Ellipse Tool Outil Ellipse + Ends With Se termine par + Entire Source Source entière + EssentialSound Son essentiel + Export Frame Exporter l'image + Extend Next Edit To Playhead Étendre le point de montage suivant jusqu'à la tête de lecture + Extend Previous Edit To Playhead Étendre le point de montage précédent jusqu'à la tête de lecture + Extend Selected Edit to Playhead Étendre le point de montage sélectionné jusqu'à la tête de lecture + Extend Selection Down Étendre la sélection vers le bas + Extend Selection Left Étendre la sélection vers la gauche + Extend Selection Right Étendre la sélection vers la droite + Extend Selection Up Étendre la sélection vers le haut + Fade In Fondu d'entrée + Gaussian Blur from 0 to 60 over the last second Flou gaussien de 0 à 60 sur la dernière seconde + Gaussian Blur from 60 to 0 over the first second Flou gaussien de 60 à 0 sur la première seconde + Generate Ducking Keyframes Générer des images clés d'atténuation + Go to Next Edit Point Aller au point de montage suivant + Go to Next Edit Point on Any Track Aller au point de montage suivant sur n'importe quelle piste + Go to Previous Edit Point Aller au point de montage précédent + Go to Previous Edit Point on Any Track Aller au point de montage précédent sur n'importe quelle piste + Go to Selected Clip End Aller à la fin du clip sélectionné + Go to Selected Clip Start Aller au début du clip sélectionné + Go to Sequence End Aller à la fin de la séquence + Go to Sequence Start Aller au début de la séquence + H.264 H.264 + AV1 AV1 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 pour sites de partage vidéo, VBR 1 passe, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 passe, 20 Mbps cible, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 passes, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 pour sites de partage vidéo, VBR 1 passe, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 passe, 60 Mbps cible, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 passes, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 portrait pour vidéos courtes sur les réseaux sociaux, mis à l'échelle pour remplir, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 à la taille et à la fréquence d'images de la séquence ; 0,05 bit par pixel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 à la taille et à la fréquence d'images de la séquence ; 0,1 bit par pixel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 à la taille et à la fréquence d'images de la séquence ; le débit s'adapte à la taille d'image (0,2 bit par pixel) + HD Anamorphic 1080 (1.333) HD anamorphique 1080 (1.333) + Hand Tool Outil Main + High Quality 1080p HD Haute qualité 1080p HD + High Quality 2160p 4K Haute qualité 2160p 4K + Image Sequence Séquence d'images + Increase Audio Tracks Height Augmenter la hauteur des pistes audio + Increase Clip Volume Augmenter le volume du clip + Increase Clip Volume Many Augmenter fortement le volume du clip + Increase Font Size by Five Units Augmenter la taille de police de cinq unités + Increase Font Size by One Unit Augmenter la taille de police d'une unité + Increase Leading by Five Units Augmenter l'interlignage de cinq unités + Increase Leading by One Unit Augmenter l'interlignage d'une unité + Increase Video Tracks Height Augmenter la hauteur des pistes vidéo + Large blocks resolving to the picture over the first second Gros blocs se résolvant en image sur la première seconde + Left align text Aligner le texte à gauche + Looping GIF, 640×360 at 15 fps GIF en boucle, 640×360 à 15 fps + Lower First Trame inférieure en premier + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a, DNxHR HQ (8 bits 4:2:2), PCM 24 bits 48 kHz, timecode de début + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High GOP long (0,3 bit par pixel), PCM 24 bits 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM 24 bits 48 kHz, timecode de début + Main Main + Mark In (Hover Scrub) Marquer l'entrée (défilement au survol) + Mark Out (Hover Scrub) Marquer la sortie (défilement au survol) + Masks Masques + Match Source – Adaptive High Bitrate Identique à la source – débit adaptatif élevé + Match Source – Adaptive Low Bitrate Identique à la source – débit adaptatif faible + Match Source – Adaptive Medium Bitrate Identique à la source – débit adaptatif moyen + Matches Correspond à + Maximize or Restore Active Frame Agrandir ou restaurer le cadre actif + Maximize or Restore Frame Under Cursor Agrandir ou restaurer le cadre sous le pointeur + Media Browser Settings Paramètres de l'Explorateur de médias + MediaBrowser Explorateur de médias + MediaCache Cache des médias + Merge Captions Fusionner les sous-titres + Mixer Mixeur + Mosaic In Entrée en mosaïque + Move All Audio Targets Down Descendre toutes les cibles audio + Move All Audio Targets Up Monter toutes les cibles audio + Move All Video Targets Down Descendre toutes les cibles vidéo + Move All Video Targets Up Monter toutes les cibles vidéo + Move Selection Down Déplacer la sélection vers le bas + Move Selection End Déplacer la sélection à la fin + Move Selection Home Déplacer la sélection au début + Move Selection Left Déplacer la sélection vers la gauche + Move Selection Page Down Déplacer la sélection d'une page vers le bas + Move Selection Page Up Déplacer la sélection d'une page vers le haut + Move Selection Right Déplacer la sélection vers la droite + Move Selection Up Déplacer la sélection vers le haut + Multi-Camera Record On/Off Toggle Activer/désactiver l'enregistrement multicaméra + Multicam Multicaméra + Navigate to End of Segment Aller à la fin du segment + Navigate to Next Line Aller à la ligne suivante + Navigate to Next Word Aller au mot suivant + Navigate to Previous Line Aller à la ligne précédente + Navigate to Previous Word Aller au mot précédent + Navigate to Start of Segment Aller au début du segment + Navigation Navigation + Next Multi-Camera Page Page multicaméra suivante + Nudge Clip Selection Down Déplacer la sélection de clips vers le bas + Nudge Clip Selection Left Five Frames Déplacer la sélection de clips de cinq images vers la gauche + Nudge Clip Selection Left One Frame Déplacer la sélection de clips d'une image vers la gauche + Nudge Clip Selection Right Five Frames Déplacer la sélection de clips de cinq images vers la droite + Nudge Clip Selection Right One Frame Déplacer la sélection de clips d'une image vers la droite + Nudge Clip Selection Up Déplacer la sélection de clips vers le haut + Nudge Selected Object down by five Déplacer l'objet sélectionné de cinq unités vers le bas + Nudge Selected Object down by one Déplacer l'objet sélectionné d'une unité vers le bas + Nudge Selected Object to left by five Déplacer l'objet sélectionné de cinq unités vers la gauche + Nudge Selected Object to left by one Déplacer l'objet sélectionné d'une unité vers la gauche + Nudge Selected Object to right by five Déplacer l'objet sélectionné de cinq unités vers la droite + Nudge Selected Object to right by one Déplacer l'objet sélectionné d'une unité vers la droite + Nudge Selected Object up by five Déplacer l'objet sélectionné de cinq unités vers le haut + Nudge Selected Object up by one Déplacer l'objet sélectionné d'une unité vers le haut + Nudge Volume +1dB Ajuster le volume de +1 dB + Nudge Volume +3dB Ajuster le volume de +3 dB + Nudge Volume -1dB Ajuster le volume de -1 dB + Nudge Volume -3dB Ajuster le volume de -3 dB + Numbered BMP stills (24-bit) Images fixes BMP numérotées (24 bits) + Numbered PNG stills at the sequence's frame size Images fixes PNG numérotées à la taille d'image de la séquence + Numbered TIFF stills (RGB, 8-bit) Images fixes TIFF numérotées (RGB, 8 bits) + Opacity 0 → 100 % over the first second Opacité 0 → 100 % sur la première seconde + Opacity 100 → 0 % over the last second Opacité 100 → 0 % sur la dernière seconde + Open Bin Ouvrir le chutier + Open Search Ouvrir la recherche + PNG PNG + PNG Sequence Séquence PNG + Panel Panneau + Pen Tool Outil Plume + PiP 35% Upper Right PiP 35 % en haut à droite + Picture-in-picture: 35 % scale in the upper right Image dans l'image : échelle de 35 % en haut à droite + Play Around Lire autour + Play In to Out with Preroll/Postroll Lire de l'entrée à la sortie avec pré-roll/post-roll + Play from Playhead to Out Point Lire de la tête de lecture au point de sortie + Play/Stop Lire/arrêter + Previous Multi-Camera Page Page multicaméra précédente + Progressive Progressif + ProjectPanel Panneau Projet + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-10 (10 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8 bits 4:2:2), PCM 24 bits 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM 24 bits 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM 24 bits 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM 24 bits 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM 24 bits 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) avec couche alpha, PCM 24 bits 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM 24 bits 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM 24 bits 48 kHz + Quit FilmCraft Quitter FilmCraft + Rate Stretch Tool Outil Étirement de la vitesse + Razor Tool Outil Cutter + Rectangle Tool Outil Rectangle + Remix Tool Outil Remixage + Right align text Aligner le texte à droite + Ripple Edit Tool Outil Montage avec décalage + Ripple Trim Next Edit to Playhead Rogner avec décalage le point de montage suivant jusqu'à la tête de lecture + Ripple Trim Previous Edit to Playhead Rogner avec décalage le point de montage précédent jusqu'à la tête de lecture + Rolling Edit Tool Outil Montage par roulement + Select Camera 1 Sélectionner la caméra 1 + Select Camera 2 Sélectionner la caméra 2 + Select Camera 3 Sélectionner la caméra 3 + Select Camera 4 Sélectionner la caméra 4 + Select Camera 5 Sélectionner la caméra 5 + Select Camera 6 Sélectionner la caméra 6 + Select Camera 7 Sélectionner la caméra 7 + Select Camera 8 Sélectionner la caméra 8 + Select Camera 9 Sélectionner la caméra 9 + Select Clip at Playhead Sélectionner le clip sous la tête de lecture + Select Find Box Sélectionner le champ de recherche + Select Next Clip Sélectionner le clip suivant + Select Next Panel Sélectionner le panneau suivant + Select Previous Clip Sélectionner le clip précédent + Select Previous Panel Sélectionner le panneau précédent + Select to Next Line Sélectionner jusqu'à la ligne suivante + Select to Next Word Sélectionner jusqu'au mot suivant + Select to Previous Line Sélectionner jusqu'à la ligne précédente + Select to Previous Word Sélectionner jusqu'au mot précédent + Select to Segment End Sélectionner jusqu'à la fin du segment + Select to Segment Start Sélectionner jusqu'au début du segment + Selection Tool Outil Sélection + Set Comparison Reference Définir la référence de comparaison + Set Creative Look Définir le look créatif + Set Input LUT Définir la LUT d'entrée + Set Project Panel View Définir l'affichage du panneau Projet + Show Next Screen Afficher l'écran suivant + Show Previous Screen Afficher l'écran précédent + Show Program Transcript Afficher la transcription du programme + Shuttle Left Navette vers la gauche + Shuttle Right Navette vers la droite + Shuttle Slow Left Navette lente vers la gauche + Shuttle Slow Right Navette lente vers la droite + Shuttle Stop Arrêter la navette + Slide Clip Selection Left Five Frames Coulisser la sélection de clips de cinq images vers la gauche + Slide Clip Selection Left One Frame Coulisser la sélection de clips d'une image vers la gauche + Slide Clip Selection Right Five Frames Coulisser la sélection de clips de cinq images vers la droite + Slide Clip Selection Right One Frame Coulisser la sélection de clips d'une image vers la droite + Slide Tool Outil Coulissement + Slip Clip Selection Left Five Frames Glisser la sélection de clips de cinq images vers la gauche + Slip Clip Selection Left One Frame Glisser la sélection de clips d'une image vers la gauche + Slip Clip Selection Right Five Frames Glisser la sélection de clips de cinq images vers la droite + Slip Clip Selection Right One Frame Glisser la sélection de clips d'une image vers la droite + Slip Tool Outil Glissement + Social Vertical 1080×1920 Vertical réseaux sociaux 1080×1920 + Soft Vignette Vignettage doux + Source In/Out Entrée/sortie source + Split Caption Scinder le sous-titre + Square Pixels (1.0) Pixels carrés (1.0) + Stack Clips Empiler les clips + Start Export Queue Démarrer la file d'attente d'exportation + Start Voice-over Recording Démarrer l'enregistrement de la voix off + Step Back Five Frames Reculer de cinq images + Step Back One Frame Reculer d'une image + Step Forward Five Frames Avancer de cinq images + Step Forward One Frame Avancer d'une image + Stop Export Queue Arrêter la file d'attente d'exportation + Stop Multi-Camera Recording Arrêter l'enregistrement multicaméra + Stop Voice-over Recording Arrêter l'enregistrement de la voix off + Sync Voice-over Capture Synchroniser la capture de la voix off + TIFF TIFF + TIFF Sequence Séquence TIFF + TextPanel Panneau Texte + Thumbnail Size Next Taille de vignette suivante + Thumbnail Size Previous Taille de vignette précédente + Toggle All Audio Targets Activer/désactiver toutes les cibles audio + Toggle All Source Audio Activer/désactiver tout l'audio source + Toggle All Source Video Activer/désactiver toute la vidéo source + Toggle All Video Targets Activer/désactiver toutes les cibles vidéo + Toggle Audio During Scrubbing Activer/désactiver l'audio pendant le défilement + Toggle Full Screen Activer/désactiver le plein écran + Toggle Mute for All Targeted Audio Tracks Activer/désactiver la coupure du son de toutes les pistes audio ciblées + Toggle Solo for All Targeted Audio Tracks Activer/désactiver le solo de toutes les pistes audio ciblées + Toggle Source/Program Monitor Focus Basculer entre moniteur source et moniteur programme + Toggle Target Audio 1 Activer/désactiver la cible audio 1 + Toggle Target Audio 2 Activer/désactiver la cible audio 2 + Toggle Target Audio 3 Activer/désactiver la cible audio 3 + Toggle Target Audio 4 Activer/désactiver la cible audio 4 + Toggle Target Audio 5 Activer/désactiver la cible audio 5 + Toggle Target Audio 6 Activer/désactiver la cible audio 6 + Toggle Target Audio 7 Activer/désactiver la cible audio 7 + Toggle Target Audio 8 Activer/désactiver la cible audio 8 + Toggle Target Video 1 Activer/désactiver la cible vidéo 1 + Toggle Target Video 2 Activer/désactiver la cible vidéo 2 + Toggle Target Video 3 Activer/désactiver la cible vidéo 3 + Toggle Target Video 4 Activer/désactiver la cible vidéo 4 + Toggle Target Video 5 Activer/désactiver la cible vidéo 5 + Toggle Target Video 6 Activer/désactiver la cible vidéo 6 + Toggle Target Video 7 Activer/désactiver la cible vidéo 7 + Toggle Target Video 8 Activer/désactiver la cible vidéo 8 + Toggle Track Output for All Targeted Video Tracks Activer/désactiver la sortie de toutes les pistes vidéo ciblées + Toggle Trim Type Basculer le type de rognage + Toggle View Basculer l'affichage + Track Select Backward Tool Outil Sélection de piste en arrière + Track Select Forward Tool Outil Sélection de piste en avant + Track Selected Mask Suivre le masque sélectionné + Trim Next Edit to Playhead Rogner le point de montage suivant jusqu'à la tête de lecture + Trim Previous Edit to Playhead Rogner le point de montage précédent jusqu'à la tête de lecture + Trimming Rognage + Type Tool Outil Texte + Unstack Clips Désempiler les clips + Upper First Trame supérieure d'abord + VBR, 1 pass VBR, 1 passe + VBR, 2 pass VBR, 2 passes + CRF (constant quality) CRF (qualité constante) + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Enregistrement de voix off + Voiceover Voix off + WAV, stereo 48 kHz 16-bit PCM WAV, stéréo 48 kHz PCM 16 bits + WAV, stereo 48 kHz 24-bit PCM WAV, stéréo 48 kHz PCM 24 bits + Warm Tint Dominante chaude + Warm highlights, brown shadows Tons clairs chauds, tons foncés bruns + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16 bits + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24 bits + Web & Social Web et réseaux sociaux + Work Area Zone de travail + Write Automation Écrire l'automatisation + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Zoomer le moniteur programme à 100 % + Zoom Program Monitor to Fit Adapter le moniteur programme à la fenêtre + Zoom Source Monitor to 100% Zoomer le moniteur source à 100 % + Zoom Source Monitor to Fit Adapter le moniteur source à la fenêtre + Zoom Tool Outil Zoom + with: par : + Interface Language Langue de l'interface + System Language Langue du système + Cinematic Cinématographique + Film Emulation Émulation de pellicule + Technical Technique + Teal Shadows, Warm Skin Tons foncés bleu canard, carnations chaudes + Night Exterior Extérieur nuit + Golden Dusk Crépuscule doré + Steel Blue Bleu acier + Hard Sun Soleil dur + Faded Print Tirage délavé + Silver Retention Rétention d'argent + Cross Processed Traitement croisé + Warm Negative Négatif chaud + Neutral Mono Monochrome neutre + Hard Mono Monochrome contrasté + Sepia Tone Virage sépia + Cold Mono Monochrome froid + Lift Shadows Relever les tons foncés + Protect Highlights Protéger les tons clairs + Half Saturation Saturation à 50 % + Boost Vibrance Augmenter la vibrance + Legal Range Squeeze Compression en niveaux légaux + A boxed label with a round pointer; move the label and the pointer follows. Un texte encadré avec un pointeur rond ; déplacez le texte et le pointeur suit. + A rounded tag that fits its text, with a status dot. Une étiquette arrondie qui s'adapte à son texte, avec un point d'état. + A title on a rounded card with a soft shadow. Un titre sur une carte arrondie avec une ombre portée douce. + Accent Color Couleur d'accentuation + Bar Color Couleur de la barre + Box Color Couleur de l'encadré + Box Opacity Opacité de l'encadré + Callout – Pointer Légende – Pointeur + Callout – Tag Légende – Étiquette + Callouts Légendes + Credits Générique + Dot Color Couleur du point + Heading En-tête + Large centred title, accent rule and subtitle. Grand titre centré, filet d'accentuation et sous-titre. + Lower Third – Rule Tiers inférieur – Filet + Lower Third – Slab Tiers inférieur – Bloc + Lower Thirds Tiers inférieurs + Name Color Couleur du nom + Name Size Taille du nom + Name and role on a coloured slab that widens with the name. Nom et fonction sur un bloc coloré qui s'élargit avec le nom. + Name over a thin rule that always matches the name's width. Nom sur un filet fin qui épouse toujours la largeur du nom. + Pointer Color Couleur du pointeur + Role Fonction + Rule Color Couleur du filet + Show Dot Afficher le point + Show Role Afficher la fonction + Slab Color Couleur du bloc + Tag Color Couleur de l'étiquette + Title Color Couleur du titre + Title Size Taille du titre + Title – Boxed Titre – Encadré + Title – Centered Titre – Centré + Titles Titres + Select an item to preview it Sélectionnez un élément pour le prévisualiser + End Credits Générique de fin + End Credits – Roll Générique de fin – Défilement vertical + Ticker – Crawl Bandeau d'infos – Défilement horizontal + Centred credits that roll up from below the frame and leave at the top. Générique centré qui monte depuis le bas de l'image et sort par le haut. + A news-style line that crawls in from the right and leaves at the left. Une ligne façon journal télévisé qui entre par la droite et sort par la gauche. + out sortie + in entrée + The language changed but could not be saved: {e} La langue a été changée mais n'a pas pu être enregistrée : {e} + no Chinese font is installed on this system; the interface stays in English aucune police chinoise n'est installée sur ce système ; l'interface reste en anglais + no Japanese font is installed on this system; the interface stays in English aucune police japonaise n'est installée sur ce système ; l'interface reste en anglais + Show Afficher + Sort Trier + Reverse the order Inverser l'ordre + Grab bag Pêle-mêle + Table Tableau + No contributor data was built into this copy. Aucune donnée de contributeurs n'est intégrée à cette copie. + No model credits were built into this copy. Aucun crédit de modèle n'est intégré à cette copie. + Company Société + Model Modèle + Version Version + Commits Commits + % of all commits % de tous les commits + Lines +/− Lignes +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) Échec de la sortie audio : lecture sans son (vérifiez Préférences ▸ Matériel audio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) La sortie audio ne répond pas : lecture sans son (vérifiez Préférences ▸ Matériel audio) + Clip Speed / Duration Vitesse/durée du clip + Speed: Vitesse : + Reverse Speed Vitesse inversée + Ripple Edit, Shifting Trailing Clips Montage avec décalage, déplacement des clips suivants + About À propos + Contributors Contributeurs + Models Modèles + Main (8-bit) Main (8 bits) + Main 10 (10-bit, HDR) Main 10 (10 bits, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8 bits ; Main 10 pour les séquences HDR) + Encoder Encodeur + Hardware (H.265 has no software encoder) Matériel (H.265 n'a pas d'encodeur logiciel) + Hardware (AV1 has no software encoder) Matériel (AV1 n'a pas d'encodeur logiciel) + Text Properties Propriétés du texte + Text Box Height Hauteur de la zone de texte + Paragraph Text Texte de paragraphe + Point Text Texte ponctuel + Text Layer Type Type de calque de texte + Text Styling Style du texte + New Color Matte Nouveau cache couleur + Color Matte Color Couleur du cache couleur + Color: Couleur : + · {time} left · reste {time} + Discard take Rejeter la prise + Voice-over take discarded Prise de voix off rejetée + Binary assets added Ressources binaires ajoutées + Binary assets removed Ressources binaires supprimées + Deselect All Project Items Désélectionner tous les éléments du projet + Display name Nom affiché + First commit Premier commit + H.265 (HEVC) H.265 (HEVC) + Last commit Dernier commit + Line delta Delta de lignes + Lines added Lignes ajoutées + Lines deleted Lignes supprimées + Merged PRs PR fusionnées + Name (A–Z) Nom (A–Z) + Real name Nom réel + Select All Project Items Sélectionner tous les éléments du projet + Username Nom d'utilisateur + Vertical Type Tool Outil Texte vertical + Go to previous keyframe Aller à l'image clé précédente + Go to next keyframe Aller à l'image clé suivante + Add keyframe Ajouter une image clé + Remove keyframe Supprimer l'image clé + Turn on animation and add keyframe Activer l'animation et ajouter une image clé + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par rectangle. Utilisez Maj, Alt et Ctrl pour d'autres options. + Click to zoom in; Alt-click to zoom out. Cliquez pour un zoom avant ; Alt+clic pour un zoom arrière. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Ctrl+clic : supprimer + Include Alpha Channel Inclure la couche alpha + Sequence Settings Paramètres de séquence + Color Management Gestion des couleurs + VR Properties Propriétés VR + Editing Mode: Mode de montage : + Timebase: Base de temps : + Frame Size: Taille de l'image : + Pixel Aspect Ratio: Rapport L/H des pixels : + Fields: Trames : + Channel Format: Format des canaux : + Number of Channels: Nombre de canaux : + Preview File Format: Format des fichiers de prévisualisation : + Working Color Space: Espace colorimétrique de travail : + Projection: Projection : + Layout: Disposition : + Captured View: Vue capturée : + Video Previews Prévisualisations vidéo + No Fields (Progressive Scan) Aucune trame (balayage progressif) + Custom ({ratio}) Personnalisé ({ratio}) + {rate} frames/second {rate} images/seconde + {rate} fps Drop-Frame Timecode Timecode drop frame {rate} fps + {rate} fps Non Drop-Frame Timecode Timecode non-drop frame {rate} fps + {rate} fps Timecode Timecode {rate} fps + horizontal horizontal + vertical vertical + Scale motion effects proportionally when changing frame size Mettre à l'échelle les effets de mouvement proportionnellement lors du changement de taille d'image + Maximum Bit Depth Profondeur de bits maximale + Maximum Render Quality Qualité de rendu maximale + Composite in Linear Color Composer en couleur linéaire + Auto Tone Map Media (HDR and log into SDR) Mappage automatique des tons des médias (HDR et log vers SDR) + Configure… Configurer… + Not supported in FilmCraft yet Pas encore pris en charge dans FilmCraft + FilmCraft always composites in linear light FilmCraft compose toujours en lumière linéaire + Loop marked range Boucler la plage marquée + Play Source Lire la source + Source Play/Stop Lire/arrêter la source + Stop Source Arrêter la source + New Sequence Nouvelle séquence + Tracks Pistes + tracks pistes + Clear Transition Effacer la transition + Set Transition Duration Définir la durée de la transition + Active Actif + Appearance Mode Mode d'apparence + Dark Theme Thème sombre + Light Theme Thème clair + Next Appearance Mode Mode d'apparence suivant + Sync with system Synchroniser avec le système + UI Scale Échelle de l'interface + Auto (system) Auto (système) + In Entrée + Out Sortie + Export Markers as CSV… Exporter les marqueurs au format CSV… + Fill Type Type de remplissage + Gradient Angle Angle du dégradé + Linear Gradient Dégradé linéaire + Solid Uni + GPU Rendering Rendu GPU + Composite on the GPU when available Composer sur le GPU si disponible + Text to Speech Synthèse vocale + Edit Narration Modifier la narration + Project Notes Notes du projet + Get Project Notes Obtenir les notes du projet + Edit Project Notes Modifier les notes du projet + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notes pour ce projet : tâches, instructions, numéros d'image… Enregistrées avec le projet. + Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet + Settings Préférences diff --git a/crates/ui-egui/src/i18n/fr.tsv.attribution b/crates/ui-egui/src/i18n/fr.tsv.attribution new file mode 100644 index 00000000..30609ac7 --- /dev/null +++ b/crates/ui-egui/src/i18n/fr.tsv.attribution @@ -0,0 +1,8 @@ +asset: fr.tsv +title: French interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-10 by vangel76 +notes: Original English-to-French interface translations using clean-room NLE vocabulary. diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 1cbf5b05..7225b5c9 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -44,6 +44,7 @@ pub enum Language { ZhCn, De, Ru, + Fr, } static JAPANESE: OnceLock = OnceLock::new(); @@ -53,9 +54,10 @@ static UKRAINIAN: OnceLock = OnceLock::new(); static CHINESE: OnceLock = OnceLock::new(); static GERMAN: OnceLock = OnceLock::new(); static RUSSIAN: OnceLock = OnceLock::new(); +static FRENCH: OnceLock = OnceLock::new(); impl Language { - pub const ALL: [Self; 8] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru]; + pub const ALL: [Self; 9] = [Self::En, Self::Ja, Self::Es, Self::PtBr, Self::Uk, Self::ZhCn, Self::De, Self::Ru, Self::Fr]; pub fn name(self) -> &'static str { match self { @@ -67,6 +69,7 @@ impl Language { Self::ZhCn => "简体中文", Self::De => "Deutsch", Self::Ru => "Русский", + Self::Fr => "Français", } } @@ -80,6 +83,7 @@ impl Language { "zh-cn" => Some(Self::ZhCn), "de" => Some(Self::De), "ru" => Some(Self::Ru), + "fr" => Some(Self::Fr), _ => None, } } @@ -87,11 +91,12 @@ impl Language { /// Interface Language ▸ System Language (#218): the first of the user's preferred languages /// (BCP 47 or POSIX locale tags such as `es-419`, `pt_BR.UTF-8`, most preferred first) that the /// interface has, else English. Any Portuguese gets the Brazilian catalog, the only one there is; - /// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one. Chinese gets the Simplified catalog + /// any German (`de-AT`, `de_CH.UTF-8`…) gets the German one and any French (`fr-CA`, `fr_BE.UTF-8`…) + /// the French one. Chinese gets the Simplified catalog /// only for Simplified locales (`zh`, `zh-CN`, `zh-SG`, `zh-Hans…`); Traditional ones (`zh-TW`, /// `zh-HK`, `zh-MO`, `zh-Hant…`) are skipped. pub fn from_locales(tags: &[String]) -> Self { - const PRIMARY: [(&str, Language); 7] = [ + const PRIMARY: [(&str, Language); 8] = [ ("en", Language::En), ("ja", Language::Ja), ("es", Language::Es), @@ -99,6 +104,7 @@ impl Language { ("uk", Language::Uk), ("de", Language::De), ("ru", Language::Ru), + ("fr", Language::Fr), ]; tags.iter() .find_map(|tag| { @@ -127,6 +133,7 @@ impl Language { Self::ZhCn => "zh-cn", Self::De => "de", Self::Ru => "ru", + Self::Fr => "fr", } } @@ -141,6 +148,7 @@ impl Language { Self::ZhCn => Some(CHINESE.get_or_init(|| Catalog::parse(include_str!("zh-cn.tsv")))), Self::De => Some(GERMAN.get_or_init(|| Catalog::parse(include_str!("de.tsv")))), Self::Ru => Some(RUSSIAN.get_or_init(|| Catalog::parse(include_str!("ru.tsv")))), + Self::Fr => Some(FRENCH.get_or_init(|| Catalog::parse(include_str!("fr.tsv")))), } } @@ -310,6 +318,7 @@ mod tests { ("zh-cn", include_str!("zh-cn.tsv")), ("de", include_str!("de.tsv")), ("ru", include_str!("ru.tsv")), + ("fr", include_str!("fr.tsv")), ] { let (entries, errors) = catalog::parse_entries(text); assert!(errors.is_empty(), "{code}: {errors:?}"); @@ -350,6 +359,13 @@ mod tests { assert_eq!(Language::Ru.tr("File"), "Файл"); assert_eq!(Language::Ru.tr("мой клип.mp4"), "мой клип.mp4"); assert_eq!(Language::Ru.tr("An untranslated label"), "An untranslated label"); + assert_eq!(Language::Fr.name(), "Français"); + assert_eq!(Language::Fr.tr("File"), "Fichier"); + assert_eq!(Language::Fr.tr("Save As…"), "Enregistrer sous…"); + assert_eq!(Language::Fr.tr("Montage d'été.mp4"), "Montage d'été.mp4"); + assert_eq!(Language::Fr.tr("An untranslated label"), "An untranslated label"); + assert_eq!(Language::parse("fr"), Some(Language::Fr)); + assert_eq!(Language::parse("fr-FR"), None, "the preference stores the bare code"); for l in Language::ALL { assert_eq!(Language::parse(l.code()), Some(l)); let json = serde_json::to_string(&l).unwrap(); @@ -801,6 +817,23 @@ mod tests { let mut restarted = filmcraft_engine::Session::default(); restarted.prefs = serde_json::from_str(&prefs).unwrap(); assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::De); + let result = crate::menus::invoke(&mut app, &ctx, "app.language.french", serde_json::json!({})).unwrap(); + assert_eq!(result, serde_json::json!("fr")); + assert_eq!((app.ui.language, current()), (Language::Fr, Language::Fr)); + assert_eq!(app.session.prefs.general.interface_language, "fr"); + for it in crate::menus::menu_items(&app).iter().filter(|it| it.id.starts_with("app.language.")) { + assert_eq!(it.checked, Some(it.id == "app.language.french"), "{}", it.id); + } + let items = crate::menus::menu_items(&app); + let label = |id: &str| items.iter().find(|it| it.id == id).map(|it| it.label.as_str()); + assert_eq!(label("file.saveAs"), Some("Enregistrer sous…")); + assert_eq!(label("app.language.german"), Some("Deutsch")); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains("\"language\":\"fr\""), "{saved}"); + let prefs = serde_json::to_string(&app.session.prefs).unwrap(); + let mut restarted = filmcraft_engine::Session::default(); + restarted.prefs = serde_json::from_str(&prefs).unwrap(); + assert_eq!(crate::FilmcraftApp::new(restarted).ui.language, Language::Fr); crate::menus::invoke(&mut app, &ctx, "app.language.english", serde_json::json!({})).unwrap(); assert_eq!(app.ui.language, Language::En); set_current(Language::En); @@ -829,11 +862,15 @@ mod tests { for tag in ["de", "DE", "de-DE", "de-AT", "de-CH", "de-LI", "de-LU", "de_DE.UTF-8", "de_AT.UTF-8@euro", "de_CH"] { assert_eq!(l(&[tag]), Language::De, "{tag}"); } - assert_eq!(l(&["fr-FR", "it", "es-MX", "ja"]), Language::Es, "the first one the interface has"); - assert_eq!(l(&["fr-CH", "de-CH", "en"]), Language::De); + for tag in ["fr", "FR", "fr-FR", "fr-CA", "fr-BE", "fr-CH", "fr_FR.UTF-8", "fr_BE.UTF-8@euro", "fr_CA"] { + assert_eq!(l(&[tag]), Language::Fr, "{tag}"); + } + assert_eq!(l(&["it-IT", "nl", "es-MX", "ja"]), Language::Es, "the first one the interface has"); + assert_eq!(l(&["it-CH", "de-CH", "fr-CH", "en"]), Language::De); + assert_eq!(l(&["it-CH", "fr-CH", "de-CH", "en"]), Language::Fr); assert_eq!(l(&["en-GB", "es"]), Language::En); assert_eq!(l(&["en-US", "de"]), Language::En); - for none in [&[][..], &["fr"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"]] { + for none in [&[][..], &["it"], &["C"], &["POSIX"], &[""], &["e"], &["esp"], &["-es"], &["deu"], &["d"], &["-de"], &["fra"], &["f"], &["-fr"]] { assert_eq!(l(none), Language::En, "{none:?}"); } assert_eq!(l(&[&"x".repeat(1 << 20), "es"]), Language::Es); @@ -856,7 +893,7 @@ mod tests { let count = asked.clone(); app.hooks.system_languages = Some(Box::new(move || { count.set(count.get() + 1); - vec!["fr-FR".into(), "es-ES".into()] + vec!["it-IT".into(), "es-ES".into()] })); app.apply_prefs(&ctx); assert_eq!(app.ui.language, Language::Es); @@ -915,6 +952,36 @@ mod tests { assert_eq!(Language::De.tr("Settings"), "Voreinstellungen"); } + /// French covers the whole interface (the Spanish catalog's entries and the native macOS + /// Settings menu), but, like Japanese, the tests require only the core menus, so strings added + /// later show in English until they are translated. Every entry must still have a Spanish + /// counterpart: a renamed or removed source would otherwise leave a dead French entry. + #[test] + fn french_covers_the_menus_and_has_no_dead_entries() { + let (entries, errors) = catalog::parse_entries(include_str!("fr.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + for (ctx, en, _) in &entries { + assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}"); + } + let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + for (_, en, _) in portuguese { + assert!(Language::Fr.has(&en), "missing French menu label: {en}"); + } + for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + assert!(Language::Fr.has(en), "untranslated French menu: {en}"); + } + // beyond the menus: panels, effects and their parameters, settings, status messages + for (en, fr) in + [("Settings", "Préférences"), ("Gaussian Blur", "Flou gaussien"), ("Audio Track Mixer", "Mixeur de pistes audio"), ("Sequence", "Séquence")] + { + assert_eq!(Language::Fr.tr(en), fr); + } + set_current(Language::Fr); + assert!(matches_query("Gaussian Blur", "flou")); + assert!(matches_query("Gaussian Blur", "gaussian")); + set_current(Language::En); + } + #[test] fn russian_entries_cover_the_original_menu_catalog() { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); @@ -933,22 +1000,14 @@ mod tests { } } - #[test] - fn ukrainian_renders_with_bundled_fonts() { + /// Every non-ASCII character of `text` renders with the bundled interface fonts, in every family. + fn assert_bundled_fonts_cover(text: impl Iterator) { let ctx = egui::Context::default(); crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default())); let mut output = ctx.run_ui(egui::RawInput::default(), |_| {}); output.textures_delta.clear(); - let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + let chars: std::collections::BTreeSet<_> = text.filter(|ch| !ch.is_ascii()).collect(); ctx.fonts_mut(|fonts| { - // Include the entire alphabet, even letters not yet used by a translated menu label. - let alphabet = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя"; - let chars: std::collections::BTreeSet<_> = alphabet - .chars() - .chain(Language::Uk.name().chars()) - .chain(entries.iter().flat_map(|(_, _, uk)| uk.chars())) - .filter(|ch| !ch.is_ascii()) - .collect(); for family in crate::theme::font_families() { let font = egui::FontId::new(13.0, family); // has_glyph compares font faces, giving false negatives when Inter also supplies @@ -966,6 +1025,24 @@ mod tests { }); } + #[test] + fn ukrainian_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + // Include the entire alphabet, even letters not yet used by a translated menu label. + let alphabet = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя"; + assert_bundled_fonts_cover(alphabet.chars().chain(Language::Uk.name().chars()).chain(entries.iter().flat_map(|(_, _, uk)| uk.chars()))); + } + + #[test] + fn french_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("fr.tsv")); + // every French letter and quotation mark, even ones no entry uses yet, and every letter the + // entries use (symbols such as ✔ come from the English sources) + let alphabet = "ÀÂÆÇÉÈÊËÎÏÔŒÙÛÜŸàâæçéèêëîïôœùûüÿ«»’"; + let letters = entries.iter().flat_map(|(_, _, fr)| fr.chars()).filter(|ch| ch.is_alphabetic()); + assert_bundled_fonts_cover(alphabet.chars().chain(Language::Fr.name().chars()).chain(letters)); + } + #[test] fn japanese_needs_an_installed_font() { let mut app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index c0095c6e..97ad0f1e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -30,6 +30,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ uic!("app.language.chinese", "简体中文", ["Edit", "Language"], None), uic!("app.language.german", "Deutsch", ["Edit", "Language"], None), uic!("app.language.russian", "Русский", ["Edit", "Language"], None), + uic!("app.language.french", "Français", ["Edit", "Language"], None), uic!("source.playback.toggle", "Source Play/Stop", [], None), uic!("source.playback.play", "Play Source", [], None), uic!("source.playback.stop", "Stop Source", [], None), @@ -184,6 +185,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: | "app.language.chinese" | "app.language.german" | "app.language.russian" + | "app.language.french" ) { // Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) { @@ -201,6 +203,7 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: "app.language.chinese" => crate::i18n::Language::ZhCn, "app.language.german" => crate::i18n::Language::De, "app.language.russian" => crate::i18n::Language::Ru, + "app.language.french" => crate::i18n::Language::Fr, _ => crate::i18n::Language::En, }; // The preference is updated in memory before it is written, so a failed write (read-only @@ -625,6 +628,7 @@ pub fn menu_items(app: &FilmcraftApp) -> Vec { "app.language.chinese" => it.checked = Some(app.ui.language == crate::i18n::Language::ZhCn), "app.language.german" => it.checked = Some(app.ui.language == crate::i18n::Language::De), "app.language.russian" => it.checked = Some(app.ui.language == crate::i18n::Language::Ru), + "app.language.french" => it.checked = Some(app.ui.language == crate::i18n::Language::Fr), _ => {} } if it.id.starts_with("view.") { diff --git a/docs/gaps.md b/docs/gaps.md index b479a6d1..b903c559 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -190,10 +190,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G14. Localization: 8 of the 12 key languages missing -- **Have:** Spanish, Japanese and Simplified Chinese catalogs cover ~99.6–99.9% of the ~3,500 - cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus). +- **Have:** Spanish, Japanese, Simplified Chinese and French (#498) catalogs cover ~99.6–100% of + the ~3,500 cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus). - **Missing:** Hindi, Arabic (no RTL interface layout; Arabic titles reported broken, #395), - French (#498), Indonesian, German (#490), Korean (#508; needs Hangul fonts in craft-fonts), + Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts), Vietnamese, Portuguese beyond menus; Premiere also ships Italian and Russian. Engine errors, CLI and MCP stay English. No language has a recorded native-speaker review. - **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index a4a18386..6124d1c7 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -10,14 +10,15 @@ Portuguese (Brazil), Russian, Simplified Chinese. Edit ▸ Language (also Settings ▸ General, `general.interfaceLanguage`) switches the interface and persists across restarts; System Language (the default) follows the OS's or browser's preferred -languages and falls back to English. Commands `app.language.` +languages and falls back to English. Commands `app.language.` are reachable over the control channel. One catalog per language, `crates/ui-egui/src/i18n/.tsv` (format in the header of `es.tsv`); UI code wraps strings in `tl!("…")` / `tlf!("…{name}", name)`, and registry names (effects and their parameters, settings, commands, panels, workspaces, presets) are translated where they are drawn. Searches match the displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu -label; `ja.tsv` has the same entries but is not yet required by tests. Engine error messages, the +label; `ja.tsv` and `fr.tsv` have the same entries but are not yet required by tests (French must +cover the core menus and keep no entry without a Spanish one). Engine error messages, the control channel, the CLI and MCP stay English. Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_FONTS_DIR`, all @@ -41,7 +42,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Spanish | es | 3,496 (99.9%) | as above | Latin | not recorded | partial | 4–8 h + review | | Hindi | hi | 0 (0%) | — | **no Devanagari shaping in the interface** | — | none | 30–50 h (shaping in UI + catalog + fonts) | | Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h | -| French | fr | 0 (0%) | — | Latin | — | none (requested #498) | 6–10 h + review | +| French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | | Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review | | Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review | | Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review | @@ -52,9 +53,9 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. Premiere also ships Italian and Russian, which we don't (6–10 h each). -**Localization dimension:** 4 of the 12 key languages near-complete (English, Spanish, Japanese, -Simplified Chinese), 1 menus-only, 7 none; against Premiere's 10 languages we cover 4 well and 1 -partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script +**Localization dimension:** 5 of the 12 key languages near-complete (English, Spanish, Japanese, +Simplified Chinese, French), 1 menus-only, 6 none; against Premiere's 10 languages we cover 5 well +and 1 partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script work (RTL and complex shaping in the egui interface) is the largest and least parallel part. Every language needs native-speaker review (human). From c9b9bf3aae5c78764dba264f74fd69de59715311 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:26:39 +0200 Subject: [PATCH 052/203] #800: MP2 audio in MP4 and MOV decodes instead of playing silent Problem: MP4 and QuickTime name MPEG audio without its layer (ffmpeg stores MP2 as `mp4a` + esds object type 0x6B), and we always made the layer III decoder for it. symphonia's decoder reads only the layer it was made for, so every MP2 frame was rejected and the track played as silence; MOV's `.mp2` sample entry was not recognised at all. Fix: the MPEG audio decoder for MP4 / MOV is made on the first packet, for the layer its frame header names (ISO/IEC 11172-3 2.4.2.3); `.mp2` maps to the MPEG audio codec like `.mp3`; the track is labelled "MPEG Audio" for layers I/II, as Matroska's is. Decoded tracks now match ffmpeg sample-exactly (edit list trims the 481-sample encoder delay). Co-Authored-By: Claude Opus 5.5 --- crates/codecs/src/audio.rs | 74 ++++++++++++++++++++++++++++++- crates/codecs/src/mp4.rs | 13 +++++- crates/codecs/tests/mp4_oracle.rs | 32 +++++++++++++ crates/isobmff/README.md | 1 + crates/isobmff/src/codec.rs | 20 ++++++++- docs/codec-parity.md | 2 +- docs/gaps.md | 10 +++-- 7 files changed, 143 insertions(+), 9 deletions(-) diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 63351636..daba8f4c 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -20,6 +20,9 @@ enum Inner { Aac { dec: Box, asc: Vec, up: Option> }, /// AAC whose configuration arrives with the first frame (ADTS / LATM). AacPending, + /// MPEG audio at this rate whose layer arrives with the first frame (MP4 / QuickTime name the + /// codec, not the layer, and symphonia's decoder reads only the layer it was made for). + MpegPending(u32), /// Our own Opus decoder (always 48 kHz output; pre-skip is left to container timestamps). /// `order` maps output channel → decoded channel (Vorbis → WAV/SMPTE order for surround). Opus { dec: Box, order: Option<&'static [usize]> }, @@ -133,6 +136,12 @@ impl PacketDecoder { }; Self::new(codec, sample_rate, None) } + /// MPEG-1/2 audio of a layer the frame headers name (see [`mpeg_audio_layer`]): MP4's + /// `esds` object types 0x69 / 0x6B and QuickTime's `.mp3` / `.mp2` cover layers I–III alike + /// (ffmpeg stores MP2 as `mp4a` + 0x6B). + pub fn lazy_mpeg_audio(sample_rate: u32) -> Self { + Self { inner: Inner::MpegPending(sample_rate), channels: 0 } + } /// AAC configured by [`Self::ensure_aac`] from in-band headers (ADTS, LATM). pub fn lazy_aac() -> Self { Self { inner: Inner::AacPending, channels: 0 } @@ -160,7 +169,7 @@ impl PacketDecoder { use filmcraft_isobmff::CodecConfig as C; match c { C::Aac(a) => Self::aac(&a.asc, if rate > 0 { rate } else { a.sample_rate }), - C::Mp3 => Self::new(CODEC_TYPE_MP3, rate, None), + C::Mp3 => Ok(Self::lazy_mpeg_audio(rate)), C::Ac3 { .. } | C::Eac3 { .. } => Self::ac3(), C::Alac { cookie } => Self::new(CODEC_TYPE_ALAC, rate, Some(cookie.clone())), C::Flac(_) => Self::new(CODEC_TYPE_FLAC, rate, None), @@ -170,6 +179,10 @@ impl PacketDecoder { } /// Decode one packet into planar channels. pub fn decode(&mut self, data: &[u8], ts: u64) -> Result>> { + if let Inner::MpegPending(rate) = self.inner { + let layer = mpeg_audio_layer(data).ok_or_else(|| CodecError::Decode("MPEG audio: no frame header".into()))?; + self.inner = Self::mpeg_audio(layer, rate)?.inner; + } let dec = match &mut self.inner { Inner::Aac { dec, up, .. } => { let mut out = dec.decode(data).map_err(|e| CodecError::Decode(e.to_string()))?; @@ -192,6 +205,7 @@ impl PacketDecoder { } Inner::Symphonia(d) => d, Inner::AacPending => return Err(CodecError::Decode("AAC: no configuration yet".into())), + Inner::MpegPending(_) => return Err(CodecError::Decode("MPEG audio: no frame header".into())), Inner::Ac3(d) => { // a packet may hold several syncframes (MP4 / Matroska); E-AC-3 dependent // substreams and further programs decode to no channels and are skipped @@ -245,11 +259,21 @@ impl PacketDecoder { Inner::Opus { dec, .. } => dec.reset(), Inner::Symphonia(d) => d.reset(), Inner::Ac3(d) => d.reset(), - Inner::AacPending => {} + Inner::AacPending | Inner::MpegPending(_) => {} } } } +/// The layer (1, 2 or 3) named by the MPEG-1/2 audio frame header that starts `frame`: the 12-bit +/// sync word (11 bits for MPEG-2.5), then `ID` and the 2-bit `layer` field, 3 for layer I down to 1 +/// for layer III (ISO/IEC 11172-3 §2.4.2.3). `None` without a sync word or for the reserved layer 0. +pub fn mpeg_audio_layer(frame: &[u8]) -> Option { + match *frame { + [0xFF, b1, ..] if b1 & 0xE0 == 0xE0 && (b1 >> 1) & 3 != 0 => Some(4 - ((b1 >> 1) & 3)), + _ => None, + } +} + /// The output rate of an AAC stream: twice the core rate for HE-AAC (ISO/IEC 14496-3 §1.6.5). /// Explicit signalling names the rate in the `AudioSpecificConfig`; with implicit signalling (an /// AAC-LC config at 24 kHz or less) SBR is only visible as fill-element data in the access units, @@ -1000,3 +1024,49 @@ mod packet_time_tests { assert_eq!(fixed_packet_samples(FixedFrames::Eac3, &[0x0B, 0x77], 48_000), None); } } + +#[cfg(test)] +mod mpeg_audio_tests { + use super::*; + + /// A silent mono 48 kHz MPEG-1 frame of `layer` (2: 64 kbps, 192 bytes; 3: 56 kbps, 168 + /// bytes): header, then all-zero bit allocation / side information. + fn silent_frame(layer: u8) -> Vec { + let (b1, len) = if layer == 2 { (0xFD, 192) } else { (0xFB, 168) }; + let mut f = vec![0xFF, b1, 0x44, 0xC0]; + f.resize(len, 0); + f + } + + #[test] + fn layer_comes_from_the_frame_header() { + assert_eq!(mpeg_audio_layer(&[0xFF, 0xFF, 0x10, 0xC0]), Some(1)); + assert_eq!(mpeg_audio_layer(&silent_frame(2)), Some(2)); + assert_eq!(mpeg_audio_layer(&silent_frame(3)), Some(3)); + // MPEG-2 layer III, MPEG-2.5 layer II + assert_eq!(mpeg_audio_layer(&[0xFF, 0xF3, 0x90, 0x44]), Some(3)); + assert_eq!(mpeg_audio_layer(&[0xFF, 0xE5, 0x90, 0x44]), Some(2)); + // reserved layer, no sync word, too short + for bad in [&[0xFF, 0xF9, 0x44, 0xC0][..], &[0xFF, 0x1D], &[0x00, 0xFD], &[0xFF], &[]] { + assert_eq!(mpeg_audio_layer(bad), None, "{bad:?}"); + } + } + + /// #800: ffmpeg stores MP2 in MP4 as `mp4a` + object type 0x6B, which names no layer. Handing + /// it to the layer III decoder rejected every frame and the track played as silence. + #[test] + fn mp4_mpeg_audio_decodes_the_layer_its_frames_name() { + for layer in [2u8, 3] { + let mut d = PacketDecoder::for_isobmff(&filmcraft_isobmff::CodecConfig::Mp3, 48_000).unwrap(); + // a packet without a frame header is an error, not a crash, and configures nothing + assert!(d.decode(&[0u8; 16], 0).is_err()); + d.reset(); + for k in 0..3u64 { + let out = d.decode(&silent_frame(layer), k * 1152).unwrap_or_else(|e| panic!("layer {layer}: {e}")); + assert_eq!(out.len(), 1, "layer {layer}"); + assert_eq!(out[0].len(), 1152, "layer {layer}"); + assert!(out[0].iter().all(|&s| s == 0.0), "layer {layer}"); + } + } + } +} diff --git a/crates/codecs/src/mp4.rs b/crates/codecs/src/mp4.rs index 4a3a2c53..c7ea9d06 100644 --- a/crates/codecs/src/mp4.rs +++ b/crates/codecs/src/mp4.rs @@ -547,7 +547,18 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option CodecConfig::Opus(o) => (crate::audio::OPUS_RATE, (o.output_channels as u32).max(1), None), _ => (if ap.sample_rate > 0.0 { ap.sample_rate as u32 } else { t.timescale }, ap.channels.max(1), None), }; - Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec: codec_label(&entry.codec), bits_per_sample: bits }) + let codec = match &entry.codec { + // these entries name no layer (ffmpeg stores MP2 as `mp4a` + 0x6B): the first frame does + CodecConfig::Mp3 => { + let head = t.samples.first().and_then(|s| filmcraft_media::reader::read_range(&*bytes.0, s.offset, (s.size as usize).min(4)).ok()); + match head.as_deref().and_then(crate::audio::mpeg_audio_layer) { + Some(1 | 2) => "MPEG Audio".into(), + _ => codec_label(&entry.codec), + } + } + c => codec_label(c), + }; + Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits }) } fn codec_label(c: &CodecConfig) -> String { diff --git a/crates/codecs/tests/mp4_oracle.rs b/crates/codecs/tests/mp4_oracle.rs index 012424d7..cbb71ba0 100644 --- a/crates/codecs/tests/mp4_oracle.rs +++ b/crates/codecs/tests/mp4_oracle.rs @@ -215,3 +215,35 @@ fn open_gop_leading_pictures_decode_alone() { } } } + +/// #800: MP2 in MP4 (`mp4a` + object type 0x6B, which names no layer) and in MOV (QuickTime +/// `.mp2`) decodes sample-exactly as ffmpeg does: same length, the encoder delay (481 samples) +/// trimmed by the edit list, and the same samples from the first one on, also after a seek. It +/// used to go to the layer III decoder (MP4: silence) or not decode at all (MOV). +#[test] +fn mp2_in_mp4_and_mov_matches_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (name, rate) in [("mp2_stereo_224k_48k.mp4", 48_000u32), ("mp2_stereo_224k_44k.mov", 44_100)] { + let input = format!("anoisesrc=d=1:c=pink:r={rate}:a=0.3"); + let Some(f) = fixture(&ff, name, &["-f", "lavfi", "-i", &input, "-ac", "2", "-c:a", "mp2", "-b:a", "224k"]) else { + eprintln!("SKIPPED ({name}): this ffmpeg can't encode it"); + continue; + }; + let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels, a.codec.as_str()), (rate, 2, "MPEG Audio"), "{name}"); + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let n = want.len() / 2; + assert!(n > rate as usize * 9 / 10, "{name}: ffmpeg decoded {n} samples"); + let worst = |from: usize, len: usize| { + let got = src.audio(from as i64, len, rate).unwrap(); + (0..len).map(|k| (0..2).map(|c| (got.channels[c][k] - want[2 * (from + k) + c]).abs()).fold(0f32, f32::max)).fold(0f32, f32::max) + }; + let level = want.iter().fold(0f32, |m, v| m.max(v.abs())); + assert!(level > 0.1, "{name}: the source is silent ({level})"); + // a seek into the middle first, then the whole track from the start + let (mid, all) = (worst(n / 2 + 77, 3000), worst(0, n)); + println!("{name}: {n} samples, max error mid {mid:.2e}, whole {all:.2e}"); + assert!(mid <= 1e-4 && all <= 1e-4, "{name}: differs from ffmpeg by {mid} (mid) / {all} (whole)"); + } +} diff --git a/crates/isobmff/README.md b/crates/isobmff/README.md index d74ae54a..47c31bb4 100644 --- a/crates/isobmff/README.md +++ b/crates/isobmff/README.md @@ -60,6 +60,7 @@ Boxes with 64-bit sizes and with size 0 (runs to the end of the file) are suppor | `jpeg mjpa mjpb` | `Jpeg { fourcc }` | | `AVdn AVdh` | `Dnx { fourcc }` | | `mp4a` + `esds` (in `wave` too) | `Aac(AacConfig)`: ASC bytes + decoded object type/rate/channels; `Mp3` for OTI 0x69/0x6B | +| `.mp3`/`.mp2` | `Mp3`: MPEG-1/2 audio of any layer (each frame header names it) | | PCM fourccs | `Pcm(PcmConfig)`: bits, float, endianness (`enda`, lpcm flags, `pcmC`), channels, rate | | `alac` | `Alac { cookie }` | | `Opus` + `dOps` | `Opus(OpusConfig)` | diff --git a/crates/isobmff/src/codec.rs b/crates/isobmff/src/codec.rs index b9afd53e..6d2ff79b 100644 --- a/crates/isobmff/src/codec.rs +++ b/crates/isobmff/src/codec.rs @@ -181,7 +181,8 @@ pub enum CodecConfig { /// Advanced Professional Video (`apv1` + `apvC`). Apv(ApvConfig), Aac(AacConfig), - /// MPEG-1/2 audio layer III (esds object type 0x69/0x6B, or QuickTime `.mp3`). + /// MPEG-1/2 audio (esds object type 0x69/0x6B, or QuickTime `.mp3` / `.mp2`). Usually layer + /// III, but these name no layer: ffmpeg stores MP2 as `mp4a` + 0x6B. Each frame header has it. Mp3, Pcm(PcmConfig), /// ALAC magic cookie (ALACSpecificConfig, 24 bytes, optionally followed by a channel layout). @@ -883,6 +884,7 @@ fn is_audio_fourcc(f: &[u8; 4]) -> bool { | b"ac-3" | b"ec-3" | b".mp3" + | b".mp2" | b"enca" | b"ulaw" | b"alaw" @@ -1028,7 +1030,7 @@ pub(crate) fn parse_sample_entry(format: FourCc, payload: &[u8], handler: FourCc Some(p) => parse_esds(p)?, None => unknown(), }, - b".mp3" => CodecConfig::Mp3, + b".mp3" | b".mp2" => CodecConfig::Mp3, b"alac" => match get(b"alac") { Some(p) if p.len() >= 4 => CodecConfig::Alac { cookie: p[4..].to_vec() }, _ => unknown(), @@ -1643,6 +1645,20 @@ mod tests { assert_eq!(roundtrip(&e, true, b"soun"), e); } + /// QuickTime's `.mp2` entry (what ffmpeg writes for MP2 in MOV) is MPEG audio, as `.mp3` is, + /// even under a handler that does not say "sound" (#800). + #[test] + fn quicktime_mpeg_audio_entries() { + for fourcc in [*b".mp3", *b".mp2"] { + let e = SampleEntry::audio(FourCc(fourcc), CodecConfig::Mp3, 2, 48000.0, 16); + for handler in [b"soun", b"\0\0\0\0"] { + let back = roundtrip(&e, true, handler); + assert_eq!(back.codec, CodecConfig::Mp3, "{fourcc:?} {handler:?}"); + assert_eq!(back.audio.map(|a| (a.channels, a.sample_rate)), Some((2, 48000.0)), "{fourcc:?} {handler:?}"); + } + } + } + #[test] fn pcm_entries() { for (bits, float, be, qt) in [ diff --git a/docs/codec-parity.md b/docs/codec-parity.md index ba0da95b..8fe1ff6b 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -42,7 +42,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h | | AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — | | E-AC-3 (Dolby Digital Plus) | decode | own decoder (ATSC A/52 Annex E, #647): independent substream, 1–6 blocks, spectral extension; **no** adaptive hybrid transform, enhanced coupling or 7.1 dependent substreams (plays the 5.1 core) | none | SNR vs ffmpeg 65–91 dB (raw, MP4, MOV, Matroska, TS) | 75% | 10–20 h | -| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) | +| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) in raw, MP4, MOV (#800), Matroska, TS, PS; **not** MP2 mono above 192 kbit/s or stereo below 64 kbit/s (ffmpeg's mono default; symphonia rejects them) | **none** | MP2 sample-exact vs ffmpeg (raw, MP4, MOV, TS, PS) | 60% | 10–20 h (MP3 encoder) | | Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — | | FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h | | WMA | decode on Windows | none | none | — | 0% | 10–20 h | diff --git a/docs/gaps.md b/docs/gaps.md index 56eddd32..4e14ba75 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -155,9 +155,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G10. Container and codec gaps on import - **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent - substream, 1–6 blocks per syncframe, spectral extension. -- **Missing:** E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 - dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no + substream, 1–6 blocks per syncframe, spectral extension. MP2 (MPEG audio layer II) in MP4 and + MOV, sample-exact against ffmpeg (#800). +- **Missing:** MP2 at bit rates ISO/IEC 11172-3 disallows: mono above 192 kbit/s (ffmpeg's default + for mono MP2 is 384 kbit/s) and stereo below 64 kbit/s; the bootstrap decoder (symphonia, also + 0.6) rejects every such frame, so the track plays silent: needs our own layer I/II decoder. + E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 dependent + substreams (played as the 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; From 50ce8b090cb207748a7dd36bbf148a6509f84f87 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:32:08 +0200 Subject: [PATCH 053/203] #805: Morph Cut morphs along optical flow instead of cross-dissolving Problem: Morph Cut rendered as a plain Cross Dissolve, so a jump cut in a talking-head shot showed the subject twice at half opacity during the transition, and the GPU plan mixed it as a dissolve too. Fix: Morph Cut warps both frames along the dense optical flow between them (the Lucas-Kanade flow of #780, crates/render/src/flow.rs) and blends, so the subject moves from its place in the outgoing shot to its place in the incoming one; where the frames do not correspond it stays the cross dissolve. Frames inside a Morph Cut are planned whole on the CPU (no longer a GPU-mixable "simple" transition) and cost as a complex transition in the preview estimate. Morph Cut goldens re-blessed. No face tracking yet. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/plan.rs | 2 +- crates/render/src/preview.rs | 2 +- crates/render/src/tests.rs | 26 ++++++++++ crates/render/src/transitions/dissolve.rs | 5 ++ crates/render/src/transitions/goldens.txt | 6 +-- crates/render/src/transitions/legacy.rs | 3 +- crates/render/src/transitions/mod.rs | 4 +- crates/render/src/transitions/tests.rs | 58 +++++++++++++++++++++++ docs/gaps.md | 9 ++-- docs/target-app-parity.md | 2 +- 10 files changed, 103 insertions(+), 14 deletions(-) diff --git a/crates/render/src/plan.rs b/crates/render/src/plan.rs index 92b333cd..4dd0d4d0 100644 --- a/crates/render/src/plan.rs +++ b/crates/render/src/plan.rs @@ -159,7 +159,7 @@ fn cpu_frame(img: crate::Image) -> Arc { } fn simple_transition(id: &str) -> bool { - matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white" | "morph_cut") + matches!(id, "cross_dissolve" | "dip_to_black" | "dip_to_white") } /// The standard effects of a media clip if the GPU can draw it (any blend mode, no opacity masks, diff --git a/crates/render/src/preview.rs b/crates/render/src/preview.rs index f113e904..8079502b 100644 --- a/crates/render/src/preview.rs +++ b/crates/render/src/preview.rs @@ -487,7 +487,7 @@ fn estimate_cost(project: &Project, seq: &Sequence, layers: &[Layer]) -> f64 { total += match l { Layer::Item(_, it) => layer_cost(project, it, px, false), Layer::Transition(_, x, a, b) => { - let simple = matches!(x.effect.effect.as_str(), "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve" | "morph_cut"); + let simple = matches!(x.effect.effect.as_str(), "cross_dissolve" | "dip_to_black" | "dip_to_white" | "non_additive_dissolve"); let sides: f64 = [a, b].iter().filter_map(|s| s.map(|it| layer_cost(project, it, px, !simple))).sum(); sides + if simple { 1.0 } else { 12.0 * px } } diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index 31f5edfc..31eed64c 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -250,6 +250,32 @@ fn push_plan_preserves_reference_track_order_and_reverse() { } } +#[test] +fn morph_cut_frames_render_on_the_cpu_with_the_morph() { + // the GPU compositor can only cross-fade layers: a Morph Cut frame is planned whole on the + // CPU, so preview and export show the optical-flow morph, not a dissolve + let (mut p, red, ocean, seq, map) = setup(); + let a = place(&mut p, seq, 0, red, 0, 24); + let b = place(&mut p, seq, 0, ocean, 24, 24); + let rate = FrameRate::FPS_24; + p.sequence_mut(seq).unwrap().video_tracks[0].transitions.push(filmcraft_project::Transition { + id: filmcraft_project::TransitionId(998), + effect: filmcraft_project::find_effect("morph_cut").unwrap().instance(), + start: rate.tick_of(18), + duration: rate.tick_of(12), + from: Some(a), + to: Some(b), + align: Default::default(), + reverse: false, + }); + let opts = RenderOptions { scale: 0.5, ..Default::default() }; + assert!(plan::is_cpu_frame(&p, seq, rate.tick_of(24))); + assert!(!plan::is_cpu_frame(&p, seq, rate.tick_of(10)), "outside the transition the frame stays on the GPU"); + let plan = plan::plan_frame(&p, seq, rate.tick_of(24), opts, &map); + assert!(matches!(plan, plan::FramePlan::Image(_))); + assert_eq!(plan::execute_cpu(&plan).px, render_sequence(&p, seq, rate.tick_of(24), opts, &map).px); +} + /// A project with a 4 s bars-and-tone clip (1 kHz at −20 dBFS) on A1, with extra audio effects. fn tone_with(effects: &[(&str, &[(&str, f64)])]) -> (Project, ItemId, SourceMap) { let mut p = Project::new("fx"); diff --git a/crates/render/src/transitions/dissolve.rs b/crates/render/src/transitions/dissolve.rs index 208a74ee..14756eef 100644 --- a/crates/render/src/transitions/dissolve.rs +++ b/crates/render/src/transitions/dissolve.rs @@ -54,6 +54,11 @@ pub(super) fn apply(id: &str, t: &Tx) -> Option { } }) } + // a jump cut smoothed by morphing: both frames are warped along the dense optical flow + // between them and blended, so the subject moves from its place in the outgoing shot to + // its place in the incoming one instead of showing twice; where the frames do not + // correspond (no consistent motion) it is the cross dissolve + "morph_cut" => crate::flow::interpolate(t.a, t.b, p), "luma_fade" => { let soft = t.frac("softness").clamp(0.005, 1.0) * 0.5; let from_b = t.choice("source") == 1; diff --git a/crates/render/src/transitions/goldens.txt b/crates/render/src/transitions/goldens.txt index 91f56bba..82692a17 100644 --- a/crates/render/src/transitions/goldens.txt +++ b/crates/render/src/transitions/goldens.txt @@ -49,9 +49,9 @@ film_dissolve 0.75 1933591e335936415928335919426e1e426e9fa16e28426e1950821e50824 luma_fade 0.25 5c2320892320b62320e223205c3620893620b63620e236205c4920894920b64920e249205c5c20895c20b65c20e05c22 luma_fade 0.50 472835462f4c4547621a38702540681d438199cb8f0e46951352a80e53b43b81b60d53b60d60d40d60d60d60d60d60d6 luma_fade 0.75 0d39760d3976224f760d39760d46960d46969ad3960d46960d53b60d53b63b81b60d53b60d60d60d60d60d60d60d60d6 -morph_cut 0.25 4929356a2935912e35ad2935493a3d6a3a3daf5d3dad3a3d494c456a4c45975745ad4c45495d4d6a5d4d8b5d4dad5d4d -morph_cut 0.50 352e4b4b2e4b6c394b782e4b353e5b4b3e5ba8855b783e5b354e6b4b4e6b78656b784e6b355e7b4b5e7b615e7b785e7b -morph_cut 0.75 2134602c34604744604234602142782c4278a1ac784242782150902c50905a7390425090215fa82c5fa8375fa8425fa8 +morph_cut 0.25 472a36582c40892d3a563c75483b43683a3e9f5a44684671434c51604d528a685b7f5271465d556c5c44925c3fb9593d +morph_cut 0.50 372e473e31536a364a3f3b763e3d51503e559e7d5b444780324e71484e6f6972803e57a6375e77505d6e6b5c6b9c5557 +morph_cut 0.75 263357273563493f5c2a3a752f406737416e99a27729478d21508e344f88537b9b2e53a5295e98355d9b3560aa495da6 mosaic_transition 0.25 7f3633973633cb3633f336337f3633973633cb3633f336337f3633973633cb3633f3363359650d70650da4650dcd650d mosaic_transition 0.50 0d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b200d4a9f0d4a9fcc3b20cc3b2036566f36566f774e77774e77 mosaic_transition 0.75 0d46963d7696a6df964680960d46963d7696a6df964680960d46963d7696a6df964680960d66e50d66e50d66e50d66e5 diff --git a/crates/render/src/transitions/legacy.rs b/crates/render/src/transitions/legacy.rs index 7af9bb24..1cf8ddfd 100644 --- a/crates/render/src/transitions/legacy.rs +++ b/crates/render/src/transitions/legacy.rs @@ -56,7 +56,6 @@ pub(super) fn apply(e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Option< id, "cross_dissolve" | "cross_dissolve_legacy" - | "morph_cut" | "film_dissolve" | "film_dissolve_legacy" | "additive_dissolve" @@ -93,7 +92,7 @@ fn legacy(id: &str, e: &EffectInstance, a: &Image, b: &Image, p: f32) -> Image { let aspect = (a.w as f32) / (a.h as f32).max(1.0); let pd = p as f64; match id { - "cross_dissolve" | "morph_cut" => masked(a, b, |_, _| p), + "cross_dissolve" => masked(a, b, |_, _| p), "film_dissolve" => { // blend in a gamma-2.2-like space for a filmic, less-dippy dissolve let mut out = Image::new(a.w, a.h); diff --git a/crates/render/src/transitions/mod.rs b/crates/render/src/transitions/mod.rs index ceb515f2..27c26922 100644 --- a/crates/render/src/transitions/mod.rs +++ b/crates/render/src/transitions/mod.rs @@ -18,8 +18,8 @@ //! //! Immersive (VR) transitions are flat approximations on the equirectangular frame: sampling wraps //! horizontally and the iris uses great-circle distance, but there is no sphere re-projection. -//! Morph Cut is a cross dissolve (no face tracking), and the Smart Tools / Text transitions are -//! procedural stand-ins (no content analysis, no typed text). +//! Morph Cut morphs with dense optical flow ([`crate::flow`]) but has no face tracking, and the +//! Smart Tools / Text transitions are procedural stand-ins (no content analysis, no typed text). use filmcraft_project::{EffectInstance, ParamValue}; use filmcraft_time::Tick; diff --git a/crates/render/src/transitions/tests.rs b/crates/render/src/transitions/tests.rs index b209a782..a096c55f 100644 --- a/crates/render/src/transitions/tests.rs +++ b/crates/render/src/transitions/tests.rs @@ -248,6 +248,64 @@ fn modern_and_legacy_ids_are_distinct_renders() { assert!(mean_abs(&m, &want) < 1e-6); } +/// A jump cut (256×144): a static background with a 64×72 subject whose left edge is at `x0`; +/// flat colours, or a smooth texture on both (the subject's own texture moves with it). +fn jump_cut_frame(x0: f32, textured: bool) -> Image { + let tex = |u: f32, v: f32, k: f32| { + 0.35 + 0.15 * (u * 0.21 * k).sin() * (v * 0.17).cos() + 0.1 * ((u + v) * 0.11 * k).sin() + 0.05 * (u * 0.53 - v * 0.31 * k).cos() + }; + paint(256, 144, |x, y| { + let inside = (x0..x0 + 64.0).contains(&x) && (36.0..108.0).contains(&y); + let l = match (textured, inside) { + (false, true) => 0.95, + (false, false) => 0.3, + (true, true) => 0.4 + tex(x - x0, y, 0.6), + (true, false) => 0.6 * tex(x, y, 1.0), + }; + [l, l * 0.9, l * 0.8, 1.0] + }) +} + +#[test] +fn morph_cut_moves_the_subject_instead_of_double_exposing() { + // the subject jumps 12 px between the shots; at the midpoint Morph Cut shows it once, 6 px + // along, where a cross dissolve shows two half-bright copies + let (a, b) = (jump_cut_frame(96.0, false), jump_cut_frame(108.0, false)); + let want = jump_cut_frame(102.0, false); + let morph = apply(&filmcraft_project::find_effect("morph_cut").unwrap().instance(), &a, &b, 0.5); + let dissolve = apply(&filmcraft_project::find_effect("cross_dissolve").unwrap().instance(), &a, &b, 0.5); + // mean red error down a column through the subject's rows + let column_err = |img: &Image, x: usize| (40..104).map(|y| (img.get(x, y)[0] - want.get(x, y)[0]).abs()).sum::() / 64.0; + // uncovered background, both edges, the middle, background still to be covered + for x in [98usize, 103, 134, 165, 170] { + let (m, d) = (column_err(&morph, x), column_err(&dissolve, x)); + assert!(m < 0.12, "x={x}: morph off by {m} (dissolve {d})"); + } + for x in [98usize, 170] { + assert!(column_err(&dissolve, x) > 0.3, "x={x}: the dissolve ghosts here"); + } + let (e_morph, e_dissolve) = (mean_abs(&morph, &want), mean_abs(&dissolve, &want)); + assert!(e_morph * 10.0 < e_dissolve, "morph {e_morph} vs dissolve {e_dissolve}"); + // the background away from the subject is left alone + for (x, y) in [(8, 8), (40, 70), (220, 130)] { + assert!(morph.get(x, y).iter().zip(a.get(x, y)).all(|(p, q)| (p - q).abs() < 1e-4), "({x}, {y})"); + } +} + +#[test] +fn morph_cut_tracks_textured_subjects_and_follows_the_progress() { + // detail on the subject and behind it: the morph lands much closer than the dissolve, at + // the midpoint and a quarter of the way through + for (p, from, to, at) in [(0.5f32, 96.0f32, 108.0f32, 102.0f32), (0.25, 96.0, 112.0, 100.0)] { + let (a, b) = (jump_cut_frame(from, true), jump_cut_frame(to, true)); + let want = jump_cut_frame(at, true); + let morph = apply(&filmcraft_project::find_effect("morph_cut").unwrap().instance(), &a, &b, p); + let dissolve = a.clone().lerp(&b, p); + let (e_morph, e_dissolve) = (mean_abs(&morph, &want), mean_abs(&dissolve, &want)); + assert!(e_morph * 2.0 < e_dissolve, "p={p}: morph {e_morph} vs dissolve {e_dissolve}"); + } +} + // --------------------------------------------------------------------------------------------- // Golden fingerprints diff --git a/docs/gaps.md b/docs/gaps.md index ab25b8af..628bd5a2 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -227,11 +227,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G18. Effects depth: approximations and missing behaviour -- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri - HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (rounded corners, - constrained lines). +- **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking + (and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles, + redesigned 26.0 mask tools (rounded corners, constrained lines). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense - coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key + coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow + instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key Setting applied as a parameter preset (#458). - **Estimate:** 60–110 h. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 7703c9a7..e74879f6 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -106,7 +106,7 @@ agent-hours to close the area to Premiere, sequential. | Area | Weight | Breadth | Ready | Hours | Evidence | Detail | |---|---|---|---|---|---|---| | Editing, timeline, trimming, multicam | 20% | ~90% | ~70% | 80–140 | Edit algebra, trim modes, Trim Monitor, multicam with audio sync, Premiere default keyboard (143 commands), Source playback and range dragging (10-08). Open: ripple-delete of a gap by column (#668), Backspace on gaps (#648), clip stretching (#653), deselect on empty space (#683), new tracks by dragging media (#483), Escape cancelling drags (#580), time remapping (#510), effects on a whole track (#623) | [ui-parity.md](ui-parity.md) | -| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Morph Cut and Auto Reframe approximate; Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) | +| Effects and transitions | 12% | 100% (measured) | ~60% | 80–140 | Every effect exists; Warp Stabilizer is 2-D, Auto Reframe approximate, Morph Cut morphs with optical flow but without face tracking (#805); Optical Flow time interpolation is motion compensated on the CPU (#780). 34 effect ids run on the GPU against ~82 of Premiere's ~92 marked Accelerated. No Object Mask (26.0, AI). | [hardware-parity.md](hardware-parity.md) | | Media import and codecs | 12% | ~75% | ~60% | 150–250 | Own bit-exact decoders for H.264, HEVC, VP9, AV1, ProRes, DNx, APV, MPEG-2, AAC, AC-3, Opus. But H.264 only 8-bit 4:2:0 and HEVC no 4:2:2 in software (most 10-bit cameras, #626); no camera RAW (R3D, ARRIRAW, BRAW, Sony RAW, Canon RAW, ProRes RAW, #345, #383); no E-AC-3 (#647), AVI (#598), WMV, DV, XAVC / P2 metadata; Sony start timecode (#460); WebM black (#432) | [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md) | | Audio | 10% | ~88% | ~65% | 50–90 | Mixer graph, automation, all 53 effects, Essential Sound, 5.1, voice-over, Remix, multi-stream container audio (#361). Device and playback reports: crackle / cut-outs (#336, #410, #602, #605, #617); Enhance Speech is DSP, not a model; ALSA fallback fixed (#254) | | | Panels and UI fidelity | 10% | ~88% | ~60% | 120–200 | All main panels. Missing: on-screen transform handles in the Program monitor (#639), Effect Controls keyframe zoom / divider / row lines (#640–#645), docking rearrangement (#493), multi-monitor windows (#466), filmstrip thumbnails (#614), marquee in Project panel (#578) | [ui-parity.md](ui-parity.md) | From fb5863bc8a14d33edf7fcef0bd5ac6f41cc76cb1 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:34:28 +0200 Subject: [PATCH 054/203] #806: export OpenEXR image sequences Problem: Premiere offers OpenEXR as an image-sequence export format; FilmCraft could not write EXR at all (docs/gaps.md G13). Fix: a new export format (id exr, "OpenEXR") writing numbered .exr stills, with a preset, the Include Alpha Channel option, the file.exportMedia format list and es/ja/zh-cn strings. The writer in crates/export/src/raster.rs follows the public OpenEXR file layout: version 2 single-part scan-line file, half-float B/G/R(/A) channels, no compression, one scan line per chunk, Rec.709 chromaticities; values are scene-linear (the pipeline's transfer undone) with premultiplied alpha, as EXR defines. No new dependency. ZIP / PIZ / DWA compression stays open. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 3 +- crates/export/src/lib.rs | 34 ++++- crates/export/src/presets.rs | 6 + crates/export/src/raster.rs | 227 +++++++++++++++++++++++++++- crates/export/src/settings.rs | 9 +- crates/export/src/settings_tests.rs | 45 +++++- crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 + docs/architecture.md | 2 +- docs/file-format-parity.md | 2 +- docs/gaps.md | 5 +- 13 files changed, 326 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e3b162fd..72a32cb8 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1285,7 +1285,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 6a8c5d71..b6d65d10 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -137,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()), None => (1920, 1080), }; - if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx") { + if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx" | "exr") { let files = files_in(&sub); let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string(); assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps"); @@ -151,6 +151,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { "jpg" => "mjpeg", "tga" => "targa", "dpx" => "dpx", + "exr" => "exr", _ => "bmp", }; assert_eq!(v["codec_name"], codec, "{name}"); diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 279384f2..c868e91c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -6,7 +6,7 @@ //! //! Video encoders implement [`VideoEncoder`]; codec crates register theirs with //! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG / -//! Targa / DPX sequences, GIF, WAV and AIFF. +//! Targa / DPX / OpenEXR sequences, GIF, WAV and AIFF. //! //! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio //! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the @@ -106,6 +106,9 @@ pub enum Format { /// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709). #[serde(rename = "dpx", alias = "DpxSequence")] DpxSequence, + /// Numbered OpenEXR stills (half-float scene-linear RGB, RGBA with [`ExportSettings::alpha`]). + #[serde(rename = "exr", alias = "ExrSequence")] + ExrSequence, #[serde(rename = "gif", alias = "Gif")] Gif, #[serde(rename = "wav", alias = "Wav")] @@ -140,6 +143,7 @@ impl Format { "jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence, "tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence, "dpx" | "dpxsequence" => Format::DpxSequence, + "exr" | "openexr" | "exrsequence" | "openexrsequence" => Format::ExrSequence, "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, @@ -162,6 +166,7 @@ impl Format { Format::JpegSequence => "jpg", Format::TgaSequence => "tga", Format::DpxSequence => "dpx", + Format::ExrSequence => "exr", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", @@ -179,6 +184,7 @@ impl Format { Format::JpegSequence => "jpg", Format::TgaSequence => "tga", Format::DpxSequence => "dpx", + Format::ExrSequence => "exr", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", @@ -200,6 +206,7 @@ impl Format { Format::JpegSequence => "JPEG", Format::TgaSequence => "Targa", Format::DpxSequence => "DPX", + Format::ExrSequence => "OpenEXR", Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", @@ -209,7 +216,16 @@ impl Format { } /// Numbered stills, one per frame ([`image_sequence_path`]). pub fn is_image_sequence(self) -> bool { - matches!(self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence) + matches!( + self, + Format::PngSequence + | Format::TiffSequence + | Format::BmpSequence + | Format::JpegSequence + | Format::TgaSequence + | Format::DpxSequence + | Format::ExrSequence + ) } /// An MXF container format. pub fn is_mxf(self) -> bool { @@ -232,7 +248,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::H264, Format::Hevc, Format::Av1, @@ -246,6 +262,7 @@ impl Format { Format::JpegSequence, Format::TgaSequence, Format::DpxSequence, + Format::ExrSequence, Format::Gif, Format::Wav, Format::Aiff, @@ -302,9 +319,10 @@ pub struct ExportSettings { /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, - /// Keep the alpha channel in PNG, TIFF and Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies - /// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over - /// black (#160). Other formats ignore it. + /// Keep the alpha channel in PNG, TIFF, Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ + /// QuickTime movies (straight alpha, Premiere's "Include Alpha Channel"; OpenEXR stores it + /// premultiplied, as the format defines). Off, every frame is flattened over black (#160). + /// Other formats ignore it. #[serde(default)] pub alpha: bool, /// Output frame size (None = Match Source: the sequence size times `scale`). @@ -1659,7 +1677,8 @@ pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { } /// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha -/// channel it is given; `alpha` keeps it in TIFF and Targa too, which are otherwise written as RGB. +/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written +/// as RGB. /// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) @@ -1683,6 +1702,7 @@ pub fn encode_still_with_quality(format: Format, rgba: Vec, w: u32, h: u32, } Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), Format::DpxSequence => return raster::dpx(&rgba, w, h), + Format::ExrSequence => return raster::exr(&rgba, w, h, alpha), Format::PngSequence => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 2a021d49..3846ce9a 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -160,6 +160,12 @@ pub fn builtin_presets() -> Vec { ExportSettings { format: Format::TgaSequence, ..Default::default() }, ), p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }), + p( + "OpenEXR Sequence", + "Image Sequence", + "Numbered OpenEXR stills (half float, linear, uncompressed)", + ExportSettings { format: Format::ExrSequence, ..Default::default() }, + ), p( "Animated GIF 640×360", "Animated GIF", diff --git a/crates/export/src/raster.rs b/crates/export/src/raster.rs index 59641319..74ea4081 100644 --- a/crates/export/src/raster.rs +++ b/crates/export/src/raster.rs @@ -1,9 +1,12 @@ -//! Uncompressed still formats written directly from RGBA8 frames: Targa and DPX. +//! Uncompressed still formats written directly from RGBA8 frames: Targa, DPX and OpenEXR. //! //! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed //! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268 //! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit //! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry. +//! OpenEXR follows the public OpenEXR file layout (version 2, single-part scan-line file): half-float +//! B, G, R (and A) channels, no compression, one scan line per chunk, scene-linear Rec.709 values +//! with premultiplied alpha, as EXR readers expect. use crate::{ExportError, Result}; /// `w * h * 4` when `rgba` holds exactly that many bytes. @@ -129,6 +132,120 @@ pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result> { Ok(out) } +/// IEEE 754 binary16 bits of `v`, rounded to nearest even (overflow → infinity, NaN → quiet NaN). +fn half(v: f32) -> u16 { + let b = v.to_bits(); + let sign = ((b >> 16) & 0x8000) as u16; + let exp = ((b >> 23) & 0xFF) as i32; + let man = b & 0x7F_FFFF; + if exp == 0xFF { + return sign | 0x7C00 | if man != 0 { 0x200 } else { 0 }; + } + // rounds `m >> shift` to nearest, ties to even (`shift` is 1..=24) + let round = |m: u32, shift: u32| { + let (q, rem, mid) = (m >> shift, m & ((1 << shift) - 1), 1 << (shift - 1)); + if rem > mid || (rem == mid && q & 1 == 1) { q + 1 } else { q } + }; + let e = exp - 127 + 15; + if e >= 0x1F { + sign | 0x7C00 + } else if e <= 0 { + if e < -10 { + return sign; + } + // subnormal half: the 24-bit significand scaled by 2^(e - 14); rounding up may carry + // into the smallest normal, which is the right encoding + sign | round(man | 0x80_0000, (14 - e) as u32) as u16 + } else { + // a carry out of the mantissa bumps the exponent (up to infinity), as it should + sign | round(((e as u32) << 23) | man, 13) as u16 + } +} + +/// An OpenEXR file: half-float scene-linear Rec.709 RGB, or RGBA with `alpha` (premultiplied, as +/// EXR defines alpha; the frame's straight alpha is multiplied in after linearisation). +pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result> { + let n = checked_len(rgba, w, h)?; + let (Ok(x_max), Ok(y_max)) = (i32::try_from(w - 1), i32::try_from(h - 1)) else { + return Err(ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large"))); + }; + // channels in the alphabetical order the file layout requires + let names: &[u8] = if alpha { b"ABGR" } else { b"BGR" }; + let line = (w as usize).checked_mul(names.len() * 2).ok_or_else(|| ExportError::Encode("frame size".into()))?; + let line_size = i32::try_from(line).map_err(|_| ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")))?; + + let mut hd = Vec::with_capacity(512); + hd.extend_from_slice(&[0x76, 0x2F, 0x31, 0x01]); // magic number 20000630 + hd.extend_from_slice(&[2, 0, 0, 0]); // version 2, single-part scan-line file, short names + let attr = |hd: &mut Vec, name: &str, ty: &str, value: &[u8]| { + for s in [name, ty] { + hd.extend_from_slice(s.as_bytes()); + hd.push(0); + } + hd.extend_from_slice(&(value.len() as i32).to_le_bytes()); + hd.extend_from_slice(value); + }; + let le = |v: &[f32]| v.iter().flat_map(|f| f.to_le_bytes()).collect::>(); + let mut channels = Vec::new(); + for &c in names { + channels.extend_from_slice(&[c, 0]); + channels.extend_from_slice(&1_i32.to_le_bytes()); // pixel type HALF + channels.extend_from_slice(&[0, 0, 0, 0]); // pLinear 0, reserved + channels.extend_from_slice(&1_i32.to_le_bytes()); // x sampling + channels.extend_from_slice(&1_i32.to_le_bytes()); // y sampling + } + channels.push(0); + let window: Vec = [0, 0, x_max, y_max].iter().flat_map(|v| v.to_le_bytes()).collect(); + attr(&mut hd, "channels", "chlist", &channels); + attr(&mut hd, "chromaticities", "chromaticities", &le(&[0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290])); + attr(&mut hd, "compression", "compression", &[0]); // NO_COMPRESSION + attr(&mut hd, "dataWindow", "box2i", &window); + attr(&mut hd, "displayWindow", "box2i", &window); + attr(&mut hd, "lineOrder", "lineOrder", &[0]); // INCREASING_Y + attr(&mut hd, "pixelAspectRatio", "float", &le(&[1.0])); + attr(&mut hd, "screenWindowCenter", "v2f", &le(&[0.0, 0.0])); + attr(&mut hd, "screenWindowWidth", "float", &le(&[1.0])); + hd.push(0); // end of header + + // offset table (one chunk per scan line), then the chunks: y, data size, pixel data + let chunk = line + 8; + let table = (h as usize).checked_mul(8); + let body = (h as usize).checked_mul(chunk); + let size = table.zip(body).and_then(|(t, b)| hd.len().checked_add(t)?.checked_add(b)).ok_or_else(|| ExportError::Encode("frame size".into()))?; + let mut out = Vec::with_capacity(size); + out.extend_from_slice(&hd); + let first = (hd.len() + h as usize * 8) as u64; + for y in 0..h as u64 { + out.extend_from_slice(&(first + y * chunk as u64).to_le_bytes()); + } + let lin = filmcraft_color::srgb_u8_to_linear_table(); + let opaque: [u16; 256] = std::array::from_fn(|i| half(lin[i])); + let row = w as usize * 4; + let mut px = vec![0_u16; w as usize * names.len()]; + for (y, src) in rgba[..n].chunks_exact(row).enumerate() { + // planar per line: every pixel of the first channel, then the next channel … + let wn = w as usize; + for (x, &[r, g, b, a]) in src.as_chunks::<4>().0.iter().enumerate() { + let rgb = [b, g, r]; + if alpha { + let k = f32::from(a) / 255.0; + px[x] = half(k); + for (c, &v) in rgb.iter().enumerate() { + px[(c + 1) * wn + x] = if a == 255 { opaque[v as usize] } else { half(lin[v as usize] * k) }; + } + } else { + for (c, &v) in rgb.iter().enumerate() { + px[c * wn + x] = opaque[v as usize]; + } + } + } + out.extend_from_slice(&(y as i32).to_le_bytes()); + out.extend_from_slice(&line_size.to_le_bytes()); + out.extend(px.iter().flat_map(|v| v.to_le_bytes())); + } + Ok(out) +} + #[cfg(test)] mod tests { use super::*; @@ -167,6 +284,111 @@ mod tests { assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]); } + #[test] + fn half_floats_round_to_nearest_even() { + for (v, h) in [ + (0.0, 0x0000), + (-0.0, 0x8000), + (1.0, 0x3C00), + (0.5, 0x3800), + (-2.0, 0xC000), + (65504.0, 0x7BFF), + (65520.0, 0x7C00), // halfway past the largest half: ties to even = infinity + (1.0e9, 0x7C00), // overflow + (6.103_515_6e-5, 0x0400), // smallest normal + (5.960_464_5e-8, 0x0001), // smallest subnormal, 2^-24 + (2.980_232_2e-8, 0x0000), // 2^-25: halfway to zero, ties to even + (1.0e-10, 0x0000), + (1.000_488_3, 0x3C00), // 1 + 2^-11: halfway, ties to even (down) + (1.001_464_8, 0x3C02), // 1 + 3·2^-11: halfway, ties to even (up) + (f32::INFINITY, 0x7C00), + ] { + assert_eq!(half(v), h, "{v}"); + } + assert_eq!(half(f32::NAN) & 0x7E00, 0x7E00); + } + + fn le32(b: &[u8], at: usize) -> u32 { + u32::from_le_bytes(b[at..at + 4].try_into().unwrap()) + } + + /// Where `needle` (an attribute name, NUL-terminated) starts in the header. + fn find(b: &[u8], needle: &[u8]) -> usize { + b.windows(needle.len()).position(|w| w == needle).unwrap() + } + + #[test] + fn exr_layout() { + // 2×1: opaque white, then (255, 0, 0) at alpha 0.5 + let px = [255, 255, 255, 255, 255, 0, 0, 128]; + for alpha in [false, true] { + let f = exr(&px, 2, 1, alpha).unwrap(); + let nch = if alpha { 4 } else { 3 }; + assert_eq!(&f[..8], &[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]); + let ch = find(&f, b"channels\0chlist\0") + 16; + assert_eq!(le32(&f, ch) as usize, nch * 18 + 1); + let names: Vec = (0..nch).map(|i| f[ch + 4 + i * 18]).collect(); + assert_eq!(names, if alpha { b"ABGR".to_vec() } else { b"BGR".to_vec() }); + let dw = find(&f, b"dataWindow\0box2i\0") + 17; + assert_eq!((le32(&f, dw), le32(&f, dw + 4), le32(&f, dw + 8), le32(&f, dw + 12)), (16, 0, 0, 1)); + let cmp = find(&f, b"compression\0compression\0") + 24; + assert_eq!((le32(&f, cmp), f[cmp + 4]), (1, 0)); + // header ends with a NUL; one offset (scan line 0) points at the only chunk + let end = f.len() - (8 + 2 * nch * 2) - 8; + assert_eq!(f[end - 1], 0); + let off = u64::from_le_bytes(f[end..end + 8].try_into().unwrap()) as usize; + assert_eq!(off, end + 8); + assert_eq!((le32(&f, off), le32(&f, off + 4) as usize), (0, 2 * nch * 2)); + let hv = |c: usize, x: usize| u16::from_le_bytes(f[off + 8 + (c * 2 + x) * 2..][..2].try_into().unwrap()); + let (b, g, r) = if alpha { (1, 2, 3) } else { (0, 1, 2) }; + // white is 1.0 everywhere; red is linear 1.0 · alpha (premultiplied) or 1.0 flat + assert_eq!((hv(b, 0), hv(g, 0), hv(r, 0)), (0x3C00, 0x3C00, 0x3C00)); + assert_eq!((hv(b, 1), hv(g, 1)), (0, 0)); + if alpha { + assert_eq!((hv(0, 0), hv(0, 1)), (0x3C00, half(128.0 / 255.0))); + assert_eq!(hv(r, 1), half(128.0 / 255.0)); + } else { + assert_eq!(hv(r, 1), 0x3C00); + } + } + } + + /// ffmpeg (external test oracle) decodes our OpenEXR files to the values we meant: linear + /// light, premultiplied alpha, rows top to bottom. + #[test] + fn exr_decodes_in_ffmpeg() { + let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR stills") else { return }; + let (w, h) = (5_u32, 3_u32); + let px: Vec = (0..w * h).flat_map(|i| [(i * 17) as u8, (255 - i * 13) as u8, (i * 40 % 256) as u8, [255, 128, 0, 64][i as usize % 4]]).collect(); + let dir = filmcraft_testkit::fixtures::workspace_root().join("target").join("export-tests").join(format!("exr-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let lin = filmcraft_color::srgb_u8_to_linear_table(); + for alpha in [false, true] { + let path = dir.join(format!("f{alpha}.exr")); + std::fs::write(&path, exr(&px, w, h, alpha).unwrap()).unwrap(); + let out = std::process::Command::new(&ffmpeg) + .args(["-v", "error", "-i"]) + .arg(&path) + .args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let n = (w * h) as usize; + assert_eq!(out.stdout.len(), n * 4 * 4); + let plane = |p: usize, i: usize| f32::from_le_bytes(out.stdout[(p * n + i) * 4..][..4].try_into().unwrap()); + for (i, &[r, g, b, a]) in px.as_chunks::<4>().0.iter().enumerate() { + let k = if alpha { f32::from(a) / 255.0 } else { 1.0 }; + // gbrap planes: G, B, R, A + let want = [lin[g as usize] * k, lin[b as usize] * k, lin[r as usize] * k, k]; + for (p, want) in want.into_iter().enumerate() { + let got = plane(p, i); + assert!((got - want).abs() <= 1e-3 + want * 1e-3, "alpha {alpha}, pixel {i}, plane {p}: {got} vs {want}"); + } + } + } + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn hostile_sizes_are_errors() { assert!(dpx(&[0; 7], 2, 1).is_err()); @@ -175,5 +397,8 @@ mod tests { assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err()); assert!(rgb(&[0; 3], 1, 1).is_err()); assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err()); + assert!(exr(&[0; 4], u32::MAX, u32::MAX, true).is_err()); + assert!(exr(&[0; 7], 2, 1, false).is_err()); + assert!(exr(&[], 0, 1, false).is_err()); } } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 61ee9dbd..162c958a 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -378,11 +378,11 @@ impl ExportSettings { if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format } } - /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF and - /// Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies. + /// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF, + /// Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ QuickTime movies. pub fn supports_alpha(&self) -> bool { match self.format { - Format::PngSequence | Format::TiffSequence | Format::TgaSequence => true, + Format::PngSequence | Format::TiffSequence | Format::TgaSequence | Format::ExrSequence => true, Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444, _ => false, } @@ -488,6 +488,8 @@ impl ExportSettings { Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps, // 10-bit RGB in one 32-bit word per pixel Format::DpxSequence => px * 4.0 * 8.0 * fps, + // two bytes per half-float channel + Format::ExrSequence => px * if self.keeps_alpha() { 8.0 } else { 6.0 } * 8.0 * fps, Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; @@ -571,6 +573,7 @@ impl ExportSettings { Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()), Format::Mjpeg | Format::JpegSequence => v += &format!(", quality {}", self.quality), Format::DpxSequence => v += ", 10-bit RGB", + Format::ExrSequence => v += ", 16-bit half float, linear", _ => {} } v diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 8cfb3170..5381a194 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -160,6 +160,49 @@ fn jpeg_targa_and_dpx_sequences() { assert!(jpeg_sizes[0] > jpeg_sizes[1], "quality 95 must be larger than quality 20: {jpeg_sizes:?}"); } +/// OpenEXR sequences (#806): numbered like the others, half-float linear light, RGBA with +/// premultiplied alpha when asked; ffprobe / ffmpeg (external test oracles) read every still. +#[test] +fn openexr_sequences() { + let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None); + for alpha in [false, true] { + let dir = Scratch::new(&format!("seq-exr-{alpha}")); + let mut s = ExportSettings { format: Format::ExrSequence, path: dir.path("Shot.exr"), alpha, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3))); + assert_eq!(Format::from_name("OpenEXR"), Some(Format::ExrSequence)); + assert!(s.is_image_sequence() && s.supports_alpha() && s.keeps_alpha() == alpha && !s.has_audio()); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 3); + let want: Vec = (0..3).map(|i| format!("Shot{i:03}.exr")).collect(); + assert_eq!(files_in(&dir.0), want); + let first = dir.0.join(&want[0]); + let bytes = std::fs::read(&first).unwrap(); + assert_eq!(&bytes[..4], &[0x76, 0x2F, 0x31, 0x01]); + let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR sequences") else { continue }; + let out = std::process::Command::new(ffmpeg) + .args(["-v", "error", "-i"]) + .arg(&first) + .args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"]) + .output() + .unwrap(); + assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr)); + let n = 64 * 36; + assert_eq!(out.stdout.len(), n * 16); + let plane = |p: usize| -> Vec { out.stdout[p * n * 4..(p + 1) * n * 4].as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() }; + let (g, b, r, a) = (plane(0), plane(1), plane(2), plane(3)); + for i in 0..n { + assert!(g[i].abs() < 1e-3 && r[i].abs() < 1e-3, "pixel {i}: no red or green"); + if alpha { + // pure blue (linear 1.0) at 50 % opacity, premultiplied + assert!((a[i] - 0.5).abs() < 0.01 && (b[i] - a[i]).abs() < 2e-3, "pixel {i}: {} / {}", b[i], a[i]); + } else { + // flattened over black, then linearised: well below the encoded half level + assert!(a[i] == 1.0 && b[i] > 0.1 && b[i] < 0.5, "pixel {i}: {} / {}", b[i], a[i]); + } + } + } +} + /// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160). #[test] fn image_sequences_keep_alpha_when_asked() { @@ -658,7 +701,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 51d1cd03..e8fd5a32 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3182,6 +3182,9 @@ Numbered JPEG stills (quality 90) Imágenes fijas JPEG numeradas (calidad 90) Numbered Targa stills (24-bit, uncompressed) Imágenes fijas Targa numeradas (24 bits, sin comprimir) Numbered DPX stills (10-bit RGB) Imágenes fijas DPX numeradas (RGB de 10 bits) + OpenEXR OpenEXR + OpenEXR Sequence Secuencia OpenEXR + Numbered OpenEXR stills (half float, linear, uncompressed) Imágenes fijas OpenEXR numeradas (coma flotante de 16 bits, lineal, sin comprimir) Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits) Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 79c7d025..defaea9a 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3171,6 +3171,9 @@ Numbered JPEG stills (quality 90) 連番 JPEG 静止画 (品質 90) Numbered Targa stills (24-bit, uncompressed) 連番 Targa 静止画 (24-bit、非圧縮) Numbered DPX stills (10-bit RGB) 連番 DPX 静止画 (10-bit RGB) + OpenEXR OpenEXR + OpenEXR Sequence OpenEXR 連番画像 + Numbered OpenEXR stills (half float, linear, uncompressed) 連番 OpenEXR 静止画 (16-bit 浮動小数点、リニア、非圧縮) Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画 Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit) Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化 diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 16472ef0..21b84306 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3165,6 +3165,9 @@ Numbered JPEG stills (quality 90) 带编号的 JPEG 静帧(质量 90) Numbered Targa stills (24-bit, uncompressed) 带编号的 Targa 静帧(24 位,未压缩) Numbered DPX stills (10-bit RGB) 带编号的 DPX 静帧(10 位 RGB) + OpenEXR OpenEXR + OpenEXR Sequence OpenEXR 序列 + Numbered OpenEXR stills (half float, linear, uncompressed) 带编号的 OpenEXR 静帧(16 位半精度浮点,线性,未压缩) Numbered PNG stills at the sequence's frame size 带编号的 PNG 静帧(采用序列帧大小) Numbered TIFF stills (RGB, 8-bit) 带编号的 TIFF 静帧(RGB,8 位) Opacity 0 → 100 % over the first second 在最初一秒内不透明度从 0 → 100% diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..1f83f353 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -667,7 +667,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | -| `tga`, `dpx` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268) | numbered stills, as above | +| `tga`, `dpx`, `exr` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268; OpenEXR half-float linear RGB(A), uncompressed scan lines) | numbered stills, as above | | `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) | - **Reproducible.** The same project and settings give the same file on every machine. Frames render diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 58c45cb6..b62d7853 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -55,7 +55,7 @@ only). "Tested" names the evidence. | PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% | | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | -| **OpenEXR** | read + write | no | no | 0% | +| **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% | | **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | | Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | | HEIF / HEIC | read | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index ab25b8af..164ede9b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,10 +187,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, + AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 and AAC-only audio, ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). -- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); OpenEXR + sequences (half float, linear, uncompressed; #806). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing From d4ec47a4a3e6c494bcd6588b055c1bd98c840953 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 09:39:29 +1100 Subject: [PATCH 055/203] Clear leftover Project item selection when making a new bin. New Bin only selected the folder and started rename, so Clear still targeted clips that were selected before. The trash button and Delete then removed those items and left the bin. Clicking a bin already deselects items; creating one now does the same, and Clear drops the rename field so it does not keep editing a bin that is about to go. --- crates/ui-egui/src/lib.rs | 1 + crates/ui-egui/src/panels/project.rs | 9 ++++++++- crates/ui-egui/tests/project_panel_ui.rs | 23 +++++++++++++++++++++++ 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 38ee823c..5f898166 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1411,6 +1411,7 @@ impl FilmcraftApp { } // Clear in the Project panel removes the selected bin when no item is selected (#587). let clear = if self.ui.focused == PanelKind::Project && id == "project.delete" { + self.ui.project_panel.rename = None; crate::panels::project_views::clear_params(&self.session.state.project_selection, self.ui.project_panel.selected_bin) } else { None diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 9aef769e..95932cb1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -625,7 +625,10 @@ fn footer(app: &mut FilmcraftApp, ui: &mut egui::Ui, bar: Rect, v: &View, action actions.push(("file.newBin".into(), json!({"parent": parent, "panel": pre}))); } "project.delete" => match crate::panels::project_views::clear_params(&app.session.state.project_selection, app.ui.project_panel.selected_bin) { - Some(p) => actions.push((id.into(), p)), + Some(p) => { + app.ui.project_panel.rename = None; + actions.push((id.into(), p)); + } None => app.ui.status = tl!("Select items to clear").into(), }, _ => actions.push((id.into(), json!({}))), @@ -732,6 +735,7 @@ pub fn panel_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui) -> bool { actions.push(("projectPanel.rename".into(), json!({}))); } if item(ui, "delete", tl!("Delete"), clear.is_some(), Some("Backspace")) { + app.ui.project_panel.rename = None; actions.push(("project.delete".into(), clear.unwrap_or_default())); } ui.separator(); @@ -979,6 +983,9 @@ pub fn new_bin(app: &mut FilmcraftApp, params: &Value) -> Result app.ui.expanded_bins.push(p); } let panel = params.get("panel").and_then(Value::as_str).map_or(shown.prefix, str::to_string); + // Clicking a bin already clears the item selection so Clear targets the bin (#587). New Bin + // must do the same: leftover clips would otherwise steal the trash button and Delete. + let _ = app.session.execute("project.select", json!({"items": []})); app.ui.project_panel.selected_bin = Some(bin); app.ui.project_panel.rename = Some(Rename { item: None, bin: Some(bin), text, panel }); Ok(r) diff --git a/crates/ui-egui/tests/project_panel_ui.rs b/crates/ui-egui/tests/project_panel_ui.rs index d4ee713e..25b9e294 100644 --- a/crates/ui-egui/tests/project_panel_ui.rs +++ b/crates/ui-egui/tests/project_panel_ui.rs @@ -570,6 +570,29 @@ fn new_bin_asks_for_its_name() { assert!(d.app().ui.project_panel.rename.is_none()); } +#[test] +fn new_bin_then_clear_removes_the_bin() { + // leftover Project item selection must not keep Clear from removing the new bin + let mut d = project_driver(); + d.exec("project.view.set", json!({"view": "list"})); + d.click("project.button.new-item"); + d.wait_for("project.newItem.file.newColorMatte"); + d.click("project.newItem.file.newColorMatte"); + d.click("colorMatte.ok"); + let items0 = d.app().session.project.items.len(); + let bins0 = d.app().session.project.root.children.len(); + assert!(!d.app().session.state.project_selection.is_empty()); + d.click("project.button.file.newBin"); + d.frames(2); + let bin = d.app().ui.project_panel.selected_bin.expect("the new bin is selected"); + assert!(d.app().session.state.project_selection.is_empty(), "New Bin clears leftover item selection"); + d.click("project.button.project.delete"); + d.frames(2); + assert!(d.app().session.project.root.find_bin(filmcraft_project::BinId(bin)).is_none(), "Clear removes the new bin"); + assert_eq!(d.app().session.project.root.children.len(), bins0); + assert_eq!(d.app().session.project.items.len(), items0, "clips already in the project stay"); +} + // ------------------------------------------------------------------------------------ Media Browser fn media_dir(tag: &str) -> std::path::PathBuf { From 16493a7f70984699d563be14cc7547433f376f9d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:47:09 +0200 Subject: [PATCH 056/203] #813: masks round their corners (rounded rectangle masks) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Premiere 26's redesigned mask tools give rectangle masks rounded corners; FilmCraft masks only had sharp corners. Fix: MaskPath::with_rounded_corners replaces each sharp corner between straight edges by a circular fillet (two vertices joined by a cubic arc, handles 4/3·tan(φ/4)·r, shrunk to fit half of each edge; hostile radii and indices are clamped or ignored). masks.add takes `cornerRadius` for a rounded rectangle, and the new masks.roundCorners command rounds an existing mask's corners (or the listed vertices) in one undo step, on every Mask Path keyframe so animated paths keep interpolating. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/masks.rs | 44 +++++++++- crates/engine/src/masks_tests.rs | 63 ++++++++++++++ crates/project/src/mask.rs | 144 ++++++++++++++++++++++++++++++- docs/architecture.md | 3 +- docs/gaps.md | 8 +- 5 files changed, 253 insertions(+), 9 deletions(-) diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index 2a53cfdf..ffa5a5a1 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -38,7 +38,7 @@ pub(crate) fn commands() -> Vec { spec( "masks.add", "Create Mask", - r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?}"#, + r#"{"clip":id?,"effect":index|id?="opacity","shape":"ellipse"|"polygon"|"rectangle"|"bezier","path":[[x,y]…]|{vertices}?,"center":[x,y]?,"size":[w,h]?,"cornerRadius":px?}"#, add, ), spec("masks.remove", "Delete Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?}"#, remove), @@ -64,6 +64,7 @@ pub(crate) fn commands() -> Vec { track, ), spec("masks.toggleVertexSmooth", "Convert Mask Vertex", r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n}"#, toggle_smooth), + spec("masks.roundCorners", "Round Mask Corners", r#"{"clip":id?,"effect":index|id?,"mask":n?,"radius":px,"vertices":[n]?}"#, round_corners), CommandSpec { id: "masks.select", label: "Select Mask", @@ -212,7 +213,12 @@ fn add(s: &mut Session, p: &Value) -> Result { None => match shape.as_str() { "ellipse" => MaskPath::ellipse(center, size * 0.5), "polygon" | "rectangle" | "rect" | "4-point" => { - MaskPath::rect(center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0) + let (x0, y0, x1, y1) = (center.x - size.x / 2.0, center.y - size.y / 2.0, center.x + size.x / 2.0, center.y + size.y / 2.0); + match f64_p(p, "cornerRadius") { + Some(r) if !r.is_finite() || r < 0.0 => return Err(bad("masks.add", "`cornerRadius` is pixels ≥ 0")), + Some(r) => MaskPath::rounded_rect(x0, y0, x1, y1, r), + None => MaskPath::rect(x0, y0, x1, y1), + } } _ => return Err(bad("masks.add", "a bezier mask needs `path` (pen points)")), }, @@ -454,6 +460,40 @@ fn toggle_smooth(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Round the mask's sharp corners (Premiere 26's rounded rectangle masks): every corner between +/// two straight edges (or only the listed `vertices`) becomes a circular fillet of `radius` +/// pixels, shrunk to fit half of each edge. The same corners are rounded in every Mask Path +/// keyframe so an animated path keeps interpolating. Returns how many corners were rounded. +fn round_corners(s: &mut Session, p: &Value) -> Result { + const ID: &str = "masks.roundCorners"; + let radius = f64_p(p, "radius").filter(|r| r.is_finite() && *r > 0.0).ok_or_else(|| bad(ID, "need `radius` (pixels, > 0)"))?; + let only: Option> = match p.get("vertices") { + None | Some(Value::Null) => None, + Some(v) => { + let list = v.as_array().ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?; + let mut out = list + .iter() + .map(|i| i.as_u64().and_then(|i| usize::try_from(i).ok()).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))) + .collect::>>()?; + out.sort_unstable(); + out.dedup(); + Some(out) + } + }; + let rounded = edit_mask(s, p, "Round Mask Corners", move |m, t| { + let mut corners = m.path_at(t).roundable_corners(); + if let Some(only) = &only { + corners.retain(|i| only.binary_search(i).is_ok()); + } + if corners.is_empty() { + return Err(bad(ID, "no sharp corner between straight edges to round")); + } + drop_all(m, t, |path| *path = path.with_rounded_corners(radius, &corners)); + Ok(corners.len()) + })?; + Ok(json!({"corners": rounded})) +} + fn select(s: &mut Session, p: &Value) -> Result { if bool_p(p, "none") == Some(true) || p.is_null() || p.as_object().is_some_and(|o| o.is_empty()) { s.state.selected_mask = None; diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index 549a64fc..1fc069a8 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -317,3 +317,66 @@ fn tracking_runs_in_the_background_and_can_be_cancelled() { assert!(n < 48, "stopped early ({n} keyframes)"); assert!(s.execute("masks.track", json!({"direction": "backward"})).is_err(), "nothing before the first frame"); } + +/// #813: a rectangle mask with rounded corners (created rounded, or rounded afterwards) cuts the +/// clip's corners away while the edges stay where they were; one undo step, every keyframe. +#[test] +fn rounded_rectangle_masks_cut_their_corners() { + let (mut s, clip) = demo(); + // a 960×540 opacity mask in the middle of the 1920×1080 frame, rounded by 200 px + s.execute("masks.add", json!({"clip": clip.0, "shape": "rectangle", "center": [960, 540], "size": [960, 540], "cornerRadius": 200})).unwrap(); + s.execute("masks.set", json!({"feather": 0})).unwrap(); + let list = s.execute("masks.list", json!({})).unwrap(); + assert_eq!(list["masks"][0]["path"]["vertices"].as_array().unwrap().len(), 8); + let img = s.render_program(0.25).unwrap(); + // just inside the rectangle's corner: cut away; middle of an edge and the centre: kept + let corner = px(&img, 0.26, 0.26); + assert!(corner[0] + corner[1] + corner[2] < 1e-4, "rounded corner cut: {corner:?}"); + assert!(px(&img, 0.5, 0.26)[3] > 0.99, "top edge kept"); + assert!(px(&img, 0.5, 0.5)[3] > 0.99, "inside kept"); + + // round an existing 4-point mask: same picture, one undo step back to sharp corners + s.undo(); + s.undo(); + s.execute("masks.add", json!({"clip": clip.0, "shape": "polygon", "center": [960, 540], "size": [960, 540]})).unwrap(); + s.execute("masks.set", json!({"feather": 0})).unwrap(); + let sharp = s.render_program(0.25).unwrap(); + assert!(px(&sharp, 0.26, 0.26)[3] > 0.99, "a sharp corner keeps the clip"); + let r = s.execute("masks.roundCorners", json!({"radius": 200})).unwrap(); + assert_eq!(r["corners"], 4); + assert_eq!(s.render_program(0.25).unwrap().px, img.px, "rounding afterwards = creating rounded"); + // nothing left to round; bad radii are refused without changing the mask + assert!(s.execute("masks.roundCorners", json!({"radius": 50})).is_err()); + for radius in [json!(0), json!(-5), json!("big"), json!(null)] { + assert!(s.execute("masks.roundCorners", json!({"radius": radius})).is_err(), "{radius}"); + } + s.undo(); + assert_eq!(s.render_program(0.25).unwrap().px, sharp.px, "one undo step restores the sharp corners"); +} + +#[test] +fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() { + let (mut s, clip) = demo(); + let it = s.active_sequence().unwrap().find_item(clip).unwrap().1.clone(); + s.set_playhead(it.start); + s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap(); + s.execute("effects.toggleAnimation", json!({"clip": clip.0, "effect": "opacity", "mask": 0, "param": "path"})).unwrap(); + let rate = s.active_sequence().unwrap().settings.frame_rate; + s.set_playhead(it.start + rate.tick_of(10)); + s.execute("masks.translate", json!({"delta": [300, 0]})).unwrap(); + // only vertices 0 and 2 (top-left, bottom-right), out-of-range indices ignored + let r = s.execute("masks.roundCorners", json!({"radius": 30, "vertices": [0, 2, 99]})).unwrap(); + assert_eq!(r["corners"], 2); + let m = &s.active_sequence().unwrap().find_item(clip).unwrap().1.effects.iter().find(|e| e.effect == "opacity").unwrap().masks[0]; + assert_eq!(m.path.keyframes.len(), 2); + for k in &m.path.keyframes { + let filmcraft_project::ParamValue::Path(p) = &k.value else { panic!("path keyframe") }; + assert_eq!(p.len(), 6, "both keyframes rounded the same corners"); + assert_eq!(p.vertices.iter().filter(|v| v.is_corner()).count(), 2, "vertices 1 and 3 stay sharp"); + } + // halfway the path still interpolates (equal vertex counts) + let l = s.execute("masks.list", json!({"time": (it.start + rate.tick_of(5)).0})).unwrap(); + let v = l["masks"][0]["path"]["vertices"].as_array().unwrap(); + assert_eq!(v.len(), 6); + assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]); +} diff --git a/crates/project/src/mask.rs b/crates/project/src/mask.rs index d2266b4d..764a27a5 100644 --- a/crates/project/src/mask.rs +++ b/crates/project/src/mask.rs @@ -1,9 +1,12 @@ -//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon, free-draw Bézier). +//! Effect masks (Premiere's per-effect masks: ellipse, 4-point polygon or rounded rectangle, +//! free-draw Bézier). //! //! Every mask is a closed cubic Bézier path in **clip pixel space** (full-resolution source //! pixels, the space of effect point parameters). An ellipse is four smooth vertices with circular -//! tangents and a polygon is vertices with zero-length tangents, so all three tools produce the same -//! [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply to each. +//! tangents, a polygon is vertices with zero-length tangents and a rounded corner is a pair of +//! vertices joined by a circular-arc cubic ([`MaskPath::with_rounded_corners`]), so all the tools +//! produce the same [`MaskPath`] and the same rasterizer, keyframe interpolation and tracker apply +//! to each. //! //! A mask has a keyframable *Mask Path* (vertex-wise interpolation when the vertex counts match, //! hold otherwise), *Feather* (falloff width in pixels, centred on the edge), *Opacity* (%), @@ -80,6 +83,86 @@ impl MaskPath { Self::polygon(&[Vec2::new(x0, y0), Vec2::new(x1, y0), Vec2::new(x1, y1), Vec2::new(x0, y1)]) } + /// An axis-aligned rectangle with corners rounded by `radius` (Premiere 26's rounded + /// rectangle mask). `radius` ≤ 0 (or not finite) gives the plain 4-point [`MaskPath::rect`]. + pub fn rounded_rect(x0: f64, y0: f64, x1: f64, y1: f64, radius: f64) -> Self { + let r = Self::rect(x0, y0, x1, y1); + if radius.is_finite() && radius > 0.0 { r.with_rounded_corners(radius, &[0, 1, 2, 3]) } else { r } + } + + /// Indices of the sharp corners [`MaskPath::with_rounded_corners`] can round: corner vertices + /// of a closed path whose two segments are straight lines. + pub fn roundable_corners(&self) -> Vec { + let n = self.vertices.len(); + if !self.closed || n < 3 { + return Vec::new(); + } + (0..n) + .filter(|&i| { + let (Some(prev), Some(v), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get(i), self.vertices.get((i + 1) % n)) else { + return false; + }; + v.is_corner() && is_zero(&prev.t_out) && is_zero(&next.t_in) + }) + .collect() + } + + /// The path with each listed corner replaced by a circular fillet of `radius`: two vertices on + /// the adjacent edges joined by a cubic arc (handles `4/3·tan(φ/4)·r` for a turn of φ, so a + /// right angle gets the [`KAPPA`] quarter circle). The fillet is shrunk so it never takes more + /// than half of either edge. Every listed roundable corner ([`MaskPath::roundable_corners`]) + /// becomes two vertices, coincident when its geometry is degenerate (a straight or reversing + /// corner, a zero-length edge), so applying the same list to each keyframe of an animated path + /// keeps the vertex counts equal. Other indices (out of range, curved, repeated) are left alone. + pub fn with_rounded_corners(&self, radius: f64, corners: &[usize]) -> Self { + let n = self.vertices.len(); + let mut round = vec![false; n]; + for &i in corners { + if let Some(r) = round.get_mut(i) { + *r = true; + } + } + let roundable = self.roundable_corners(); + round.iter_mut().enumerate().for_each(|(i, r)| *r &= roundable.binary_search(&i).is_ok()); + let radius = if radius.is_finite() { radius.clamp(0.0, 1e7) } else { 0.0 }; + let mut vertices = Vec::with_capacity(n.saturating_mul(2)); + for (i, v) in self.vertices.iter().enumerate() { + if !round.get(i).copied().unwrap_or(false) { + vertices.push(*v); + continue; + } + let (Some(prev), Some(next)) = (self.vertices.get((i + n - 1) % n), self.vertices.get((i + 1) % n)) else { + vertices.push(*v); + continue; + }; + let (a, b) = (v.p - prev.p, next.p - v.p); + let (la, lb) = (a.length(), b.length()); + let fillet = (la > 1e-9 && lb > 1e-9 && radius > 0.0).then(|| (a * (1.0 / la), b * (1.0 / lb))).and_then(|(u, w)| { + // turn angle φ between the incoming and outgoing directions + let phi = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos(); + if !(1e-6..std::f64::consts::PI - 1e-6).contains(&phi) { + return None; + } + let tan_half = (phi / 2.0).tan(); + let d = (radius * tan_half).min(la / 2.0).min(lb / 2.0); + let r = d / tan_half; + let h = 4.0 / 3.0 * (phi / 4.0).tan() * r; + Some((u, w, d, h)) + }); + match fillet { + Some((u, w, d, h)) => { + vertices.push(MaskVertex { p: v.p - u * d, t_in: Vec2::ZERO, t_out: u * h }); + vertices.push(MaskVertex { p: v.p + w * d, t_in: w * -h, t_out: Vec2::ZERO }); + } + None => { + vertices.push(MaskVertex::corner(v.p)); + vertices.push(MaskVertex::corner(v.p)); + } + } + } + Self { vertices, closed: self.closed } + } + pub fn len(&self) -> usize { self.vertices.len() } @@ -439,4 +522,59 @@ mod tests { let back: Mask = serde_json::from_str(&s).unwrap(); assert_eq!(back, m); } + + #[test] + fn rounded_rect_corners_are_quarter_circles() { + let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 60.0, 20.0); + assert_eq!(p.len(), 8); + // top-left corner: a quarter circle of radius 20 about (20, 20); the edges stay straight + let pts = p.flatten(0.01); + let mut on_arc = 0; + for q in &pts { + let (cx, cy) = (q.x.clamp(20.0, 80.0), q.y.clamp(20.0, 40.0)); + let d = ((q.x - cx).powi(2) + (q.y - cy).powi(2)).sqrt(); + if (q.x - cx).abs() > 1e-9 && (q.y - cy).abs() > 1e-9 { + on_arc += 1; + assert!((d - 20.0).abs() < 0.03, "{q:?} is {d} from its corner centre"); + } + assert!(q.x >= -1e-9 && q.x <= 100.0 + 1e-9 && q.y >= -1e-9 && q.y <= 60.0 + 1e-9, "{q:?} outside the rectangle"); + } + assert!(on_arc >= 8, "the corners are curved: {on_arc} arc points"); + // the sharp corner itself is cut away + assert!(pts.iter().all(|q| q.x + q.y > 5.0), "the corner at (0, 0) is rounded off"); + // radius 0 is the plain rectangle + assert_eq!(MaskPath::rounded_rect(0.0, 0.0, 10.0, 5.0, 0.0), MaskPath::rect(0.0, 0.0, 10.0, 5.0)); + } + + #[test] + fn rounding_clamps_to_half_an_edge_and_ignores_curves_and_hostile_input() { + // a radius larger than the shape gives a stadium: fillets meet at the edges' midpoints + let p = MaskPath::rounded_rect(0.0, 0.0, 100.0, 40.0, 1e9); + assert_eq!(p.len(), 8); + for v in &p.vertices { + assert!(v.p.x.is_finite() && v.p.y.is_finite()); + } + assert!(p.vertices.iter().any(|v| (v.p - Vec2::new(0.0, 20.0)).length() < 1e-9), "{:?}", p.vertices); + // a triangle: the 60° turns get fillets tangent to both edges, within the triangle + let tri = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(100.0, 0.0), Vec2::new(50.0, 86.6)]); + let r = tri.with_rounded_corners(10.0, &tri.roundable_corners()); + assert_eq!(r.len(), 6); + assert!(r.vertices.iter().all(|v| v.p.y >= -1e-9)); + // an ellipse has no sharp corners; NaN / negative radii and bad indices change nothing harmful + let e = MaskPath::ellipse(Vec2::new(50.0, 50.0), Vec2::new(30.0, 20.0)); + assert!(e.roundable_corners().is_empty()); + assert_eq!(e.with_rounded_corners(10.0, &[0, 1, 2, 3]), e); + let sq = MaskPath::rect(0.0, 0.0, 10.0, 10.0); + assert_eq!(sq.with_rounded_corners(5.0, &[usize::MAX, 99]), sq); + for bad in [f64::NAN, f64::INFINITY, -3.0, 0.0] { + let q = sq.with_rounded_corners(bad, &[0]); + assert_eq!(q.len(), 5, "a listed corner always splits in two"); + assert!(q.vertices.iter().all(|v| v.is_corner() && v.p.x.is_finite())); + } + // a collinear vertex splits into two coincident corners, keeping vertex counts stable + let line = MaskPath::polygon(&[Vec2::new(0.0, 0.0), Vec2::new(5.0, 0.0), Vec2::new(10.0, 0.0), Vec2::new(5.0, 5.0)]); + let q = line.with_rounded_corners(2.0, &[1]); + assert_eq!(q.len(), 5); + assert_eq!(q.vertices[1].p, q.vertices[2].p); + } } diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..8b09b678 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -529,7 +529,8 @@ with different vertex counts hold). Feather, Opacity and Expansion are ordinary - **GPU.** `filmcraft-gpu::GpuMask` evaluates the same coverage and mix in WGSL (compute), tested to agree with the CPU within 3·10⁻⁶. Layers with masks are CPU-rendered images in frame plans. - **Editing.** `masks.*` commands (add / remove / set / moveVertex / translate / addVertex / - removeVertex / toggleVertexSmooth / select / list); keyframe commands take `"mask": n`. The + removeVertex / toggleVertexSmooth / roundCorners / select / list; `masks.add` takes a + `cornerRadius` for a rounded rectangle); keyframe commands take `"mask": n`. The Program monitor overlay drags vertices, Bézier handles, the whole mask and the feather / expansion handles; drags merge into one undo step. - **Tracking** (`render::track`, `masks.track`): Shi–Tomasi features inside the mask, pyramidal diff --git a/docs/gaps.md b/docs/gaps.md index aec13ce2..c5c87ce5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -228,11 +228,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G18. Effects depth: approximations and missing behaviour - **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut approximate, Lumetri - HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (rounded corners, - constrained lines). + HDR-aware maths, multicam paging beyond 16 angles, redesigned 26.0 mask tools (constrained + lines; an on-monitor corner-roundness handle). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense coarse-to-fine Lucas–Kanade, CPU; #780); Transform shutter angle motion blur (#415); Ultra Key - Setting applied as a parameter preset (#458). + Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners + with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; + #813). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From 39afd2f6c3c8929af2b00ad3f6798753da06d6a9 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:48:45 +0200 Subject: [PATCH 057/203] #809: own MPEG audio Layer I / II decoder plays MP2 at ffmpeg's default bit rates Problem: symphonia rejects every Layer II frame whose bit rate / mode combination ISO/IEC 11172-3 does not allow (mono above 192 kbit/s, stereo below 64 kbit/s). ffmpeg writes mono MP2 at 384 kbit/s by default, so such tracks played as silence in MPEG-TS / PS, Matroska and raw .mp2 files. Fix: new L0 crate filmcraft-mpa, a clean-room Layer I / II decoder written from ISO/IEC 11172-3 and 13818-3 (all modes incl. joint stereo, MPEG-1/2/2.5 rates, the disallowed combinations decoded with the allocation table their bit rate per channel selects). PacketDecoder::mpeg_audio uses it for layers I and II (packets of several frames too); raw .mp2 files decode with it, resyncing after damage. Oracle tests vs ffmpeg: 73-84 dB SNR on ffmpeg / twolame streams and synthetic Layer I / joint stereo frames; fuzzed damaged frames never panic. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 10 + Cargo.toml | 1 + crates/codecs/Cargo.toml | 1 + crates/codecs/src/audio.rs | 143 +++++++- crates/codecs/tests/mpeg_oracle.rs | 42 +++ crates/mpa/Cargo.toml | 17 + crates/mpa/README.md | 76 ++++ crates/mpa/src/lib.rs | 409 +++++++++++++++++++++ crates/mpa/src/tables.rs | 189 ++++++++++ crates/mpa/src/tests.rs | 151 ++++++++ crates/mpa/tests/oracle.rs | 572 +++++++++++++++++++++++++++++ docs/architecture.md | 3 +- docs/codec-parity.md | 2 +- docs/gaps.md | 9 +- xtask/src/main.rs | 2 + 15 files changed, 1619 insertions(+), 8 deletions(-) create mode 100644 crates/mpa/Cargo.toml create mode 100644 crates/mpa/README.md create mode 100644 crates/mpa/src/lib.rs create mode 100644 crates/mpa/src/tables.rs create mode 100644 crates/mpa/src/tests.rs create mode 100644 crates/mpa/tests/oracle.rs diff --git a/Cargo.lock b/Cargo.lock index 5529201a..f70d5b16 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1819,6 +1819,7 @@ dependencies = [ "filmcraft-isobmff", "filmcraft-matroska", "filmcraft-media", + "filmcraft-mpa", "filmcraft-mpeg2v", "filmcraft-mpegts", "filmcraft-mxf", @@ -2075,6 +2076,15 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "filmcraft-mpa" +version = "0.5.0" +dependencies = [ + "filmcraft-bitstream", + "filmcraft-testkit", + "thiserror 2.0.21", +] + [[package]] name = "filmcraft-mpeg2v" version = "0.5.0" diff --git a/Cargo.toml b/Cargo.toml index 1d306cff..46a03f1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ filmcraft-cfb = { path = "crates/cfb" } filmcraft-mpegts = { path = "crates/mpegts" } filmcraft-mpeg2v = { path = "crates/mpeg2v" } filmcraft-ac3 = { path = "crates/ac3" } +filmcraft-mpa = { path = "crates/mpa" } filmcraft-ogg = { path = "crates/ogg" } filmcraft-h264 = { path = "crates/h264" } filmcraft-hevc = { path = "crates/hevc" } diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c..ffaeabbc 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -20,6 +20,7 @@ filmcraft-mxf = { workspace = true } filmcraft-mpegts = { workspace = true } filmcraft-mpeg2v = { workspace = true } filmcraft-ac3 = { workspace = true } +filmcraft-mpa = { workspace = true } filmcraft-ogg = { workspace = true } filmcraft-bitstream = { workspace = true } filmcraft-h264 = { workspace = true } diff --git a/crates/codecs/src/audio.rs b/crates/codecs/src/audio.rs index 63351636..bc937efa 100644 --- a/crates/codecs/src/audio.rs +++ b/crates/codecs/src/audio.rs @@ -27,6 +27,8 @@ enum Inner { Symphonia(Box), /// Our AC-3 and E-AC-3 decoder (ATSC A/52, Annex E). Ac3(Box), + /// Our MPEG-1/2 audio Layer I and II decoder (ISO/IEC 11172-3, 13818-3). + Mpa(Box), } /// Opus always decodes at 48 kHz (RFC 7845 §5.1: the input rate in the header is informational). @@ -124,11 +126,12 @@ impl PacketDecoder { let order = if dec.head().mapping_family == 1 { vorbis_to_wav_order(channels) } else { None }; Ok(Self { inner: Inner::Opus { dec: Box::new(dec), order }, channels }) } - /// MPEG-1/2 audio layer I, II or III (symphonia). + /// MPEG-1/2 audio layer I, II (our decoder: symphonia rejects the bit rate / mode + /// combinations ISO/IEC 11172-3 does not allow, such as ffmpeg's default 384 kbit/s mono MP2) + /// or III (symphonia). pub fn mpeg_audio(layer: u8, sample_rate: u32) -> Result { let codec = match layer { - 1 => symphonia::core::codecs::CODEC_TYPE_MP1, - 2 => symphonia::core::codecs::CODEC_TYPE_MP2, + 1 | 2 => return Ok(Self { inner: Inner::Mpa(Box::default()), channels: 0 }), _ => CODEC_TYPE_MP3, }; Self::new(codec, sample_rate, None) @@ -214,6 +217,31 @@ impl PacketDecoder { self.channels = out.len(); return Ok(out); } + Inner::Mpa(d) => { + // a packet holds one frame or several; a damaged first frame is an error, damage + // after a good frame ends the packet + let mut out: Vec> = Vec::new(); + let mut p = 0; + while let Some(rest) = data.get(p..).filter(|r| !r.is_empty()) { + let f = match d.decode(rest) { + Ok(f) => f, + Err(e) if out.is_empty() => return Err(CodecError::Decode(e.to_string())), + Err(_) => break, + }; + p += f.header.frame_bytes; + if out.is_empty() { + out = f.channels; + } else if out.len() == f.channels.len() { + for (o, c) in out.iter_mut().zip(f.channels) { + o.extend(c); + } + } else { + break; + } + } + self.channels = out.len(); + return Ok(out); + } }; let pkt = Packet::new_from_slice(0, ts, 0, data); let buf = dec.decode(&pkt).map_err(|e| CodecError::Decode(e.to_string()))?; @@ -242,6 +270,7 @@ impl PacketDecoder { up.reset(); } } + Inner::Mpa(d) => d.reset(), Inner::Opus { dec, .. } => dec.reset(), Inner::Symphonia(d) => d.reset(), Inner::Ac3(d) => d.reset(), @@ -741,6 +770,22 @@ pub struct AudioFileSource { impl AudioFileSource { pub fn decode(name: &str, bytes: Arc<[u8]>) -> Result { let ext = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + if matches!(ext.as_str(), "mp2" | "mp3") + && let Some((layer, rate, samples)) = decode_mpeg_layer12(&bytes) + { + let frames = samples.first().map_or(0, Vec::len); + let info = MediaInfo { + name: name.to_string(), + kind: MediaKind::AudioOnly, + duration: Tick::from_units(frames as i64, rate as i64), + video: None, + audio_streams: vec![AudioStreamInfo { sample_rate: rate, channels: samples.len() as u32, codec: format!("MP{layer}"), bits_per_sample: None }], + container: ext.to_uppercase(), + start_timecode: None, + file_size: Some(bytes.len() as u64), + }; + return Ok(Self { info, rate, samples }); + } let mss = MediaSourceStream::new(Box::new(std::io::Cursor::new(bytes.clone())), Default::default()); let mut hint = Hint::new(); hint.with_extension(&ext); @@ -796,6 +841,49 @@ impl AudioFileSource { } } +/// A raw MPEG audio Layer I / II stream (`.mp2`) decoded with our decoder: (layer, sample rate, +/// planar samples). Frames follow each other from the first frame header (after an ID3v2 tag); +/// damaged data is skipped up to the next frame header of the same layer and rate, and a frame +/// with another channel count keeps its duration as silence. `None` when the stream does not start +/// with a Layer I / II frame followed by another frame (or the end), e.g. MP3. +fn decode_mpeg_layer12(bytes: &[u8]) -> Option<(u8, u32, Vec>)> { + let mut p = match bytes { + // ID3v2: 10-byte header, a syncsafe size, and a 10-byte footer when flagged + [b'I', b'D', b'3', _, _, flags, s0, s1, s2, s3, ..] => { + let size = [*s0, *s1, *s2, *s3].iter().fold(0usize, |a, &b| (a << 7) | (b & 0x7F) as usize); + 10 + size + if flags & 0x10 != 0 { 10 } else { 0 } + } + _ => 0, + }; + let frame_at = |p: usize| bytes.get(p..).and_then(|b| filmcraft_mpa::parse_header(b).ok()).filter(|h| h.frame_bytes > 0); + let first = frame_at(p)?; + let next = p.saturating_add(first.frame_bytes); + if first.layer == 3 || (next < bytes.len() && frame_at(next).is_none_or(|h| h.layer != first.layer || h.sample_rate != first.sample_rate)) { + return None; + } + let mut dec = filmcraft_mpa::Decoder::new(); + let mut out: Vec> = vec![Vec::new(); first.channels()]; + while let Some(rest) = bytes.get(p..).filter(|r| r.len() >= 4) { + match dec.decode(rest) { + Ok(f) if f.header.layer == first.layer && f.header.sample_rate == first.sample_rate => { + let n = f.header.samples(); + if f.channels.len() == out.len() { + for (o, c) in out.iter_mut().zip(f.channels) { + o.extend(c); + } + } else { + for o in out.iter_mut() { + o.resize(o.len() + n, 0.0); + } + } + p += f.header.frame_bytes; + } + _ => p += 1, + } + } + out.first().is_some_and(|c| !c.is_empty()).then_some((first.layer, first.sample_rate, out)) +} + impl MediaSource for AudioFileSource { fn info(&self) -> &MediaInfo { &self.info @@ -816,6 +904,55 @@ pub fn opener(name: &str, bytes: Arc<[u8]>) -> Option Vec { + let h = [0xFF, 0xFD, bitrate_index << 4 | 0x04, mode << 6]; + let mut f = h.to_vec(); + f.resize(filmcraft_mpa::parse_header(&h).unwrap().frame_bytes, 0); + f + } + + /// #809: mono MP2 at 384 kbit/s (ffmpeg's default for mono) is outside ISO/IEC 11172-3's + /// allowed combinations, and symphonia rejected every frame, so the track played as silence. + #[test] + fn mono_mp2_at_384k_decodes() { + let mut d = PacketDecoder::mpeg_audio(2, 48_000).unwrap(); + let out = d.decode(&silent_frame(14, 3), 0).unwrap(); + assert_eq!((out.len(), out[0].len(), d.channels), (1, 1152, 1)); + // a packet of two stereo frames decodes to both + let two = [silent_frame(10, 0), silent_frame(10, 0)].concat(); + let out = d.decode(&two, 1152).unwrap(); + assert_eq!((out.len(), out[0].len()), (2, 2304)); + // damage: an error for the first frame, the good frames before it otherwise + assert!(d.decode(&[0u8; 8], 0).is_err()); + assert!(d.decode(&silent_frame(10, 0)[..100], 0).is_err()); + let partial = [silent_frame(10, 0), vec![0xFF, 0xFD, 0xA4]].concat(); + assert_eq!(d.decode(&partial, 0).unwrap()[0].len(), 1152); + d.reset(); + } + + /// Raw `.mp2` files: after an ID3v2 tag, frame after frame, skipping damage. + #[test] + fn raw_streams_resync_after_damage() { + let mono = silent_frame(14, 3); + let file = [&b"ID3\x04\0\0\0\0\0\x05abcde"[..], &mono, &mono, &[0xFF, 0xFD, 0x13, 0x37, 0x42], &mono].concat(); + let (layer, rate, samples) = decode_mpeg_layer12(&file).unwrap(); + assert_eq!((layer, rate, samples.len(), samples[0].len()), (2, 48_000, 1, 3 * 1152)); + let src = AudioFileSource::decode("x.mp2", file.clone().into()).unwrap(); + assert_eq!(src.info().audio().map(|a| (a.channels, a.codec.as_str())), Some((1, "MP2"))); + // MP3, junk and truncated data are not ours (symphonia gets them) + assert!(decode_mpeg_layer12(&[0xFF, 0xFB, 0x90, 0x44, 0, 0]).is_none()); + for n in 0..40 { + assert!(decode_mpeg_layer12(&file[..n]).is_none(), "{n}"); + } + assert!(decode_mpeg_layer12(&[&mono[..], &[0x12; 50]].concat()).is_none()); + } +} + #[cfg(test)] mod he_aac_tests { use super::*; diff --git a/crates/codecs/tests/mpeg_oracle.rs b/crates/codecs/tests/mpeg_oracle.rs index d6a7042a..90cc6bd4 100644 --- a/crates/codecs/tests/mpeg_oracle.rs +++ b/crates/codecs/tests/mpeg_oracle.rs @@ -16,6 +16,9 @@ use filmcraft_media::{FrameRequest, MediaError, MediaSource}; const NOISE: &[&str] = &["-f", "lavfi", "-i", "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t):s=48000"]; +/// A mono tone. +const MONO: &[&str] = &["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000"]; + fn spec(name: &str) -> Vec { let cat = |p: &[&[&str]]| p.concat().into_iter().map(String::from).collect::>(); match name { @@ -142,6 +145,12 @@ fn spec(name: &str) -> Vec { cat(&[&["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25"], NOISE, &["-t", "0.6", "-c:v", "mpeg2video", "-bf", "2", "-g", "6", "-c:a", "mp2"]]) } "audio.mp2" => cat(&[NOISE, &["-t", "1", "-c:a", "mp2", "-b:a", "224k", "-f", "mp2"]]), + // mono MP2 at ffmpeg's default bit rate, 384 kbit/s, which ISO/IEC 11172-3 does not allow + "audio_mono.mp2" => cat(&[MONO, &["-t", "1", "-c:a", "mp2", "-f", "mp2"]]), + "ts_mpeg2_mono_mp2.ts" => { + cat(&[&["-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25"], MONO, &["-t", "1", "-c:v", "mpeg2video", "-c:a", "mp2", "-f", "mpegts"]]) + } + "mkv_mono_mp2.mkv" => cat(&[MONO, &["-t", "1", "-c:a", "mp2"]]), "es_mpeg2.m2v" => { cat(&[&["-f", "lavfi", "-i", "testsrc2=size=352x288:rate=25"], &["-t", "1", "-c:v", "mpeg2video", "-bf", "2", "-g", "9", "-f", "mpeg2video"]]) } @@ -164,6 +173,9 @@ pub const ALL: &[&str] = &[ "mp4_mpeg2.mp4", "mkv_mpeg2.mkv", "audio.mp2", + "audio_mono.mp2", + "ts_mpeg2_mono_mp2.ts", + "mkv_mono_mp2.mkv", "es_mpeg2.m2v", ]; @@ -515,6 +527,36 @@ fn standalone_mp2_file() { assert!(worst <= 2e-4, "{worst}"); } +/// #809: mono MP2 at 384 kbit/s, ffmpeg's default, played as silence in every container because +/// symphonia rejects the bit rate / mode combinations ISO/IEC 11172-3 does not allow. +#[test] +fn mono_mp2_at_ffmpegs_default_bit_rate() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let f = make(&ff, "audio_mono.mp2"); + assert_eq!(std::fs::read(&f).unwrap()[2] >> 4, 14, "384 kbit/s"); + let worst = check_audio(&ff, &make(&ff, "ts_mpeg2_mono_mp2.ts"), 2e-4); + println!("ts_mpeg2_mono_mp2.ts: max error {worst}"); + // Matroska: ffmpeg trims the 481-sample encoder delay; we trim `CodecDelay` rounded to the + // millisecond (#790), so allow a lag of a sample or two there + for (name, max_lag) in [("audio_mono.mp2", 0i64), ("mkv_mono_mp2.mkv", 2)] { + let f = make(&ff, name); + let src = filmcraft_codecs::open_bytes(name, bytes(&f)).unwrap(); + let a = src.info().audio().cloned().unwrap(); + assert_eq!((a.sample_rate, a.channels), (48_000, 1), "{name}"); + let want = ffmpeg_audio_f32(&ff, &f, &[]); + let n = want.len() - 4; + let worst = (-max_lag..=max_lag) + .map(|lag| { + let buf = src.audio(2 + lag, n, 48_000).unwrap(); + want[2..].iter().zip(&buf.channels[0]).map(|(a, b)| (a - b).abs()).fold(0f32, f32::max) + }) + .fold(f32::INFINITY, f32::min); + let peak = want.iter().fold(0f32, |m, x| m.max(x.abs())); + println!("{name}: max error {worst}, peak {peak}"); + assert!(peak > 0.1 && worst <= 2e-4, "{name}: peak {peak}, max error {worst}"); + } +} + #[test] fn truncated_and_corrupt_files_never_panic() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/mpa/Cargo.toml b/crates/mpa/Cargo.toml new file mode 100644 index 00000000..8abf08c2 --- /dev/null +++ b/crates/mpa/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "filmcraft-mpa" +description = "Clean-room MPEG-1/2 Audio Layer I and Layer II (MP1 / MP2) decoder" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true + +[lints] +workspace = true + +[dependencies] +filmcraft-bitstream = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/crates/mpa/README.md b/crates/mpa/README.md new file mode 100644 index 00000000..caa1afde --- /dev/null +++ b/crates/mpa/README.md @@ -0,0 +1,76 @@ +# filmcraft-mpa + +Clean-room MPEG-1 / MPEG-2 Audio Layer I and Layer II decoder (MP1 / MP2) in pure Rust. Layer L0: +depends only on `filmcraft-bitstream` and `thiserror`. Builds for `wasm32-unknown-unknown`; no +`unsafe`. No GPL/LGPL code (FFmpeg, libmad, mpg123, twolame) was consulted; FFmpeg (and twolame, +when installed) are used only as external fixture generators and test oracles. + +## Why + +symphonia (the bootstrap decoder for MP3 and others) rejects every Layer II frame whose bit rate / +channel mode combination ISO/IEC 11172-3 does not allow: mono above 192 kbit/s and stereo below +64 kbit/s. ffmpeg writes such streams by default (mono MP2 is 384 kbit/s unless asked otherwise), +so those tracks played as silence in every container (#809). + +## Specification + +ISO/IEC 11172-3:1993: §2.4.1 bit stream syntax (header, Layer I and Layer II audio data), +§2.4.2 semantics, §2.4.3.1 frame length, §2.4.3.2 synthesis subband filter (Figure A.2), +§2.4.3.3 Layer I and §2.4.3.4 Layer II decoding; Annex B: Table 3-B.1 scalefactors (computed as +2^(1 - i/3)), Table 3-B.2a-d Layer II allocation tables, Table 3-B.3 synthesis window (the 257 +coefficients `D[0..=256]`, exact multiples of 2⁻¹⁶, the rest from the prototype's symmetry; +a unit test checks the typed table is the smooth prototype), Table 3-B.4 quantization classes. +ISO/IEC 13818-3: the lower sampling frequencies (16 / 22.05 / 24 kHz), their bit rate tables and +the Layer II allocation table B.1 (its rows confirmed against FFmpeg's decode). + +## API + +```rust +let mut dec = filmcraft_mpa::Decoder::new(); +let h = filmcraft_mpa::parse_header(frame)?; // layer, rate, mode, frame size +let out = dec.decode(frame)?; // h.samples() per channel (384 or 1152) +dec.reset(); // before decoding from another position +``` + +## Decoder + +- Layer I and Layer II; mono, stereo, dual channel and joint (intensity) stereo with every bound; + MPEG-1, MPEG-2 lower sampling frequencies and the unofficial MPEG-2.5; with or without CRC. +- Layer II table selection by sampling frequency and bit rate per channel, with the ranges of + Table 3-B.2 taken as open-ended so the disallowed combinations decode the way encoders write + them (mono 384 kbit/s uses table a at 48 kHz, b at 44.1 / 32 kHz; stereo 32 / 48 kbit/s uses + c, or d at 32 kHz). +- Requantization `(2v + 1 - steps) / steps` (the standard's `C · (s''' + D)` for every class), + grouped 3-, 5- and 9-step codewords, scalefactor selection information. +- Output: planar f32 at full scale ±1. +- Errors (forbidden allocation, reserved header fields, data running past the frame) return + `Err`; garbage never panics (unit fuzz test and damaged-frame oracle test). + +## Accuracy (vs FFmpeg as an external oracle) + +`tests/oracle.rs`, worst channel SNR against FFmpeg's float decoder. FFmpeg's own fixed-point +decoder agrees with its float decoder to ~77 dB (the error sits at the 16-bit LSB); the test +requires 70 dB, and a wrong table entry or misread field costs tens of dB. + +| Stream | SNR | +|---|---| +| Layer II mono 48 kHz, FFmpeg's default 384 kbit/s (not allowed by 11172-3) | 77.1 dB | +| Layer II mono 44.1 kHz 320 kbit/s pink noise (not allowed) | 72.9 dB | +| Layer II stereo 48 kHz 32 kbit/s (table c; not allowed) | 83.2 dB | +| Layer II stereo 48 kHz 192 kbit/s, 44.1 kHz 256 / 96 kbit/s, 32 kHz 128 kbit/s | 83.3-83.6 dB | +| Layer II mono 32 kHz 48 kbit/s (table d) | 76.9 dB | +| Layer II MPEG-2: stereo 24 kHz 160 kbit/s, mono 22.05 kHz 64 kbit/s, stereo 16 kHz 32 kbit/s | 76.9-83.5 dB | +| Layer II joint stereo and dual channel from twolame (when installed) | 83.4-83.6 dB | +| Synthetic valid random frames: Layer I mono / stereo / dual / joint stereo at every bound, MPEG-1 and MPEG-2; Layer II joint stereo at every bound and dual channel with every allocation table | 74.1-80.0 dB | + +No installed encoder writes Layer I, so Layer I is checked on synthetic frames (random +allocations, scalefactors and samples) that FFmpeg decodes as it would any stream. Through +`filmcraft-codecs` (`tests/mpeg_oracle.rs`), mono 384 kbit/s MP2 in MPEG-TS, Matroska and a raw +`.mp2` file is within 3.1·10⁻⁵ of FFmpeg's decode. + +## Gaps + +- Layer III is not decoded here (symphonia does it); `parse_header` reads its headers. +- The free format (bit rate index 0) is refused: its frame length is only known from the next + sync word. +- The CRC is read, not checked; ancillary data is skipped. diff --git a/crates/mpa/src/lib.rs b/crates/mpa/src/lib.rs new file mode 100644 index 00000000..4dd32814 --- /dev/null +++ b/crates/mpa/src/lib.rs @@ -0,0 +1,409 @@ +//! Clean-room MPEG-1 / MPEG-2 Audio Layer I and Layer II decoder (MP1 / MP2), written from +//! ISO/IEC 11172-3 (§2.4 bit stream syntax and decoding, Annex B tables) and ISO/IEC 13818-3 +//! (the lower sampling frequencies 16 / 22.05 / 24 kHz). +//! +//! - Layer I and Layer II; mono, stereo, dual channel and joint (intensity) stereo; every bit rate +//! and sampling frequency of MPEG-1, MPEG-2 and the unofficial MPEG-2.5 (8 / 11.025 / 12 kHz). +//! - Lenient where encoders are: Layer II bit rate / mode combinations that ISO/IEC 11172-3 does not +//! allow (mono above 192 kbit/s, which is ffmpeg's default for mono, and stereo below 64 kbit/s) +//! decode with the allocation table their bit rate per channel selects (see [`Header`]). +//! - Output: planar f32 at full scale ±1, 384 (Layer I) or 1152 (Layer II) samples per channel. +//! +//! Not decoded: Layer III (use an MP3 decoder; [`parse_header`] still reads its headers), the free +//! format (bit rate index 0, whose frame length is only known from the next sync word) and the +//! CRC (read, not checked, like most decoders). + +#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] + +mod tables; + +use filmcraft_bitstream::BitReader; +use tables::AllocTable; + +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum Error { + #[error("not an MPEG audio frame")] + NoSync, + #[error("unsupported MPEG audio: {0}")] + Unsupported(&'static str), + #[error("invalid MPEG audio frame: {0}")] + Invalid(&'static str), +} + +pub type Result = std::result::Result; + +/// MPEG audio version (`ID` bit, and the MPEG-2.5 extension of the sync word). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Version { + /// ISO/IEC 11172-3: 32 / 44.1 / 48 kHz. + Mpeg1, + /// ISO/IEC 13818-3 lower sampling frequencies: 16 / 22.05 / 24 kHz. + Mpeg2, + /// The unofficial extension to 8 / 11.025 / 12 kHz. + Mpeg25, +} + +/// Channel mode (`mode`, §2.4.2.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + Stereo, + /// Intensity stereo above the bound given by `mode_extension` (Layers I and II). + JointStereo, + /// Two independent mono channels. + DualChannel, + Mono, +} + +/// The 32-bit frame header (§2.4.1.3, §2.4.2.3). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Header { + pub version: Version, + /// 1, 2 or 3. + pub layer: u8, + /// A 16-bit CRC follows the header. + pub crc: bool, + /// 0 for the free format. + pub bitrate_kbps: u32, + pub sample_rate: u32, + pub padding: bool, + pub mode: Mode, + pub mode_extension: u8, + /// Bytes in the frame, header included (0 for the free format). + pub frame_bytes: usize, +} + +impl Header { + /// Output channels: 1 for mono, else 2. + pub fn channels(&self) -> usize { + if self.mode == Mode::Mono { 1 } else { 2 } + } + /// Samples per channel the frame decodes to. + pub fn samples(&self) -> usize { + match (self.layer, self.version) { + (1, _) => 384, + (3, Version::Mpeg2 | Version::Mpeg25) => 576, + _ => 1152, + } + } + /// The first subband coded as intensity stereo (`bound`, §2.4.2.3): `mode_extension` 0-3 gives + /// 4, 8, 12 or 16 in joint stereo; otherwise every subband is coded per channel. + fn bound(&self) -> usize { + if self.mode == Mode::JointStereo { 4 * (self.mode_extension as usize + 1) } else { 32 } + } +} + +/// Parse the frame header at the start of `b`. +pub fn parse_header(b: &[u8]) -> Result
{ + let [0xFF, b1, b2, b3, ..] = *b else { + return Err(if b.len() < 4 && b.first().is_none_or(|&x| x == 0xFF) { Error::Invalid("truncated header") } else { Error::NoSync }); + }; + if b1 & 0xE0 != 0xE0 { + return Err(Error::NoSync); + } + let version = match (b1 >> 3) & 3 { + 3 => Version::Mpeg1, + 2 => Version::Mpeg2, + 0 => Version::Mpeg25, + _ => return Err(Error::Invalid("reserved version")), + }; + let layer = match (b1 >> 1) & 3 { + 3 => 1, + 2 => 2, + 1 => 3, + _ => return Err(Error::Invalid("reserved layer")), + }; + let crc = b1 & 1 == 0; + let bitrate_index = (b2 >> 4) as usize; + let rate_index = ((b2 >> 2) & 3) as usize; + let padding = (b2 >> 1) & 1 == 1; + let mode = match b3 >> 6 { + 0 => Mode::Stereo, + 1 => Mode::JointStereo, + 2 => Mode::DualChannel, + _ => Mode::Mono, + }; + let mode_extension = (b3 >> 4) & 3; + let base = *tables::SAMPLE_RATES.get(rate_index).ok_or(Error::Invalid("reserved sampling frequency"))?; + let sample_rate = match version { + Version::Mpeg1 => base, + Version::Mpeg2 => base / 2, + Version::Mpeg25 => base / 4, + }; + let rates = match (version, layer) { + (Version::Mpeg1, 1) => &tables::BITRATES_MPEG1_L1, + (Version::Mpeg1, 2) => &tables::BITRATES_MPEG1_L2, + (Version::Mpeg1, _) => &tables::BITRATES_MPEG1_L3, + (_, 1) => &tables::BITRATES_LSF_L1, + _ => &tables::BITRATES_LSF_L23, + }; + let bitrate_kbps = *rates.get(bitrate_index).ok_or(Error::Invalid("forbidden bit rate index"))?; + // §2.4.3.1: Layer I slots are 4 bytes, 12 slots per 384-sample frame per bit/s over the + // sampling frequency; Layers II / III have 1-byte slots, 144 per 1152 samples (72 for the + // 576-sample Layer III frames of MPEG-2). + let bits_per_second = u64::from(bitrate_kbps) * 1000; + let rate = u64::from(sample_rate); + let pad = u64::from(padding); + let frame_bytes = if bitrate_kbps == 0 { + 0 + } else if layer == 1 { + (12 * bits_per_second / rate + pad) * 4 + } else { + let slots = if layer == 3 && version != Version::Mpeg1 { 72 } else { 144 }; + slots * bits_per_second / rate + pad + }; + Ok(Header { version, layer, crc, bitrate_kbps, sample_rate, padding, mode, mode_extension, frame_bytes: frame_bytes as usize }) +} + +/// One decoded frame. +#[derive(Clone, Debug)] +pub struct Frame { + pub header: Header, + /// Planar samples, [`Header::channels`] × [`Header::samples`]. + pub channels: Vec>, +} + +/// Subband samples of one frame: `[channel][time slot][subband]`, up to 36 time slots. +type Subbands = [[[f32; 32]; 36]; 2]; + +/// A Layer I / Layer II decoder. It keeps the synthesis filter state between frames, so frames +/// must be fed in order; call [`Decoder::reset`] before decoding from another position. +pub struct Decoder { + /// The synthesis filter's `V` vector per channel (§2.4.3.2, Figure A.2). + v: [[f32; 1024]; 2], + sb: Box, +} + +impl Default for Decoder { + fn default() -> Self { + Self::new() + } +} + +impl Decoder { + pub fn new() -> Self { + Self { v: [[0.0; 1024]; 2], sb: Box::new([[[0.0; 32]; 36]; 2]) } + } + + /// Forget the filter history. + pub fn reset(&mut self) { + self.v = [[0.0; 1024]; 2]; + } + + /// Decode the frame at the start of `frame` (which may run on into the next frames; only + /// [`Header::frame_bytes`] are read). + pub fn decode(&mut self, frame: &[u8]) -> Result { + let header = parse_header(frame)?; + if header.layer == 3 { + return Err(Error::Unsupported("Layer III")); + } + if header.frame_bytes == 0 { + return Err(Error::Unsupported("free-format bit rate")); + } + let data = frame.get(..header.frame_bytes).ok_or(Error::Invalid("truncated frame"))?; + let mut r = BitReader::new(data); + r.skip(32 + if header.crc { 16 } else { 0 }).map_err(|_| Error::Invalid("truncated frame"))?; + let nch = header.channels(); + for ch in self.sb.iter_mut() { + for slot in ch.iter_mut() { + *slot = [0.0; 32]; + } + } + let slots = if header.layer == 1 { + layer1(&mut r, &header, &mut self.sb)?; + 12 + } else { + layer2(&mut r, &header, &mut self.sb)?; + 36 + }; + let window = tables::window(); + let matrix = tables::matrix(); + let mut channels = vec![vec![0f32; slots * 32]; nch]; + for (ch, out) in channels.iter_mut().enumerate() { + let (Some(v), Some(sb)) = (self.v.get_mut(ch), self.sb.get(ch)) else { continue }; + for (slot, o) in sb.iter().take(slots).zip(out.as_chunks_mut::<32>().0) { + synthesize(v, matrix, window, slot, o); + } + } + Ok(Frame { header, channels }) + } +} + +fn read(r: &mut BitReader, n: u32) -> Result { + r.read_bits(n).map_err(|_| Error::Invalid("frame data runs past the frame")) +} + +/// Requantize an `steps`-level code `v` (§2.4.3.3.4 / §2.4.3.4.4): the fractional value with +/// the MSB inverted, `C · (s''' + D)`, which for every class is `(2v + 1 - steps) / steps`. +fn requantize(v: u32, steps: u32) -> f32 { + (2.0 * v as f32 + 1.0 - steps as f32) / steps as f32 +} + +/// Layer I audio data (§2.4.1.5): allocation, scalefactors and 12 samples per subband. +fn layer1(r: &mut BitReader, h: &Header, out: &mut Subbands) -> Result<()> { + let nch = h.channels(); + let bound = if nch == 1 { 32 } else { h.bound() }; + let mut alloc = [[0u32; 32]; 2]; + for sb in 0..32 { + if sb < bound { + for ch in 0..nch { + alloc[ch][sb] = read(r, 4)?; + } + } else { + let a = read(r, 4)?; + alloc[0][sb] = a; + alloc[1][sb] = a; + } + } + if alloc.iter().flatten().any(|&a| a == 15) { + return Err(Error::Invalid("forbidden Layer I allocation")); + } + let mut scf = [[0f32; 32]; 2]; + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + scf[ch][sb] = tables::scalefactor(read(r, 6)?); + } + } + } + for s in 0..12 { + for sb in 0..32 { + if sb < bound { + for ch in 0..nch { + let a = alloc[ch][sb]; + if a != 0 { + let nb = a + 1; + out[ch][s][sb] = requantize(read(r, nb)?, (1 << nb) - 1) * scf[ch][sb]; + } + } + } else if alloc[0][sb] != 0 { + let nb = alloc[0][sb] + 1; + let q = requantize(read(r, nb)?, (1 << nb) - 1); + for ch in 0..nch { + out[ch][s][sb] = q * scf[ch][sb]; + } + } + } + } + Ok(()) +} + +/// Read the three samples of one granule in a subband with `steps` quantization steps. +fn granule(r: &mut BitReader, steps: u16) -> Result<[f32; 3]> { + let steps = u32::from(steps); + let mut q = [0u32; 3]; + if let Some(bits) = tables::grouping(steps as u16) { + // three samples in one codeword: c = s0 + steps·s1 + steps²·s2 (§2.4.3.3.4); what is left + // of an invalid codeword beyond steps³ - 1 is ignored + let mut c = read(r, bits)?; + for s in q.iter_mut() { + *s = c % steps; + c /= steps; + } + } else { + let nb = (steps + 1).trailing_zeros(); + for s in q.iter_mut() { + *s = read(r, nb)?.min(steps - 1); + } + } + Ok(q.map(|v| requantize(v, steps))) +} + +/// Layer II audio data (§2.4.1.6): allocation, scalefactor selection, scalefactors and 12 +/// granules of three samples per subband. +fn layer2(r: &mut BitReader, h: &Header, out: &mut Subbands) -> Result<()> { + let nch = h.channels(); + let per_channel = h.bitrate_kbps / nch as u32; + let table = AllocTable::select(h.version != Version::Mpeg1, h.sample_rate, per_channel); + let sblimit = table.sblimit(); + let bound = if nch == 1 { sblimit } else { h.bound().min(sblimit) }; + let mut steps = [[0u16; 32]; 2]; + for sb in 0..sblimit { + let row = table.row(sb); + let nbal = row.len().trailing_zeros(); + let pick = |r: &mut BitReader| -> Result { Ok(row.get(read(r, nbal)? as usize).copied().unwrap_or(0)) }; + if sb < bound { + for ch in 0..nch { + steps[ch][sb] = pick(r)?; + } + } else { + let s = pick(r)?; + steps[0][sb] = s; + steps[1][sb] = s; + } + } + let mut scfsi = [[0u32; 32]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + if steps[ch][sb] != 0 { + scfsi[ch][sb] = read(r, 2)?; + } + } + } + // the scalefactor for each third of the frame (4 granules each) + let mut scf = [[[0f32; 3]; 32]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + if steps[ch][sb] == 0 { + continue; + } + let mut s = || -> Result { Ok(tables::scalefactor(read(r, 6)?)) }; + scf[ch][sb] = match scfsi[ch][sb] { + 0 => [s()?, s()?, s()?], + 1 => { + let (a, b) = (s()?, s()?); + [a, a, b] + } + 2 => { + let a = s()?; + [a, a, a] + } + _ => { + let (a, b) = (s()?, s()?); + [a, b, b] + } + }; + } + } + for gr in 0..12 { + let part = gr / 4; + for sb in 0..sblimit { + if sb < bound { + for ch in 0..nch { + if steps[ch][sb] != 0 { + let q = granule(r, steps[ch][sb])?; + for (k, v) in q.iter().enumerate() { + out[ch][3 * gr + k][sb] = v * scf[ch][sb][part]; + } + } + } + } else if steps[0][sb] != 0 { + let q = granule(r, steps[0][sb])?; + for ch in 0..nch { + for (k, v) in q.iter().enumerate() { + out[ch][3 * gr + k][sb] = v * scf[ch][sb][part]; + } + } + } + } + } + Ok(()) +} + +/// The synthesis subband filter (§2.4.3.2, Figure A.2) for one time slot: 32 subband samples in, +/// 32 PCM samples out. +fn synthesize(v: &mut [f32; 1024], n: &[[f32; 32]; 64], d: &[f32; 512], s: &[f32; 32], out: &mut [f32; 32]) { + v.copy_within(0..960, 64); + for (vi, row) in v.iter_mut().zip(n.iter()) { + *vi = row.iter().zip(s.iter()).map(|(a, b)| a * b).sum(); + } + for (j, o) in out.iter_mut().enumerate() { + let mut acc = 0f32; + for i in 0..8 { + acc += v[i * 128 + j] * d[i * 64 + j] + v[i * 128 + 96 + j] * d[i * 64 + 32 + j]; + } + *o = acc; + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/mpa/src/tables.rs b/crates/mpa/src/tables.rs new file mode 100644 index 00000000..4ca94756 --- /dev/null +++ b/crates/mpa/src/tables.rs @@ -0,0 +1,189 @@ +//! Tables from ISO/IEC 11172-3 Annex B and ISO/IEC 13818-3 Annex B. + +use std::sync::OnceLock; + +/// Bit rates in kbit/s by `bitrate_index` (ISO/IEC 11172-3 §2.4.2.3; ISO/IEC 13818-3 §2.4.2.3 for +/// the lower sampling frequencies). Index 0 is the free format, index 15 is forbidden. +pub(crate) const BITRATES_MPEG1_L1: [u32; 15] = [0, 32, 64, 96, 128, 160, 192, 224, 256, 288, 320, 352, 384, 416, 448]; +pub(crate) const BITRATES_MPEG1_L2: [u32; 15] = [0, 32, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384]; +pub(crate) const BITRATES_MPEG1_L3: [u32; 15] = [0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320]; +pub(crate) const BITRATES_LSF_L1: [u32; 15] = [0, 32, 48, 56, 64, 80, 96, 112, 128, 144, 160, 176, 192, 224, 256]; +pub(crate) const BITRATES_LSF_L23: [u32; 15] = [0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160]; + +/// Sampling frequencies of MPEG-1 by `sampling_frequency` (3 is reserved); MPEG-2 halves them, +/// the unofficial MPEG-2.5 quarters them. +pub(crate) const SAMPLE_RATES: [u32; 3] = [44_100, 48_000, 32_000]; + +/// Coefficients `D[0..=256]` of the synthesis window (Table 3-B.3), in units of 2⁻¹⁶: the table's +/// nine-decimal values are exact multiples of 1/65536. The rest of the window follows from the +/// symmetry of the prototype filter, see [`window`]. +const WINDOW_HALF: [i32; 257] = [ + 0, -1, -1, -1, -1, -1, -1, -2, -2, -2, -2, -3, -3, -4, -4, -5, // + -5, -6, -7, -7, -8, -9, -10, -11, -13, -14, -16, -17, -19, -21, -24, -26, // + -29, -31, -35, -38, -41, -45, -49, -53, -58, -63, -68, -73, -79, -85, -91, -97, // + -104, -111, -117, -125, -132, -139, -147, -154, -161, -169, -176, -183, -190, -196, -202, -208, // + 213, 218, 222, 225, 227, 228, 228, 227, 224, 221, 215, 208, 200, 189, 177, 163, // + 146, 127, 106, 83, 57, 29, -2, -36, -72, -111, -153, -197, -244, -294, -347, -401, // + -459, -519, -581, -645, -711, -779, -848, -919, -991, -1064, -1137, -1210, -1283, -1356, -1428, -1498, // + -1567, -1634, -1698, -1759, -1817, -1870, -1919, -1962, -2001, -2032, -2057, -2075, -2085, -2087, -2080, -2063, // + 2037, 2000, 1952, 1893, 1822, 1739, 1644, 1535, 1414, 1280, 1131, 970, 794, 605, 402, 185, // + -45, -288, -545, -814, -1095, -1388, -1692, -2006, -2330, -2663, -3004, -3351, -3705, -4063, -4425, -4788, // + -5153, -5517, -5879, -6237, -6589, -6935, -7271, -7597, -7910, -8209, -8491, -8755, -8998, -9219, -9416, -9585, // + -9727, -9838, -9916, -9959, -9966, -9935, -9863, -9750, -9592, -9389, -9139, -8840, -8492, -8092, -7640, -7134, // + 6574, 5959, 5288, 4561, 3776, 2935, 2037, 1082, 70, -998, -2122, -3300, -4533, -5818, -7154, -8540, // + -9975, -11455, -12980, -14548, -16155, -17799, -19478, -21189, -22929, -24694, -26482, -28289, -30112, -31947, -33791, -35640, // + -37489, -39336, -41176, -43006, -44821, -46617, -48390, -50137, -51853, -53534, -55178, -56778, -58333, -59838, -61289, -62684, // + -64019, -65290, -66494, -67629, -68692, -69679, -70590, -71420, -72169, -72835, -73415, -73908, -74313, -74630, -74856, -74992, // + 75038, +]; + +/// `D[i]` for `i` in `0..=256`, in units of 2⁻¹⁶. +pub(crate) fn window_half(i: usize) -> Option { + WINDOW_HALF.get(i).copied() +} + +/// The 512 synthesis window coefficients `D[i]` (Table 3-B.3). The table stores the symmetric +/// prototype `h[i] = h[512 - i]` with every other block of 64 negated, so for `i > 256` +/// `D[i] = -D[512 - i]`, except at multiples of 64 where both halves share the block parity. +pub(crate) fn window() -> &'static [f32; 512] { + static W: OnceLock<[f32; 512]> = OnceLock::new(); + W.get_or_init(|| { + let mut w = [0f32; 512]; + for (i, d) in w.iter_mut().enumerate() { + let (j, sign) = if i <= 256 { (i, 1.0) } else { (512 - i, if i % 64 == 0 { 1.0 } else { -1.0 }) }; + *d = sign * window_half(j).unwrap_or(0) as f32 / 65_536.0; + } + w + }) +} + +/// The matrixing coefficients `N[i][k] = cos((16 + i)(2k + 1)π / 64)` of the synthesis subband +/// filter (§2.4.3.2, Figure A.2), `i` in `0..64`, `k` in `0..32`. +pub(crate) fn matrix() -> &'static [[f32; 32]; 64] { + static N: OnceLock<[[f32; 32]; 64]> = OnceLock::new(); + N.get_or_init(|| { + let mut n = [[0f32; 32]; 64]; + for (i, row) in n.iter_mut().enumerate() { + for (k, c) in row.iter_mut().enumerate() { + *c = (((16 + i) * (2 * k + 1)) as f64 * std::f64::consts::PI / 64.0).cos() as f32; + } + } + n + }) +} + +/// Scalefactors (Table 3-B.1): `2^(1 - index / 3)`. Index 63 is not used by encoders; it continues +/// the progression rather than failing the frame. +pub(crate) fn scalefactor(index: u32) -> f32 { + static S: OnceLock<[f32; 64]> = OnceLock::new(); + let s = S.get_or_init(|| { + let mut s = [0f32; 64]; + for (i, v) in s.iter_mut().enumerate() { + *v = 2f64.powf(1.0 - i as f64 / 3.0) as f32; + } + s + }); + s.get(index as usize).copied().unwrap_or(0.0) +} + +// Layer II possible quantizations per subband (Tables 3-B.2a-d; ISO/IEC 13818-3 Table B.1): the +// number of quantization steps for each value of the subband's allocation field, 0 meaning +// nothing is transmitted. A row has 2^nbal entries. + +/// 3-B.2a/b subbands 0-2. +const A_LOW: [u16; 16] = [0, 3, 7, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383, 32767, 65535]; +/// 3-B.2a/b subbands 3-10. +const A_MID: [u16; 16] = [0, 3, 5, 7, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 65535]; +/// 3-B.2a/b subbands 11-22. +const A_HIGH: [u16; 8] = [0, 3, 5, 7, 9, 15, 31, 65535]; +/// 3-B.2a/b subbands 23-29. +const A_TOP: [u16; 4] = [0, 3, 5, 65535]; +/// 3-B.2c/d subbands 0-1. +const C_LOW: [u16; 16] = [0, 3, 5, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383, 32767]; +/// 3-B.2c/d subbands 2-11; 13818-3 B.1 subbands 4-10. +const C_HIGH: [u16; 8] = [0, 3, 5, 9, 15, 31, 63, 127]; +/// 13818-3 B.1 subbands 0-3. +const LSF_LOW: [u16; 16] = [0, 3, 5, 7, 9, 15, 31, 63, 127, 255, 511, 1023, 2047, 4095, 8191, 16383]; +/// 13818-3 B.1 subbands 11-29. +const LSF_TOP: [u16; 4] = [0, 3, 5, 9]; + +/// A Layer II bit allocation table. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum AllocTable { + /// 3-B.2a: 27 subbands. + A, + /// 3-B.2b: 30 subbands. + B, + /// 3-B.2c: 8 subbands. + C, + /// 3-B.2d: 12 subbands. + D, + /// ISO/IEC 13818-3 Table B.1 (lower sampling frequencies): 30 subbands. + Lsf, +} + +impl AllocTable { + /// The table for a Layer II stream (§2.4.3.3.1 and Table 3-B.2): by sampling frequency and bit + /// rate per channel. ISO/IEC 11172-3 lists only the allowed bit rate / mode combinations; + /// encoders also write the others (ffmpeg's default for mono is 384 kbit/s), so the bit rate + /// ranges are taken as open-ended: at least 56 kbit/s per channel at 48 kHz, or 56-80 kbit/s, + /// is table a; 96 kbit/s and up at 44.1 / 32 kHz is table b; 48 kbit/s and below is table c + /// (d at 32 kHz). The free format (`kbps_per_channel` 0) uses a or b like the high rates. + pub(crate) fn select(lsf: bool, sample_rate: u32, kbps_per_channel: u32) -> Self { + if lsf { + return Self::Lsf; + } + let free = kbps_per_channel == 0; + if (sample_rate == 48_000 && (free || kbps_per_channel >= 56)) || (56..=80).contains(&kbps_per_channel) { + Self::A + } else if free || kbps_per_channel >= 96 { + Self::B + } else if sample_rate == 32_000 { + Self::D + } else { + Self::C + } + } + + /// Subbands with an allocation (`sblimit`). + pub(crate) fn sblimit(self) -> usize { + match self { + Self::A => 27, + Self::B | Self::Lsf => 30, + Self::C => 8, + Self::D => 12, + } + } + + /// The quantization steps per allocation value for subband `sb`; its length is 2^nbal. + pub(crate) fn row(self, sb: usize) -> &'static [u16] { + match self { + Self::A | Self::B => match sb { + 0..=2 => &A_LOW, + 3..=10 => &A_MID, + 11..=22 => &A_HIGH, + _ => &A_TOP, + }, + Self::C | Self::D => match sb { + 0..=1 => &C_LOW, + _ => &C_HIGH, + }, + Self::Lsf => match sb { + 0..=3 => &LSF_LOW, + 4..=10 => &C_HIGH, + _ => &LSF_TOP, + }, + } + } +} + +/// How the samples of a quantization class are coded (Table 3-B.4): `Some(bits)` for the grouped +/// classes (three samples in one codeword), `None` for one `log2(steps + 1)`-bit code per sample. +pub(crate) fn grouping(steps: u16) -> Option { + match steps { + 3 => Some(5), + 5 => Some(7), + 9 => Some(10), + _ => None, + } +} diff --git a/crates/mpa/src/tests.rs b/crates/mpa/src/tests.rs new file mode 100644 index 00000000..1f2e20a6 --- /dev/null +++ b/crates/mpa/src/tests.rs @@ -0,0 +1,151 @@ +use super::*; +use tables::window_half; + +/// A frame header: MPEG-1 unless `lsf`, `layer` 1/2, no CRC, `bitrate_index`, `rate_index` +/// (0: 44.1 kHz, 1: 48 kHz, 2: 32 kHz), `mode` 0-3 and `mode_extension`. +fn header(lsf: bool, layer: u8, bitrate_index: u8, rate_index: u8, mode: u8, ext: u8) -> [u8; 4] { + let version = if lsf { 0b10 } else { 0b11 }; + [0xFF, 0xE0 | version << 3 | (4 - layer) << 1 | 1, bitrate_index << 4 | rate_index << 2, mode << 6 | ext << 4] +} + +/// A frame of `header` with all-zero audio data: no subband has an allocation. +fn silent(h: [u8; 4]) -> Vec { + let mut f = h.to_vec(); + f.resize(parse_header(&h).unwrap().frame_bytes, 0); + f +} + +#[test] +fn headers() { + // Layer II 48 kHz stereo 192 kbit/s: 144 * 192000 / 48000 = 576 bytes + let h = parse_header(&header(false, 2, 10, 1, 0, 0)).unwrap(); + assert_eq!((h.version, h.layer, h.bitrate_kbps, h.sample_rate, h.frame_bytes, h.channels(), h.samples()), (Version::Mpeg1, 2, 192, 48_000, 576, 2, 1152)); + // ffmpeg's default for mono: Layer II 384 kbit/s, which ISO/IEC 11172-3 does not allow + let h = parse_header(&header(false, 2, 14, 1, 3, 0)).unwrap(); + assert_eq!((h.bitrate_kbps, h.frame_bytes, h.mode, h.channels()), (384, 1152, Mode::Mono, 1)); + // Layer I 44.1 kHz 384 kbit/s with padding: (12 * 384000 / 44100 + 1) * 4 = 420 + let mut b = header(false, 1, 12, 0, 1, 2); + b[2] |= 2; + let h = parse_header(&b).unwrap(); + assert_eq!((h.layer, h.frame_bytes, h.samples(), h.mode, h.mode_extension, h.bound()), (1, 420, 384, Mode::JointStereo, 2, 12)); + // MPEG-2 Layer II 24 kHz 160 kbit/s: 144 * 160000 / 24000 = 960; Layer I 22.05 kHz 256 kbit/s + let h = parse_header(&header(true, 2, 14, 1, 0, 0)).unwrap(); + assert_eq!((h.version, h.sample_rate, h.frame_bytes), (Version::Mpeg2, 24_000, 960)); + let h = parse_header(&header(true, 1, 14, 0, 0, 0)).unwrap(); + assert_eq!((h.sample_rate, h.bitrate_kbps, h.frame_bytes), (22_050, 256, 4 * (12 * 256_000 / 22_050))); + // MPEG-2.5 and Layer III headers parse too + assert_eq!(parse_header(&[0xFF, 0xE5, 0x10, 0xC0]).unwrap().sample_rate, 11_025); + let h = parse_header(&[0xFF, 0xFB, 0x90, 0x44]).unwrap(); + assert_eq!((h.layer, h.frame_bytes), (3, 417)); +} + +#[test] +fn bad_headers_are_errors() { + assert_eq!(parse_header(&[]), Err(Error::Invalid("truncated header"))); + assert_eq!(parse_header(&[0xFF, 0xFD]), Err(Error::Invalid("truncated header"))); + assert_eq!(parse_header(&[0x00, 0xFD, 0x90, 0x00]), Err(Error::NoSync)); + assert_eq!(parse_header(&[0xFF, 0x1D, 0x90, 0x00]), Err(Error::NoSync)); + assert_eq!(parse_header(&[0xFF, 0xED, 0x90, 0x00]), Err(Error::Invalid("reserved version"))); + assert_eq!(parse_header(&[0xFF, 0xF9, 0x90, 0x00]), Err(Error::Invalid("reserved layer"))); + assert_eq!(parse_header(&[0xFF, 0xFD, 0xF0, 0x00]), Err(Error::Invalid("forbidden bit rate index"))); + assert_eq!(parse_header(&[0xFF, 0xFD, 0x9C, 0x00]), Err(Error::Invalid("reserved sampling frequency"))); + let mut d = Decoder::new(); + assert_eq!(d.decode(&[0xFF, 0xFB, 0x90, 0x44]).unwrap_err(), Error::Unsupported("Layer III")); + assert_eq!(d.decode(&[0xFF, 0xFD, 0x04, 0x00, 0, 0]).unwrap_err(), Error::Unsupported("free-format bit rate")); + assert_eq!(d.decode(&silent(header(false, 2, 10, 1, 0, 0))[..100]).unwrap_err(), Error::Invalid("truncated frame")); +} + +#[test] +fn silent_frames_decode_to_silence() { + let mut d = Decoder::new(); + for (h, ch, n) in [ + (header(false, 2, 14, 1, 3, 0), 1, 1152), + (header(false, 2, 1, 2, 0, 0), 2, 1152), + (header(true, 2, 8, 0, 1, 3), 2, 1152), + (header(false, 1, 6, 0, 2, 0), 2, 384), + (header(false, 1, 4, 1, 3, 0), 1, 384), + ] { + let f = d.decode(&silent(h)).unwrap(); + assert_eq!(f.channels.len(), ch, "{h:?}"); + assert!(f.channels.iter().all(|c| c.len() == n && c.iter().all(|&s| s == 0.0)), "{h:?}"); + } +} + +/// Table 3-B.3 as typed in must be the smooth prototype filter: with every other block of 64 +/// negated back, its third differences stay within the rounding of the 2⁻¹⁶ units (a mistyped +/// coefficient shows up as a spike of hundreds). +#[test] +fn synthesis_window_is_the_smooth_prototype() { + let h: Vec = (0..=256).map(|i| window_half(i).unwrap() as f64 * if (i / 64) % 2 == 1 { -1.0 } else { 1.0 }).collect(); + let d1: Vec = h.windows(2).map(|w| w[1] - w[0]).collect(); + let d2: Vec = d1.windows(2).map(|w| w[1] - w[0]).collect(); + let worst = d2.windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0, f64::max); + assert!(worst <= 8.0, "{worst}"); + assert_eq!(window_half(257), None); + let w = tables::window(); + // symmetry of the full window and the table's endpoints + assert_eq!((w[0], w[256], w[511], w[448]), (0.0, 75_038.0 / 65_536.0, 1.0 / 65_536.0, w[64])); + assert_eq!(w[300], -w[212]); +} + +#[test] +fn layer2_allocation_tables() { + use tables::AllocTable as T; + for t in [T::A, T::B, T::C, T::D, T::Lsf] { + for sb in 0..t.sblimit() { + let row = t.row(sb); + assert!(row.len().is_power_of_two() && row[0] == 0, "{t:?} {sb}"); + } + } + // the allowed combinations (Table 3-B.2) and the ones encoders write anyway + assert_eq!(T::select(false, 48_000, 192), T::A); + assert_eq!(T::select(false, 44_100, 64), T::A); + assert_eq!(T::select(false, 44_100, 96), T::B); + assert_eq!(T::select(false, 32_000, 192), T::B); + assert_eq!(T::select(false, 48_000, 32), T::C); + assert_eq!(T::select(false, 44_100, 48), T::C); + assert_eq!(T::select(false, 32_000, 48), T::D); + assert_eq!(T::select(false, 48_000, 384), T::A); + assert_eq!(T::select(false, 44_100, 384), T::B); + assert_eq!(T::select(false, 32_000, 16), T::D); + assert_eq!(T::select(true, 24_000, 160), T::Lsf); +} + +#[test] +fn requantization_is_symmetric() { + assert_eq!([0, 1, 2].map(|v| requantize(v, 3)), [-2.0 / 3.0, 0.0, 2.0 / 3.0]); + assert_eq!(requantize(4, 9), 0.0); + assert_eq!(requantize(0, 65_535), -65_534.0 / 65_535.0); +} + +/// Random audio data behind valid headers, and damaged headers: errors or noise, never a panic. +#[test] +fn random_frames_never_panic() { + let mut x = 0x1234_5678u32; + let mut rnd = move || { + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + x + }; + let mut d = Decoder::new(); + for k in 0..4000 { + let mut h = header(k % 3 == 0, 1 + (k % 2) as u8, 1 + (rnd() % 14) as u8, (rnd() % 3) as u8, (rnd() % 4) as u8, (rnd() % 4) as u8); + if k % 5 == 0 { + h[1] &= 0xFE; // CRC present + } + if k % 7 == 0 { + h[2] ^= rnd() as u8; + h[3] ^= rnd() as u8; + } + let len = parse_header(&h).map(|p| p.frame_bytes).unwrap_or(64); + let mut f = h.to_vec(); + f.extend((0..len.saturating_sub(4) + (rnd() % 8) as usize).map(|_| rnd() as u8)); + if k % 11 == 0 { + f.truncate(rnd() as usize % f.len()); + } + if let Ok(fr) = d.decode(&f) { + assert!(fr.channels.iter().flatten().all(|s| s.is_finite())); + } + } +} diff --git a/crates/mpa/tests/oracle.rs b/crates/mpa/tests/oracle.rs new file mode 100644 index 00000000..57ad6544 --- /dev/null +++ b/crates/mpa/tests/oracle.rs @@ -0,0 +1,572 @@ +//! Layer I and Layer II against ffmpeg's decoder as the reference. +//! +//! - Encoded streams: ffmpeg's MP2 encoder (mono, stereo, MPEG-1 and MPEG-2 sampling frequencies, +//! every Layer II allocation table, and bit rate / mode combinations ISO/IEC 11172-3 does not +//! allow but ffmpeg writes: its default for mono is 384 kbit/s); twolame, when installed, for +//! joint stereo and dual channel. +//! - Synthetic streams: valid random frames for what no installed encoder writes: Layer I (mono, +//! stereo, dual channel, joint stereo at every bound) and Layer II joint stereo / dual channel +//! with every allocation table. ffmpeg decodes them as it would any stream. + +use std::path::{Path, PathBuf}; +use std::process::{Command, Stdio}; + +use filmcraft_bitstream::BitWriter; +use filmcraft_mpa::{Decoder, Mode, parse_header}; + +/// A stream from an encoder. +struct Encoded { + name: &'static str, + input: &'static str, + /// Encoder arguments after the input (ffmpeg) or flags (twolame). + enc: &'static [&'static str], + rate: u32, + channels: usize, + /// The bit rate the frames must carry (proves which combination is covered). + kbps: u32, + mode: Mode, + twolame: bool, +} + +const TONES: &str = "aevalsrc=exprs=0.3*sin(2*PI*441*t)+0.1*sin(2*PI*3100*t)|0.25*sin(2*PI*660*t)+0.1*sin(2*PI*(500+4000*t)*t)"; + +const ENCODED: &[Encoded] = &[ + // ffmpeg's default for mono: 384 kbit/s, which ISO/IEC 11172-3 does not allow (table a) + Encoded { + name: "mono_48k_default", + input: "sine=frequency=1000:sample_rate=48000", + enc: &[], + rate: 48_000, + channels: 1, + kbps: 384, + mode: Mode::Mono, + twolame: false, + }, + Encoded { + name: "stereo_48k_192k", + input: TONES, + enc: &["-ar", "48000", "-b:a", "192k"], + rate: 48_000, + channels: 2, + kbps: 192, + mode: Mode::Stereo, + twolame: false, + }, + // table b + Encoded { + name: "stereo_44k_256k", + input: TONES, + enc: &["-ar", "44100", "-b:a", "256k"], + rate: 44_100, + channels: 2, + kbps: 256, + mode: Mode::Stereo, + twolame: false, + }, + // table b, mono above 192 kbit/s (not allowed) + Encoded { + name: "mono_44k_320k", + input: "anoisesrc=color=pink:sample_rate=44100:amplitude=0.3:seed=5", + enc: &["-b:a", "320k"], + rate: 44_100, + channels: 1, + kbps: 320, + mode: Mode::Mono, + twolame: false, + }, + // table c, and stereo below 64 kbit/s (not allowed) + Encoded { + name: "stereo_48k_32k", + input: TONES, + enc: &["-ar", "48000", "-b:a", "32k"], + rate: 48_000, + channels: 2, + kbps: 32, + mode: Mode::Stereo, + twolame: false, + }, + Encoded { + name: "stereo_44k_96k", + input: TONES, + enc: &["-ar", "44100", "-b:a", "96k"], + rate: 44_100, + channels: 2, + kbps: 96, + mode: Mode::Stereo, + twolame: false, + }, + // table d + Encoded { + name: "mono_32k_48k", + input: "sine=frequency=440:sample_rate=32000", + enc: &["-b:a", "48k"], + rate: 32_000, + channels: 1, + kbps: 48, + mode: Mode::Mono, + twolame: false, + }, + // table a at 32 kHz + Encoded { + name: "stereo_32k_128k", + input: TONES, + enc: &["-ar", "32000", "-b:a", "128k"], + rate: 32_000, + channels: 2, + kbps: 128, + mode: Mode::Stereo, + twolame: false, + }, + // MPEG-2 lower sampling frequencies (ISO/IEC 13818-3 table) + Encoded { + name: "lsf_stereo_24k_160k", + input: TONES, + enc: &["-ar", "24000", "-b:a", "160k"], + rate: 24_000, + channels: 2, + kbps: 160, + mode: Mode::Stereo, + twolame: false, + }, + Encoded { + name: "lsf_mono_22k_64k", + input: "sine=frequency=700:sample_rate=22050", + enc: &["-b:a", "64k"], + rate: 22_050, + channels: 1, + kbps: 64, + mode: Mode::Mono, + twolame: false, + }, + Encoded { + name: "lsf_stereo_16k_32k", + input: TONES, + enc: &["-ar", "16000", "-b:a", "32k"], + rate: 16_000, + channels: 2, + kbps: 32, + mode: Mode::Stereo, + twolame: false, + }, + // twolame: joint stereo (intensity coding above the bound) and dual channel + Encoded { + name: "twolame_joint_48k_128k", + input: TONES, + enc: &["-b", "128", "-m", "j"], + rate: 48_000, + channels: 2, + kbps: 128, + mode: Mode::JointStereo, + twolame: true, + }, + Encoded { + name: "twolame_joint_44k_64k", + input: TONES, + enc: &["-b", "64", "-m", "j"], + rate: 44_100, + channels: 2, + kbps: 64, + mode: Mode::JointStereo, + twolame: true, + }, + Encoded { + name: "twolame_dual_44k_192k", + input: TONES, + enc: &["-b", "192", "-m", "d"], + rate: 44_100, + channels: 2, + kbps: 192, + mode: Mode::DualChannel, + twolame: true, + }, +]; + +fn tool(name: &str) -> Option { + filmcraft_testkit::oracle::find_tool(name) +} + +fn make_encoded(ff: &Path, s: &Encoded) -> Option { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{}.mp2", s.name)); + if s.twolame { + let twolame = tool("twolame")?; + return filmcraft_testkit::fixtures::generate(&out, |tmp| { + let wav = tmp.with_extension("wav"); + let made = Command::new(ff) + .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", &format!("{}:s={}", s.input, s.rate), "-t", "1", "-c:a", "pcm_s16le"]) + .arg(&wav) + .stdin(Stdio::null()) + .status() + .is_ok_and(|x| x.success()) + && Command::new(twolame).arg("--quiet").args(s.enc).arg(&wav).arg(tmp).stdin(Stdio::null()).status().is_ok_and(|x| x.success()); + let _ = std::fs::remove_file(&wav); + made + }); + } + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ff) + .args(["-hide_banner", "-v", "error", "-y", "-f", "lavfi", "-i", s.input, "-t", "1", "-c:a", "mp2"]) + .args(s.enc) + .args(["-f", "mp2"]) + .arg(tmp) + .stdin(Stdio::null()) + .status() + .is_ok_and(|x| x.success()) + }) +} + +/// The frames of a raw stream (an ID3 tag or garbage before the first frame is not expected). +fn frames(data: &[u8]) -> Vec<&[u8]> { + let mut out = Vec::new(); + let mut p = 0; + while let Some(h) = data.get(p..).and_then(|b| parse_header(b).ok()) { + let Some(f) = data.get(p..p + h.frame_bytes).filter(|_| h.frame_bytes > 0) else { break }; + out.push(f); + p += h.frame_bytes; + } + out +} + +/// ffmpeg's decode of `file`, interleaved f32. +fn ffmpeg_decode(ff: &Path, file: &Path) -> Vec { + let o = Command::new(ff).args(["-v", "error", "-f", "mp3", "-i"]).arg(file).args(["-f", "f32le", "-"]).output().unwrap(); + assert!(o.status.success(), "{}: {}", file.display(), String::from_utf8_lossy(&o.stderr)); + o.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// Decode `file` with our decoder and return the worst channel's SNR against ffmpeg (dB). +fn snr_vs_ffmpeg(ff: &Path, file: &Path, rate: u32, channels: usize) -> f64 { + let data = std::fs::read(file).unwrap(); + let fr = frames(&data); + assert!(fr.len() > 10, "{}: {} frames", file.display(), fr.len()); + let want = ffmpeg_decode(ff, file); + let n = want.len() / channels; + // ffmpeg drops the first frame of a stream whose second frame changes the channel mode + // (twolame starts in joint stereo and switches to stereo): start there too + let first = if first_frame_mode_changes(&data) { 1 } else { 0 }; + let mut dec = Decoder::new(); + let mut ours: Vec> = vec![Vec::new(); channels]; + for f in &fr[first..] { + let d = dec.decode(f).unwrap_or_else(|e| panic!("{}: {e}", file.display())); + assert_eq!((d.header.sample_rate, d.channels.len()), (rate, channels), "{}", file.display()); + for (o, c) in ours.iter_mut().zip(d.channels) { + o.extend(c); + } + } + assert_eq!(n, ours[0].len(), "{}: sample count", file.display()); + let mut worst = f64::INFINITY; + for (c, ours) in ours.iter().enumerate() { + let (mut sig, mut err) = (0f64, 0f64); + for (i, &a) in ours.iter().enumerate() { + let b = want[i * channels + c] as f64; + sig += b * b; + err += (a as f64 - b) * (a as f64 - b); + } + worst = worst.min(10.0 * (sig / err.max(1e-30)).log10()); + } + worst +} + +fn first_frame_mode_changes(data: &[u8]) -> bool { + let f = frames(data); + f.len() > 1 && parse_header(f[0]).unwrap().mode != parse_header(f[1]).unwrap().mode +} + +/// The minimum SNR against ffmpeg's float decoder. ffmpeg's own fixed-point decoder agrees with +/// it to ~77 dB (the error is at the 16-bit LSB, ~-96 dBFS); we measure 73-84 dB. A wrong table +/// entry or a misread field costs tens of dB. +const MIN_SNR: f64 = 70.0; + +#[test] +fn encoded_streams_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for s in ENCODED { + let Some(f) = make_encoded(&ff, s) else { + assert!(s.twolame, "could not generate {}", s.name); + eprintln!("{}: twolame not installed, skipped", s.name); + continue; + }; + let data = std::fs::read(&f).unwrap(); + let h = parse_header(&data).unwrap(); + assert_eq!((h.layer, h.bitrate_kbps, h.mode), (2, s.kbps, s.mode), "{}", s.name); + let snr = snr_vs_ffmpeg(&ff, &f, s.rate, s.channels); + println!("{}: {} Hz, {} kbit/s, {:?}: SNR vs ffmpeg {snr:.1} dB", s.name, s.rate, s.kbps, s.mode); + assert!(snr >= MIN_SNR, "{}: SNR {snr:.1} dB < {MIN_SNR}", s.name); + } +} + +struct Rng(u32); +impl Rng { + fn next(&mut self) -> u32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 17; + self.0 ^= self.0 << 5; + self.0 + } + fn below(&mut self, n: u32) -> u32 { + self.next() % n + } +} + +/// Header bytes (no CRC): `lsf` MPEG-2, `layer` 1/2, the indices, `mode` 0-3, `ext`. +fn header(lsf: bool, layer: u8, bitrate_index: u8, rate_index: u8, mode: u8, ext: u8) -> [u8; 4] { + let version = if lsf { 0b10 } else { 0b11 }; + [0xFF, 0xE0 | version << 3 | (4 - layer) << 1 | 1, bitrate_index << 4 | rate_index << 2, mode << 6 | ext << 4] +} + +/// A valid random Layer I frame: random allocations (as many as the frame holds), scalefactors +/// and samples. +fn random_layer1(rng: &mut Rng, h: [u8; 4]) -> Vec { + let hd = parse_header(&h).unwrap(); + let nch = hd.channels(); + let bound = if hd.mode == Mode::JointStereo { 4 * (hd.mode_extension as usize + 1) } else { 32 }; + let mut alloc = [[0u32; 32]; 2]; + for sb in 0..32 { + for ch in 0..nch { + if (ch == 0 || sb < bound) && rng.below(3) != 0 { + alloc[ch][sb] = 1 + rng.below(14); + } + } + if sb >= bound { + alloc[1][sb] = alloc[0][sb]; + } + } + let bits = |alloc: &[[u32; 32]; 2]| { + let mut n = 32 + 4 * (32 + bound.min(32) * (nch - 1)); + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + n += 6; + if ch == 0 || sb < bound { + n += 12 * (alloc[ch][sb] as usize + 1); + } + } + } + } + n + }; + // drop allocations from the top until the frame holds them + let mut sb = 31; + while bits(&alloc) > hd.frame_bytes * 8 { + alloc[0][sb] = 0; + alloc[1][sb] = 0; + sb -= 1; + } + let mut w = BitWriter::new(); + w.write_bytes(&h); + for sb in 0..32 { + for ch in 0..if sb < bound { nch } else { 1 } { + w.write_bits(alloc[ch][sb], 4); + } + } + let mut scf = || 10 + rng.below(40); + let mut scfs = vec![]; + for sb in 0..32 { + for ch in 0..nch { + if alloc[ch][sb] != 0 { + scfs.push(scf()); + } + } + } + for s in scfs { + w.write_bits(s, 6); + } + for _ in 0..12 { + for sb in 0..32 { + for ch in 0..if sb < bound { nch } else { 1 } { + let nb = alloc[ch][sb] + 1; + if alloc[ch][sb] != 0 { + // any code but the forbidden all-ones + w.write_bits(rng.below((1 << nb) - 1), nb); + } + } + } + } + let mut f = w.finish(); + f.resize(hd.frame_bytes, 0); + f +} + +/// Allocation field widths (nbal) per subband of the Layer II table the test frames use, by the +/// same selection as the decoder; every table's allocation value 1 is the 3-step class. +fn nbal(lsf: bool, rate: u32, kbps_per_channel: u32) -> Vec { + let rep = |v: &[(u32, usize)]| v.iter().flat_map(|&(n, k)| std::iter::repeat_n(n, k)).collect::>(); + if lsf { + rep(&[(4, 4), (3, 7), (2, 19)]) + } else if (rate == 48_000 && kbps_per_channel >= 56) || (56..=80).contains(&kbps_per_channel) { + rep(&[(4, 11), (3, 12), (2, 4)]) + } else if kbps_per_channel >= 96 { + rep(&[(4, 11), (3, 12), (2, 7)]) + } else if rate == 32_000 { + rep(&[(4, 2), (3, 10)]) + } else { + rep(&[(4, 2), (3, 6)]) + } +} + +/// A valid random Layer II frame using only the 3-step class (allocation value 1, grouped). +fn random_layer2(rng: &mut Rng, h: [u8; 4]) -> Vec { + let hd = parse_header(&h).unwrap(); + let nch = hd.channels(); + let nbal = nbal(h[1] & 0x08 == 0, hd.sample_rate, hd.bitrate_kbps / nch as u32); + let sblimit = nbal.len(); + let bound = if hd.mode == Mode::JointStereo { (4 * (hd.mode_extension as usize + 1)).min(sblimit) } else { sblimit }; + let mut alloc = [[false; 32]; 2]; + let mut scfsi = [[0u32; 32]; 2]; + for sb in 0..sblimit { + for ch in 0..nch { + alloc[ch][sb] = if ch == 1 && sb >= bound { alloc[0][sb] } else { rng.below(3) != 0 }; + scfsi[ch][sb] = rng.below(4); + } + } + let scf_bits = |s: u32| { + if s == 0 { + 18 + } else if s == 2 { + 6 + } else { + 12 + } + }; + let bits = |alloc: &[[bool; 32]; 2]| { + let mut n = 32; + for sb in 0..sblimit { + n += nbal[sb] as usize * if sb < bound { nch } else { 1 }; + for ch in 0..nch { + if alloc[ch][sb] { + n += 2 + scf_bits(scfsi[ch][sb]); + if ch == 0 || sb < bound { + n += 12 * 5; + } + } + } + } + n + }; + let mut sb = sblimit - 1; + while bits(&alloc) > hd.frame_bytes * 8 { + alloc[0][sb] = false; + alloc[1][sb] = false; + sb -= 1; + } + let mut w = BitWriter::new(); + w.write_bytes(&h); + for sb in 0..sblimit { + for ch in 0..if sb < bound { nch } else { 1 } { + w.write_bits(alloc[ch][sb] as u32, nbal[sb]); + } + } + for sb in 0..sblimit { + for ch in 0..nch { + if alloc[ch][sb] { + w.write_bits(scfsi[ch][sb], 2); + } + } + } + for sb in 0..sblimit { + for ch in 0..nch { + if alloc[ch][sb] { + for _ in 0..scf_bits(scfsi[ch][sb]) / 6 { + w.write_bits(10 + rng.below(40), 6); + } + } + } + } + for _ in 0..12 { + for sb in 0..sblimit { + for ch in 0..if sb < bound { nch } else { 1 } { + if alloc[ch][sb] { + w.write_bits(rng.below(27), 5); + } + } + } + } + let mut f = w.finish(); + f.resize(hd.frame_bytes, 0); + f +} + +/// (name, layer, MPEG-2, bitrate index, sampling frequency index, mode, mode extension) +const SYNTHETIC: &[(&str, u8, bool, u8, u8, u8, u8)] = &[ + ("l1_mono_44k_192k", 1, false, 6, 0, 3, 0), + ("l1_stereo_48k_384k", 1, false, 12, 1, 0, 0), + ("l1_dual_32k_256k", 1, false, 8, 2, 2, 0), + ("l1_joint4_48k_192k", 1, false, 6, 1, 1, 0), + ("l1_joint8_44k_320k", 1, false, 10, 0, 1, 1), + ("l1_joint12_32k_128k", 1, false, 4, 2, 1, 2), + ("l1_joint16_48k_448k", 1, false, 14, 1, 1, 3), + ("l1_lsf_stereo_24k_128k", 1, true, 8, 1, 0, 0), + ("l1_lsf_joint_16k_64k", 1, true, 4, 2, 1, 1), + ("l2_joint4_48k_192k", 2, false, 10, 1, 1, 0), + ("l2_joint8_44k_256k", 2, false, 12, 0, 1, 1), + ("l2_joint12_48k_64k", 2, false, 4, 1, 1, 2), + ("l2_joint16_32k_96k", 2, false, 6, 2, 1, 3), + ("l2_dual_44k_160k", 2, false, 9, 0, 2, 0), + ("l2_dual_32k_384k", 2, false, 14, 2, 2, 0), + ("l2_lsf_joint_22k_96k", 2, true, 10, 0, 1, 1), + ("l2_lsf_mono_24k_160k", 2, true, 14, 1, 3, 0), +]; + +#[test] +fn synthetic_streams_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (k, &(name, layer, lsf, br, sr, mode, ext)) in SYNTHETIC.iter().enumerate() { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{name}.mp{layer}")); + let f = filmcraft_testkit::fixtures::generate(&out, |tmp| { + let mut rng = Rng(0x9E37_79B9 ^ k as u32); + let h = header(lsf, layer, br, sr, mode, ext); + let data: Vec = (0..40).flat_map(|_| if layer == 1 { random_layer1(&mut rng, h) } else { random_layer2(&mut rng, h) }).collect(); + std::fs::write(tmp, data).is_ok() + }) + .unwrap(); + let h = parse_header(&std::fs::read(&f).unwrap()).unwrap(); + let snr = snr_vs_ffmpeg(&ff, &f, h.sample_rate, h.channels()); + println!("{name}: SNR vs ffmpeg {snr:.1} dB"); + assert!(snr >= MIN_SNR, "{name}: SNR {snr:.1} dB < {MIN_SNR}"); + } +} + +/// Damaged frames (bit flips, truncation, scrambled headers) give errors or noise, never a panic. +#[test] +fn damaged_frames_never_panic() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let mut rng = Rng(0x00C0_FFEE); + let mut streams: Vec> = ENCODED.iter().filter(|s| !s.twolame).take(5).map(|s| std::fs::read(make_encoded(&ff, s).unwrap()).unwrap()).collect(); + let mut synth = Rng(7); + streams.push((0..8).flat_map(|_| random_layer1(&mut synth, header(false, 1, 12, 1, 1, 1))).collect()); + for data in &streams { + let mut dec = Decoder::new(); + for fr in frames(data).into_iter().take(8) { + for k in 0..300 { + let mut m = fr.to_vec(); + match k % 3 { + 0 => { + for _ in 0..1 + rng.below(8) { + let bit = rng.below(m.len() as u32 * 8) as usize; + m[bit / 8] ^= 0x80 >> (bit % 8); + } + } + 1 => m.truncate(rng.below(m.len() as u32) as usize), + _ => { + // keep the sync word; scramble version, layer, rates and mode + m[1] ^= rng.next() as u8 & 0x1F; + m[2] ^= rng.next() as u8; + m[3] ^= rng.next() as u8; + } + } + if let Ok(d) = dec.decode(&m) { + assert!(d.channels.iter().flatten().all(|s| s.is_finite())); + } + } + } + } +} + +#[test] +#[ignore] +fn generate_fixtures() { + let Some(ff) = filmcraft_testkit::ffmpeg_or_skip("mpa fixtures") else { return }; + for s in ENCODED { + let out = filmcraft_testkit::fixtures_dir("mpa").join(format!("{}.mp2", s.name)); + filmcraft_testkit::fixtures::generate_and_report(&format!("mpa/{}", s.name), &[out], || make_encoded(&ff, s)); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..97fc7209 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -29,7 +29,7 @@ Design principles: L2 edit · codecs · interchange · captions · speech · tts · tts-text L1 frame · media · project · audio-dsp · text L0 foundation: time · geom · color · bitstream · testkit (dev-dependency only) - codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · aac · ac3 · opus + codecs/containers: isobmff · matroska · mxf · cfb · mpegts · ogg · h264 · h264enc · hevc · vp9 · av1 · mpeg2v · prores · dnx · apv · aac · ac3 · mpa · opus ``` Crates are named `filmcraft-` (`crates/time` is `filmcraft-time`). The apps are `filmcraft` @@ -56,6 +56,7 @@ and `filmcraft-cli`. | `mpeg2v` | L0 | MPEG-2 video (Main / 4:2:2 profile; frame and field pictures, dual prime) and MPEG-1 video decoder ([README](../crates/mpeg2v/README.md)) | | `aac` | L0 | AAC-LC decoder and encoder | | `ac3` | L0 | AC-3 (ATSC A/52) decoder ([README](../crates/ac3/README.md)) | +| `mpa` | L0 | MPEG-1/2 Audio Layer I and II (MP1 / MP2) decoder ([README](../crates/mpa/README.md)) | | `testkit` | L0 | test-only helpers, used only as a dev-dependency: ffmpeg/ffprobe discovery, fixture dirs, golden images ([testing.md](testing.md)) | | `frame` | L1 | `VideoFrame` (planar YUV / RGBA8 / linear RGBA f32, colour metadata), `AudioBuffer` | | `media` | L1 | `MediaSource` trait, probing/openers, frame cache, generators, stills, image sequences, WAV / Broadcast WAV | diff --git a/docs/codec-parity.md b/docs/codec-parity.md index ba0da95b..ecbf6d72 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -42,7 +42,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | AAC (LC, HE, LATM) | decode + encode | own decoder (LC, HE core), LATM | own LC encoder | loudness oracle vs ffmpeg | 90% | 3–5 h | | AC-3 | decode (licensed) | own decoder (ATSC A/52) | none | conformance | 80% | — | | E-AC-3 (Dolby Digital Plus) | decode | own decoder (ATSC A/52 Annex E, #647): independent substream, 1–6 blocks, spectral extension; **no** adaptive hybrid transform, enhanced coupling or 7.1 dependent substreams (plays the 5.1 core) | none | SNR vs ffmpeg 65–91 dB (raw, MP4, MOV, Matroska, TS) | 75% | 10–20 h | -| MP3 / MP2 | decode + MP3 encode | decode (symphonia, MPL-2.0; own MP2) | **none** | — | 60% | 10–20 h (MP3 encoder) | +| MP3 / MP2 | decode + MP3 encode | MP3: symphonia (MPL-2.0); MP1 / MP2: own decoder (ISO/IEC 11172-3 / 13818-3, #809), also the bit rate / mode combinations ffmpeg writes by default (mono 384 kbit/s) | **none** | MP1 / MP2: SNR vs ffmpeg 73–84 dB | 65% | 10–20 h (MP3 encoder) | | Opus | — (not a Premiere import format) | own decoder, RFC 8251 range-exact | none | conformance vectors | ahead | — | | FLAC, ALAC, Vorbis | partial | decode (symphonia); FLAC in MP4 silent (#603) | none | — | 70% | 3–6 h | | WMA | decode on Windows | none | none | — | 0% | 10–20 h | diff --git a/docs/gaps.md b/docs/gaps.md index ab25b8af..fca996d6 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -156,9 +156,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G10. Container and codec gaps on import - **Have:** E-AC-3 (Dolby Digital Plus) in MP4, MOV, Matroska and MPEG-TS (#647): independent - substream, 1–6 blocks per syncframe, spectral extension. -- **Missing:** E-AC-3 adaptive hybrid transform, enhanced coupling and the extra channels of 7.1 - dependent substreams (played as the 5.1 core), AVI (#598; listed as an extension but no + substream, 1–6 blocks per syncframe, spectral extension. MP1 / MP2 (MPEG audio Layer I / II): + own decoder (#809) in raw files, MPEG-TS / PS and Matroska, including the bit rate / mode + combinations ISO/IEC 11172-3 disallows but ffmpeg writes (its mono default is 384 kbit/s). +- **Missing:** free-format MPEG audio, MP1 in Matroska (`A_MPEG/L1`), E-AC-3 adaptive hybrid + transform, enhanced coupling and the extra channels of 7.1 dependent substreams (played as the + 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1ea276af..ab3955ac 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -39,6 +39,7 @@ const LAYERS: &[(&str, u8)] = &[ ("mpegts", 0), ("mpeg2v", 0), ("ac3", 0), + ("mpa", 0), ("ogg", 0), ("riff", 0), ("h264", 0), @@ -406,6 +407,7 @@ const FIXTURE_GENERATORS: &[(&str, &str)] = &[ ("codecs", "mpeg_oracle"), ("mpeg2v", "oracle"), ("ac3", "oracle"), + ("mpa", "oracle"), ("codecs", "ogg_oracle"), ]; From 5f9908905c530d4cd6e217d230f8583287db5504 Mon Sep 17 00:00:00 2001 From: ReReddo <249398616+ReReddo@users.noreply.github.com> Date: Sun, 11 Oct 2026 01:55:26 +0300 Subject: [PATCH 058/203] monitors: the wheel zooms about the pointer, the middle button pans (#807) Zooming a monitor took the zoom dropdown, which zooms about the middle of the picture, and then scrolling or the Hand tool to bring the part you wanted into view; at Fit the wheel did nothing. Now, on the Program and Source monitors: - The mouse wheel (or a pinch) zooms about the pointer, from Fit too, between the dropdown's 10% and 1600%: the point under the pointer stays under it. It zooms as fast as the Timeline's wheel zoom, through the same wheel_input. - A middle-button drag that starts over the picture pans it, whatever the tool; the Hand tool still pans with the left button. - Fit resets zoom and pan, and each monitor keeps its own, as before. Comparison View, which always fits both frames, ignores the wheel. Tests: view_ui (wheel zoom about the pointer, middle-button pan, the 10% floor, Fit, Source untouched). --- crates/ui-egui/src/panels/monitor.rs | 5 +- crates/ui-egui/src/panels/monitor_view.rs | 39 +++++++++++++-- crates/ui-egui/src/panels/timeline.rs | 8 ++-- crates/ui-egui/tests/view_ui.rs | 58 +++++++++++++++++++++++ docs/monitors.md | 2 +- 5 files changed, 100 insertions(+), 12 deletions(-) diff --git a/crates/ui-egui/src/panels/monitor.rs b/crates/ui-egui/src/panels/monitor.rs index a57d0483..36930535 100644 --- a/crates/ui-egui/src/panels/monitor.rs +++ b/crates/ui-egui/src/panels/monitor.rs @@ -287,8 +287,9 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect, which: Which) // Click/drag in the picture: the Hand tool pans a magnified picture, otherwise focus. let pic_resp = ui.interact(pic, egui::Id::new((prefix, "pic")), if show_picture { Sense::click_and_drag() } else { Sense::hover() }); app.auto.add(&format!("{prefix}.picture"), pic.intersect(video_area), "picture"); - if show_picture { - monitor_view::pan_input(app, ui, which, video_area, pic); + // (Comparison View always fits both frames) + if show_picture && !compare { + monitor_view::zoom_pan_input(app, ui, which, video_area, pic, display_w, ppp); } if which == Which::Program && show_picture { eyedropper(app, ui, &pic_resp, pic, target, rate, time); diff --git a/crates/ui-egui/src/panels/monitor_view.rs b/crates/ui-egui/src/panels/monitor_view.rs index 15e6ab6f..501ead7a 100644 --- a/crates/ui-egui/src/panels/monitor_view.rs +++ b/crates/ui-egui/src/panels/monitor_view.rs @@ -1,7 +1,8 @@ //! Monitor view options (the View menu and the monitors' wrench menus): Playback / Paused //! Resolution, High Quality Playback, Display Mode (Composite Video, Alpha, Red, Green, Blue, //! Multi-Camera, Audio Waveform, Comparison View, Video and Audio Waveform Split), Magnification -//! (Fit, 10%…1600%, scroll or Hand-tool panning), rulers, guides (dragged out of the rulers, Add +//! (Fit, 10%…1600%, the wheel zooming about the pointer, middle-button or Hand-tool panning), +//! rulers, guides (dragged out of the rulers, Add //! Guide…, Clear Guides, Lock Guides, templates saved in the user preferences) and Snap in Program //! Monitor (graphic drags snap to guides, the frame edges and centre). //! @@ -354,14 +355,42 @@ pub fn picture_rect(area: Rect, fw: f32, fh: f32, v: &MonitorView, ppp: f32) -> } } -/// Pan a magnified picture: scroll over it, or drag with the Hand tool. -pub fn pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect) { +/// Zoom and pan the picture `pic` shown in `area`: the wheel (or a pinch) zooms about the pointer, +/// from Fit too, between the zoom menu's 10% and 1600%; a middle-button drag, or a drag with the +/// Hand tool, pans a magnified picture. `fw` is the picture's width in frame pixels. +pub fn zoom_pan_input(app: &mut FilmcraftApp, ui: &mut egui::Ui, w: Which, area: Rect, pic: Rect, fw: f32, ppp: f32) { + let ctx = ui.ctx().clone(); + if ui.rect_contains_pointer(area) + && let Some(p) = ctx.pointer_hover_pos() + { + // as fast as the Timeline's own wheel zoom + let wheel = crate::panels::timeline::wheel_input(&ctx); + let f = if (wheel.pinch - 1.0).abs() > 1e-4 { wheel.pinch } else { (1.0 + wheel.delta.y * 0.01).clamp(0.5, 2.0) }; + if (f - 1.0).abs() > 1e-4 && pic.width() > 0.0 && fw > 0.0 { + let cur = pic.width() * ppp / fw; + let z = (cur * f).clamp(ZOOMS[0].1, ZOOMS[ZOOMS.len() - 1].1); + let k = z / cur; + // the point of the picture under the pointer stays under it + let centre = p - (p - pic.center()) * k; + let max = ((pic.size() * k - area.size()) / 2.0).max(Vec2::ZERO); + let v = view_mut(app, w); + v.zoom = Some(z); + v.pan = [(centre.x - area.center().x).clamp(-max.x, max.x), (centre.y - area.center().y).clamp(-max.y, max.y)]; + return; + } + } if view(app, w).zoom.is_none() { return; } let mut d = Vec2::ZERO; - if ui.rect_contains_pointer(area) { - d += ui.input(|i| i.smooth_scroll_delta); + // a middle-button drag that began over the picture pans it, whatever the tool + let held = egui::Id::new((prefix(w), "middle-pan")); + let (pressed, down, moved) = ui.input(|i| (i.pointer.button_pressed(egui::PointerButton::Middle), i.pointer.middle_down(), i.pointer.delta())); + let panning = down && (ui.data(|m| m.get_temp::(held)).unwrap_or(false) || (pressed && ui.rect_contains_pointer(area))); + ui.data_mut(|m| m.insert_temp(held, panning)); + if panning { + ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + d += moved; } if app.ui.tool == Tool::Hand { let resp = ui.interact(area, egui::Id::new((prefix(w), "pan")), Sense::drag()); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ca4fab0d..70cde2b6 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1902,11 +1902,11 @@ fn multicam_menu(app: &mut FilmcraftApp, ui: &mut egui::Ui, picked: &[&TrackItem } /// What the wheel and the trackpad did this frame. -struct WheelInput { +pub(crate) struct WheelInput { /// Scroll in points (wheel lines and pages converted), positive = content moves right / down. - delta: egui::Vec2, + pub(crate) delta: egui::Vec2, /// Pinch factor (1 = none). - pinch: f32, + pub(crate) pinch: f32, command: bool, alt: bool, shift: bool, @@ -1915,7 +1915,7 @@ struct WheelInput { /// The wheel events of this frame, read from the events themselves: egui turns Cmd + wheel into a /// zoom and Shift + wheel into a sideways scroll before anyone asks, and the Timeline has its own /// meaning for both. The modifiers are those of the events (a script's wheel carries its own). -fn wheel_input(ctx: &egui::Context) -> WheelInput { +pub(crate) fn wheel_input(ctx: &egui::Context) -> WheelInput { ctx.input(|i| { let mut w = WheelInput { delta: egui::Vec2::ZERO, pinch: 1.0, command: false, alt: false, shift: false }; for e in &i.events { diff --git a/crates/ui-egui/tests/view_ui.rs b/crates/ui-egui/tests/view_ui.rs index 1339ad6c..92b34a1f 100644 --- a/crates/ui-egui/tests/view_ui.rs +++ b/crates/ui-egui/tests/view_ui.rs @@ -183,6 +183,64 @@ fn view_menu_resolutions_and_display_modes() { assert_eq!((s["display"].as_str(), s["show_rulers"].as_bool()), (Some("Composite"), Some(true))); } +fn num(v: &Value) -> f64 { + v.as_f64().unwrap_or_else(|| panic!("not a number: {v}")) +} + +/// #807: the wheel over a monitor zooms about the pointer, a middle-button drag pans, Fit resets, +/// and each monitor keeps its own view. +#[test] +fn wheel_zooms_about_the_pointer_and_the_middle_button_pans() { + let mut d = Driver::demo(); + // at 100% the picture is larger than the monitor, so the element is the monitor's area + d.menu("view.magnification.100"); + let area = d.rect("program.picture"); + let centre = (area[0] + area[2] / 2.0, area[1] + area[3] / 2.0); + let at = (area[0] + area[2] * 0.25, area[1] + area[3] * 0.4); + // 50 points up: 1.5 times; the point under the pointer stays under it + d.ok("ui.scroll", json!({"x": at.0, "y": at.1, "dx": 0.0, "dy": 50.0})); + d.frames(2); + let p = d.program(); + assert!((num(&p["zoom"]) - 1.5).abs() < 1e-4, "{p}"); + let want = ((centre.0 - at.0) * 0.5, (centre.1 - at.1) * 0.5); + assert!((num(&p["pan"][0]) - want.0).abs() < 1.0 && (num(&p["pan"][1]) - want.1).abs() < 1.0, "pan {} for {want:?}", p["pan"]); + + // the middle button drags the picture, whatever the tool + let (zoom, pan) = (num(&p["zoom"]), [num(&p["pan"][0]), num(&p["pan"][1])]); + let send = |d: &mut Driver, e: egui::Event| { + d.harness.input_mut().events.push(e); + d.frames(1); + }; + let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Middle, pressed, modifiers: Default::default() }; + let from = egui::pos2(centre.0 as f32, centre.1 as f32); + send(&mut d, egui::Event::PointerMoved(from)); + send(&mut d, button(from, true)); + for k in 1..=4 { + send(&mut d, egui::Event::PointerMoved(from + egui::vec2(-10.0 * k as f32, 5.0 * k as f32))); + } + send(&mut d, button(from + egui::vec2(-40.0, 20.0), false)); + let p = d.program(); + assert_eq!(num(&p["zoom"]), zoom, "a pan does not zoom"); + assert!((num(&p["pan"][0]) - (pan[0] - 40.0)).abs() < 1.0 && (num(&p["pan"][1]) - (pan[1] + 20.0)).abs() < 1.0, "pan {} after {pan:?}", p["pan"]); + + // zooming out stops at the menu's smallest level; Fit resets zoom and pan + for _ in 0..12 { + d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": -50.0})); + } + d.frames(2); + assert!((num(&d.program()["zoom"]) - 0.1).abs() < 1e-6, "stops at 10%"); + d.menu("view.magnification.fit"); + let p = d.program(); + assert!(p["zoom"].is_null() && p["pan"] == json!([0.0, 0.0]), "{p}"); + + // from Fit, the wheel zooms in too; the Source monitor keeps its own view + d.ok("ui.scroll", json!({"x": centre.0, "y": centre.1, "dx": 0.0, "dy": 20.0})); + d.frames(2); + assert!(d.program()["zoom"].as_f64().is_some(), "zoomed in from Fit"); + let source = d.ok("ui.inspect", json!({}))["ui"]["source"].clone(); + assert!(source["zoom"].is_null() && source["pan"] == json!([0.0, 0.0]), "{source}"); +} + #[test] fn magnification_pans_and_guides_from_rulers() { let mut d = Driver::demo(); diff --git a/docs/monitors.md b/docs/monitors.md index 3610c9ea..f8cdd36d 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -15,7 +15,7 @@ focused monitor (Program by default). Code: `crates/ui-egui/src/panels/monitor_v | `view.display.multicam` | Display Mode ▸ Multi-Camera | the Program's multi-camera view (`multicam.toggleView`) | | `view.display.audioWaveform`, `view.display.videoAndWaveform` | Display Mode ▸ Audio Waveform / Video and Audio Waveform Split | Source Monitor: the clip's waveform (click to move the source playhead); audio-only clips always show it | | `view.display.comparison` | Display Mode ▸ Comparison View | Program: the reference frame (left) beside the current frame; the reference starts at the playhead and is stepped or reset with the buttons under it, or `view.compare.setReference {time\|seconds}` | -| `view.magnification.` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel; scroll or drag with the Hand tool to pan | +| `view.magnification.` | View ▸ Magnification and the zoom dropdown | 100% = one frame pixel per screen pixel. The mouse wheel (or a pinch) over a monitor zooms about the pointer, from Fit too, within 10%–1600%; drag with the middle mouse button, or with the Hand tool, to pan; Fit resets both. Each monitor keeps its own zoom and pan | | `view.showRulers`, `view.showGuides`, `view.lockGuides`, `view.clearGuides` | View | toggles take `{"enabled": bool}` | | `view.addGuide` | View ▸ Add Guide… | dialog, or `{"orientation": "vertical\|horizontal", "position": px}` | | `view.snapInProgramMonitor` | View ▸ Snap in Program Monitor | graphic moves snap to the frame edges / centre and the guides (6 pt) | From b9f60842bbd49a9a247ae389cb170d8e32edf8ab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:56:35 +0200 Subject: [PATCH 059/203] #814: AAC Audio export (audio-only .m4a, or ADTS .aac) Problem: Premiere's "AAC Audio" format has no FilmCraft equivalent. AAC was only written inside H.264 / H.265 / AV1 movies; the only audio-only exports were Waveform Audio and AIFF. Fix: a new `aac` export format ("AAC Audio") that encodes the sequence mix with the existing AAC-LC encoder into an MPEG-4 audio file (.m4a, encoder delay in an edit list), or a raw ADTS stream when the path ends in .aac. Bitrate, sample rate, channels and loudness normalisation follow the audio settings. Adds an "AAC Audio 48 kHz 320 kbps" Audio Only preset, CLI extension guessing (.m4a / .aac), es/fr/ja/zh-cn strings, and updates G13 (also noting ProRes 4444 / XQ is already in the export UI). Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/main.rs | 3 +- crates/engine/src/commands.rs | 2 +- crates/engine/src/export_tests.rs | 29 ++++ crates/engine/src/export_tools.rs | 3 +- crates/export/src/aac_out.rs | 201 ++++++++++++++++++++++++++++ crates/export/src/lib.rs | 17 ++- crates/export/src/presets.rs | 10 ++ crates/export/src/settings.rs | 10 +- crates/export/src/settings_tests.rs | 99 +++++++++++++- crates/ui-egui/src/i18n/es.tsv | 3 + crates/ui-egui/src/i18n/fr.tsv | 3 + crates/ui-egui/src/i18n/ja.tsv | 3 + crates/ui-egui/src/i18n/zh-cn.tsv | 3 + docs/architecture.md | 1 + docs/file-format-parity.md | 2 +- docs/gaps.md | 6 +- 16 files changed, 382 insertions(+), 13 deletions(-) create mode 100644 crates/export/src/aac_out.rs diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 9ead306d..10592444 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -79,7 +79,7 @@ SUBCOMMANDS [--scale f] [--quality 0-100] [--no-audio] [--queue] export the active sequence and wait for it to finish: with an export preset (`export --list-presets`; built-in or the user's), or - a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff, guessed + a format (h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff|aac, guessed from the extension); --range entire|inOut|workArea, or a custom range in seconds; --settings is ExportSettings JSON merged over the preset; --scale renders at a fraction of the frame size (0.5 = @@ -209,6 +209,7 @@ fn format_for(path: &str) -> Option<&'static str> { "tif" | "tiff" => "tiff", "bmp" => "bmp", "aif" | "aiff" => "aiff", + "m4a" | "aac" => "aac", _ => return None, }) } diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index e3b162fd..c31894f3 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -1285,7 +1285,7 @@ fn build() -> Vec { "Media…", ["File", "Export"], None, - r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, + r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff|aac"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#, has_seq, crate::export_tools::export_media ), diff --git a/crates/engine/src/export_tests.rs b/crates/engine/src/export_tests.rs index 6a8c5d71..82245e97 100644 --- a/crates/engine/src/export_tests.rs +++ b/crates/engine/src/export_tests.rs @@ -177,6 +177,16 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.01, "{name}: {}", j["format"]["duration"]); continue; } + "aac" => { + // AAC Audio: an MPEG-4 audio file, sound only + assert!(out.ends_with(".m4a"), "{name}: {out}"); + assert!(fmt_name.contains("m4a"), "{name}: {fmt_name}"); + assert_eq!(j["streams"].as_array().unwrap().len(), 1, "{name}"); + let a = stream(&j, "audio").unwrap(); + assert_eq!((a["codec_name"].as_str(), a["sample_rate"].as_str(), a["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2)), "{name}"); + assert!((num(&j["format"]["duration"]) - 0.25).abs() < 0.03, "{name}: {}", j["format"]["duration"]); + continue; + } "gif" => { let v = stream(&j, "video").unwrap(); assert_eq!(v["codec_name"], "gif"); @@ -281,6 +291,25 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() { /// H.264 bitrate control over three seconds of the demo: the adaptive Match Source preset /// targets width × height × fps × bpp, the 1080p delivery presets their fixed targets; ffprobe's /// measured video bitrate stays within the VBR maximum and close to the target. +#[test] +fn aac_audio_keeps_a_dot_aac_path_as_adts_and_adds_m4a_otherwise() { + let mut s = demo(); + let dir = Scratch::new("aac-audio"); + let range = json!({"range": "custom", "startSeconds": 1.0, "endSeconds": 1.25, "wait": true, "format": "aac"}); + let run = |s: &mut Session, path: String| { + let mut p = range.clone(); + p["path"] = json!(path); + s.execute("file.exportMedia", p).unwrap()["path"].as_str().unwrap().to_string() + }; + let adts = run(&mut s, dir.path("Mix.aac")); + assert!(adts.ends_with("Mix.aac"), "{adts}"); + let b = std::fs::read(&adts).unwrap(); + assert_eq!((b[0], b[1]), (0xFF, 0xF1), "an ADTS header"); + let m4a = run(&mut s, dir.path("Mix")); + assert!(m4a.ends_with("Mix.m4a"), "{m4a}"); + assert_eq!(&std::fs::read(&m4a).unwrap()[4..8], b"ftyp"); +} + #[test] fn h264_presets_hit_their_bitrates() { let mut s = demo(); diff --git a/crates/engine/src/export_tools.rs b/crates/engine/src/export_tools.rs index 9e37a780..b714e284 100644 --- a/crates/engine/src/export_tools.rs +++ b/crates/engine/src/export_tools.rs @@ -363,7 +363,8 @@ fn with_extension(path: &str, settings: &ExportSettings) -> String { Some(e) if e.eq_ignore_ascii_case(ext) || (settings.format == Format::TiffSequence && e.eq_ignore_ascii_case("tiff")) - || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) => + || (settings.format == Format::JpegSequence && e.eq_ignore_ascii_case("jpeg")) + || (settings.format == Format::Aac && e.eq_ignore_ascii_case("aac")) => { path.to_string() } diff --git a/crates/export/src/aac_out.rs b/crates/export/src/aac_out.rs new file mode 100644 index 00000000..0e18b1fd --- /dev/null +++ b/crates/export/src/aac_out.rs @@ -0,0 +1,201 @@ +//! AAC Audio: the sequence mix alone, AAC-LC from the same encoder as the MPEG-4 movies, in an +//! MPEG-4 audio file (`.m4a`, the encoder delay signalled by an edit list) or, when the path ends +//! in `.aac`, a raw ADTS stream (ISO/IEC 14496-3 §1.A.2; one header per access unit, no CRC). ADTS +//! cannot signal the encoder delay, so a player starts it with the priming samples. + +use std::io::Write; +use std::sync::Arc; +use std::sync::atomic::Ordering; + +use filmcraft_isobmff::{Brand, CodecConfig, Mp4Writer, TrackConfig, WriteSample, WriterOptions}; +use filmcraft_project::{ItemId, Project}; +use filmcraft_render::SourceProvider; +use filmcraft_time::TimeRange; + +use crate::audio_out::AudioOut; +use crate::{AudioEncoder, ExportError, ExportSettings, Format, Out, Progress, Result, audio_factories}; + +/// Whether `path` asks for a raw ADTS stream rather than an MPEG-4 file. +pub(crate) fn is_adts_path(path: &str) -> bool { + std::path::Path::new(path).extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("aac")) +} + +/// Where the encoded access units go. +enum Sink { + Mp4 { mux: Mp4Writer, track: usize, frame: u32 }, + Adts { out: Out, header: AdtsHeader }, +} + +impl Sink { + fn write(&mut self, units: Vec>) -> Result<()> { + let io = |e: std::io::Error| ExportError::Io(e.to_string()); + match self { + Sink::Mp4 { mux, track, frame } => { + for au in units { + mux.write_sample(*track, WriteSample { data: &au, duration: *frame, composition_offset: 0, is_sync: true }) + .map_err(|e| ExportError::Io(e.to_string()))?; + } + } + Sink::Adts { out, header } => { + for au in units { + out.write_all(&header.bytes(au.len())?).map_err(io)?; + out.write_all(&au).map_err(io)?; + } + } + } + Ok(()) + } + + fn finish(self, settings: &ExportSettings) -> Result { + match self { + Sink::Mp4 { mux, .. } => mux.finish().map_err(|e| ExportError::Io(e.to_string()))?.finish(settings), + Sink::Adts { out, .. } => out.finish(settings), + } + } +} + +/// The fixed fields of an ADTS header, from the AudioSpecificConfig. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct AdtsHeader { + /// `profile_ObjectType`: the audio object type minus one (1 = AAC-LC). + profile: u8, + sf_index: u8, + channel_config: u8, +} + +impl AdtsHeader { + /// Read the object type, sampling frequency index and channel configuration from an + /// AudioSpecificConfig; ADTS can carry only object types 1–4 with an indexed rate and a + /// channel configuration of 1–7. + pub(crate) fn from_asc(asc: &[u8]) -> Result { + let (Some(&a), Some(&b)) = (asc.first(), asc.get(1)) else { + return Err(ExportError::Encode("AAC: the encoder gave no AudioSpecificConfig".into())); + }; + let object_type = a >> 3; + let sf_index = ((a & 0x07) << 1) | (b >> 7); + let channel_config = (b >> 3) & 0x0F; + if !(1..=4).contains(&object_type) || sf_index > 12 || !(1..=7).contains(&channel_config) { + return Err(ExportError::Unsupported(format!( + "an ADTS .aac stream cannot carry this AAC configuration (object type {object_type}, rate index {sf_index}, channels {channel_config}): export .m4a instead" + ))); + } + Ok(AdtsHeader { profile: object_type - 1, sf_index, channel_config }) + } + + /// The 7-byte header (`protection_absent`) of an access unit of `payload` bytes. + pub(crate) fn bytes(&self, payload: usize) -> Result<[u8; 7]> { + // aac_frame_length is 13 bits and counts the header + let too_big = || ExportError::Encode(format!("AAC: a {payload}-byte frame does not fit ADTS")); + let len = u32::try_from(payload).ok().and_then(|n| n.checked_add(7)).filter(|n| *n < 1 << 13).ok_or_else(too_big)?; + Ok([ + 0xFF, + 0xF1, // syncword, MPEG-4, layer 0, protection absent + (self.profile << 6) | (self.sf_index << 2) | (self.channel_config >> 2), + ((self.channel_config & 0x03) << 6) | ((len >> 11) & 0x03) as u8, + ((len >> 3) & 0xFF) as u8, + (((len & 0x07) << 5) as u8) | 0x1F, // buffer fullness 0x7FF: variable rate + 0xFC, // … and one raw data block + ]) + } +} + +/// Encode the mix of `range` to `settings.path`; returns (bytes written, samples per channel). +pub(crate) fn export( + project: &Arc, + seq: ItemId, + settings: &ExportSettings, + range: TimeRange, + sources: &dyn SourceProvider, + progress: &Progress, +) -> Result<(u64, u64)> { + let cancelled = || progress.cancel.load(Ordering::Relaxed); + let mut a = AudioOut::new(project.clone(), seq, settings, range)?; + let mut enc: Box = audio_factories() + .read() + .unwrap_or_else(|e| e.into_inner()) + .iter() + .find_map(|f| f(Format::Aac, a.sr, a.channels as u32, settings)) + .ok_or_else(|| ExportError::Unsupported("AAC encoder not available".into()))??; + let entry = enc.sample_entry(); + let frames = a.remaining(); + // one second per chunk: memory stays flat however long the range is + let chunk = i64::from(a.sr.max(1)); + if !settings.part_of_batch { + progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed); + progress.set_status(format!("Exporting audio ({})", settings.format.label())); + } + a.measure(settings, sources, &cancelled)?; + *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; + let mut sink = if is_adts_path(&settings.path) { + let CodecConfig::Aac(cfg) = &entry.codec else { + return Err(ExportError::Encode("AAC: the encoder's sample entry is not AAC".into())); + }; + Sink::Adts { header: AdtsHeader::from_asc(&cfg.asc)?, out: Out::create(settings)? } + } else { + let mut opts = WriterOptions::new(Brand::Mp4); + opts.metadata = settings.metadata.udta(); + let mut mux = Mp4Writer::new(Out::create(settings)?, opts).map_err(|e| ExportError::Io(e.to_string()))?; + let mut track = TrackConfig::new(entry, a.sr); + track.media_start = Some(i64::from(enc.priming())); + let track = mux.add_track(track).map_err(|e| ExportError::Io(e.to_string()))?; + Sink::Mp4 { mux, track, frame: enc.frame_size() } + }; + while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) { + if cancelled() { + return Err(ExportError::Cancelled); + } + sink.write(enc.encode(&planar)?)?; + if !settings.part_of_batch { + progress.done.fetch_add(1, Ordering::Relaxed); + } + } + sink.write(enc.flush()?)?; + let n = sink.finish(settings)?; + if settings.part_of_batch { + progress.done.store(1, Ordering::Relaxed); + } + Ok((n, frames)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn adts_header_fields_follow_the_asc() { + // AAC-LC (2), 48 kHz (index 3), stereo: 00010 0011 0010 000 + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert_eq!(h, AdtsHeader { profile: 1, sf_index: 3, channel_config: 2 }); + let b = h.bytes(371).unwrap(); + assert_eq!(&b[..2], &[0xFF, 0xF1]); + assert_eq!(b[2] >> 6, 1); + assert_eq!((b[2] >> 2) & 0x0F, 3); + assert_eq!(((b[2] & 1) << 2) | (b[3] >> 6), 2); + let len = (u32::from(b[3] & 0x03) << 11) | (u32::from(b[4]) << 3) | u32::from(b[5] >> 5); + assert_eq!(len, 378); + assert_eq!(((u32::from(b[5] & 0x1F)) << 6) | u32::from(b[6] >> 2), 0x7FF); + assert_eq!(b[6] & 0x03, 0); + } + + #[test] + fn adts_refuses_what_it_cannot_signal() { + assert!(AdtsHeader::from_asc(&[]).is_err()); + assert!(AdtsHeader::from_asc(&[0x11]).is_err()); + // explicit sampling frequency (index 15) + assert!(AdtsHeader::from_asc(&[0x17, 0x90, 0, 0, 0]).is_err()); + // object type 5 (SBR) has no ADTS profile + assert!(AdtsHeader::from_asc(&[0x29, 0x90]).is_err()); + let h = AdtsHeader::from_asc(&[0x11, 0x90]).unwrap(); + assert!(h.bytes(8184).is_ok()); + assert!(h.bytes(8185).is_err()); + assert!(h.bytes(usize::MAX).is_err()); + } + + #[test] + fn only_a_dot_aac_path_is_adts() { + assert!(is_adts_path("/x/mix.aac")); + assert!(is_adts_path("mix.AAC")); + assert!(!is_adts_path("mix.m4a")); + assert!(!is_adts_path("aac")); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 279384f2..9a7d15dc 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -15,6 +15,7 @@ use std::io::Write; +mod aac_out; mod audio_out; mod job; mod mxf_out; @@ -112,6 +113,10 @@ pub enum Format { Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, + /// AAC Audio: the mix alone, AAC-LC in an MPEG-4 audio file (`.m4a`), or a raw ADTS stream when + /// the path ends in `.aac`. + #[serde(rename = "aac", alias = "Aac")] + Aac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, @@ -143,6 +148,7 @@ impl Format { "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, + "aac" | "aacaudio" | "m4a" | "adts" => Format::Aac, _ => return None, }) } @@ -165,6 +171,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", + Format::Aac => "aac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } @@ -182,6 +189,7 @@ impl Format { Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", + Format::Aac => "m4a", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } @@ -203,6 +211,7 @@ impl Format { Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", + Format::Aac => "AAC Audio", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } @@ -220,6 +229,10 @@ impl Format { pub fn has_builtin_encoder(self) -> bool { !matches!(self, Format::Hevc | Format::Av1) } + /// Sound without picture: Waveform Audio, AIFF and AAC Audio. + pub fn is_audio_only(self) -> bool { + matches!(self, Format::Wav | Format::Aiff | Format::Aac) + } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) @@ -232,7 +245,7 @@ impl Format { pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } - pub const ALL: [Format; 18] = [ + pub const ALL: [Format; 19] = [ Format::H264, Format::Hevc, Format::Av1, @@ -249,6 +262,7 @@ impl Format { Format::Gif, Format::Wav, Format::Aiff, + Format::Aac, Format::MxfOp1a, Format::MxfOpAtom, ]; @@ -1719,6 +1733,7 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let cancelled = || progress.cancel.load(Ordering::Relaxed); let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { + Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?, Format::Wav | Format::Aiff => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, bits) = (a.channels as u16, a.sr, settings.audio.bits); diff --git a/crates/export/src/presets.rs b/crates/export/src/presets.rs index 2a021d49..c6f04979 100644 --- a/crates/export/src/presets.rs +++ b/crates/export/src/presets.rs @@ -169,6 +169,16 @@ pub fn builtin_presets() -> Vec { p("Waveform Audio 48 kHz 16-bit", "Audio Only", "WAV, stereo 48 kHz 16-bit PCM", audio_only(Format::Wav, 16)), p("Waveform Audio 48 kHz 24-bit", "Audio Only", "WAV, stereo 48 kHz 24-bit PCM", audio_only(Format::Wav, 24)), p("AIFF 48 kHz 16-bit", "Audio Only", "AIFF, stereo 48 kHz 16-bit PCM", audio_only(Format::Aiff, 16)), + p( + "AAC Audio 48 kHz 320 kbps", + "Audio Only", + "MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps", + ExportSettings { + format: Format::Aac, + audio: AudioSettings { codec: AudioCodec::Aac, sample_rate: Some(48_000), channels: 2, bitrate_kbps: 320, bits: 16 }, + ..Default::default() + }, + ), ] } diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 61ee9dbd..4bfa28ec 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -160,7 +160,7 @@ impl MxfVideoCodec { } } -/// Audio codec. `Auto` = AAC in MP4, PCM in QuickTime / WAV / AIFF. +/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub enum AudioCodec { @@ -432,6 +432,7 @@ impl ExportSettings { pub fn audio_codec(&self) -> AudioCodec { match (self.format, self.audio.codec) { (Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm, + (Format::Aac, _) => AudioCodec::Aac, (f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac, (_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac, (_, AudioCodec::Auto) => AudioCodec::Pcm, @@ -446,7 +447,7 @@ impl ExportSettings { /// Whether the format carries video. pub fn has_video(&self) -> bool { - !matches!(self.format, Format::Wav | Format::Aiff) + !self.format.is_audio_only() } /// File extension of the output. @@ -461,6 +462,7 @@ impl ExportSettings { let fps = r.rate.num as f64 / r.rate.den as f64; let px = r.width as f64 * r.height as f64; let video_bps = match self.video_format() { + Format::Aac => 0.0, Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0, Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97, Format::DnxHr => { @@ -491,7 +493,7 @@ impl ExportSettings { Format::Gif => px * 0.6 * 8.0 * fps, Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0, }; - let audio_bps = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio_bps = if self.has_audio() || self.format.is_audio_only() { match self.audio_codec() { AudioCodec::Aac => self.audio.bitrate_kbps as f64 * 1000.0, _ => r.sample_rate as f64 * r.channels as f64 * if self.audio.bits >= 24 { 24.0 } else { 16.0 }, @@ -575,7 +577,7 @@ impl ExportSettings { } v }; - let audio = if self.has_audio() || matches!(self.format, Format::Wav | Format::Aiff) { + let audio = if self.has_audio() || self.format.is_audio_only() { let ch = match r.channels { 1 => "Mono", 6 => "5.1", diff --git a/crates/export/src/settings_tests.rs b/crates/export/src/settings_tests.rs index 8cfb3170..1b72f10b 100644 --- a/crates/export/src/settings_tests.rs +++ b/crates/export/src/settings_tests.rs @@ -218,6 +218,103 @@ fn wav_and_aiff_audio_only() { } } +/// Root-mean-square level of decoded planar audio. +fn rms(planes: &[Vec]) -> f32 { + let n = planes.iter().map(Vec::len).sum::().max(1); + (planes.iter().flatten().map(|s| s * s).sum::() / n as f32).sqrt() +} + +#[test] +fn aac_audio_writes_m4a_and_adts() { + let (p, seq, m) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); + let dir = Scratch::new("aac-audio"); + let range = Some(TimeRange::new(Tick::ZERO, Tick(TICKS_PER_SECOND))); + // .m4a: one AAC track, no video, the encoder delay in an edit list + let m4a = dir.path("a.m4a"); + let s = ExportSettings { format: Format::Aac, path: m4a.clone(), range, ..Default::default() }; + assert!(!s.has_video()); + assert_eq!(s.audio_codec(), AudioCodec::Aac); + assert_eq!(s.extension(), "m4a"); + let r = export(&p, seq, &s, &m, &Progress::default()).unwrap(); + assert_eq!(r.frames, 48_000, "one second of samples"); + let bytes = std::fs::read(&m4a).unwrap(); + let file = filmcraft_isobmff::open(bytes.clone()).unwrap(); + assert_eq!(file.tracks.len(), 1, "audio only"); + let t = &file.tracks[0]; + let filmcraft_isobmff::CodecConfig::Aac(cfg) = &t.entries[0].codec else { panic!("not AAC: {:?}", t.entries[0].codec) }; + assert_eq!((cfg.object_type, cfg.sample_rate, cfg.channel_config), (2, 48_000, 2)); + assert!(t.samples.len() >= 48_000 / 1024, "{} access units", t.samples.len()); + let mut dec = filmcraft_aac::Decoder::new(&cfg.asc).unwrap(); + let mut pcm: Vec> = Vec::new(); + for i in 0..t.samples.len() { + let out = dec.decode(&file.read_sample(&bytes, 0, i).unwrap()).unwrap(); + pcm.resize(out.len(), Vec::new()); + pcm.iter_mut().zip(out).for_each(|(a, b)| a.extend(b)); + } + // the -6 dBFS tone (RMS about 0.35) survives the round trip + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &m4a) { + assert_eq!(j["streams"].as_array().unwrap().len(), 1); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "48000"); + assert_eq!(st["channels"], 2); + let d: f64 = j["format"]["duration"].as_str().unwrap().parse().unwrap(); + assert!((d - 1.0).abs() < 0.03, "duration {d}"); + } + + // .aac: raw ADTS, mono 44.1 kHz at 128 kbps + let adts = dir.path("a.aac"); + let mut s = ExportSettings { format: Format::Aac, path: adts.clone(), range, ..Default::default() }; + s.audio = AudioSettings { sample_rate: Some(44_100), channels: 1, bitrate_kbps: 128, ..Default::default() }; + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + let b = std::fs::read(&adts).unwrap(); + // AAC-LC 44.1 kHz (index 4) mono: 00010 0100 0001 000 + let mut dec = filmcraft_aac::Decoder::new(&[0x12, 0x08]).unwrap(); + let (mut pos, mut units, mut pcm) = (0usize, 0usize, vec![Vec::new()]); + while pos < b.len() { + let h = &b[pos..pos + 7]; + assert_eq!((h[0], h[1] & 0xF6), (0xFF, 0xF0), "ADTS sync at {pos}"); + assert_eq!(h[1] & 0x01, 1, "no CRC"); + assert_eq!(((h[2] >> 2) & 0x0F, ((h[2] & 1) << 2) | (h[3] >> 6)), (4, 1), "44.1 kHz mono"); + let len = ((h[3] as usize & 0x03) << 11) | ((h[4] as usize) << 3) | (h[5] as usize >> 5); + assert!(len > 7 && pos + len <= b.len(), "frame length {len} at {pos}"); + pcm[0].extend(dec.decode(&b[pos + 7..pos + len]).unwrap().swap_remove(0)); + pos += len; + units += 1; + } + assert!(units >= 44_100 / 1024, "{units} frames"); + assert!((0.2..0.5).contains(&rms(&pcm)), "rms {}", rms(&pcm)); + if let Some(j) = ffprobe_json(&["-show_streams", "-show_format"], &adts) { + assert_eq!(j["format"]["format_name"], "aac"); + let st = &j["streams"][0]; + assert_eq!(st["codec_name"], "aac"); + assert_eq!(st["sample_rate"], "44100"); + assert_eq!(st["channels"], 1); + } + if let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("AAC Audio decode") { + for f in [&m4a, &adts] { + let out = std::process::Command::new(&ffmpeg).args(["-v", "error", "-xerror", "-i", f, "-f", "null", "-"]).output().unwrap(); + assert!(out.status.success() && out.stderr.is_empty(), "{f}: {}", String::from_utf8_lossy(&out.stderr)); + } + } +} + +#[test] +fn aac_audio_settings_and_names() { + assert_eq!(Format::from_name("m4a"), Some(Format::Aac)); + assert_eq!(Format::from_name("AAC Audio"), Some(Format::Aac)); + assert_eq!(Format::from_name(Format::Aac.id()), Some(Format::Aac)); + assert!(Format::ALL.contains(&Format::Aac) && Format::Aac.is_audio_only() && !Format::H264.is_audio_only()); + let s = ExportSettings { format: Format::Aac, include_audio: false, ..Default::default() }; + let sum = s.summary(1920, 1080, FrameRate::FPS_24, 48_000, Tick(10 * TICKS_PER_SECOND)); + assert_eq!(sum.video, "No video"); + assert_eq!(sum.audio, "AAC, 320 kbps, 48000 Hz, Stereo"); + assert_eq!(sum.estimated_bytes, 400_000, "320 kbps for 10 s, no picture"); + let preset = presets::find_builtin("AAC Audio 48 kHz 320 kbps").unwrap(); + assert_eq!(preset.settings.format, Format::Aac); +} + #[test] fn wav_export_honours_explicit_sample_rates() { let (project, sequence, sources) = matte([0.0, 0.0, 0.0, 1.0], 64, 36, Some(-6.0)); @@ -658,7 +755,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() { } assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc)); assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)")); - assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18); + assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19); let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap(); assert_eq!(s.format, Format::Hevc); assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc"); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 51d1cd03..4f76c350 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -2974,6 +2974,9 @@ Step Forward 1 Frame Avanzar 1 fotograma Match Playhead Igualar cabezal Gang to Program Vincular al programa + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz y 16 bits diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index f9f8c660..5f17a4f4 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -2976,6 +2976,9 @@ Step Forward 1 Frame Avancer d'une image Match Playhead Faire correspondre la tête de lecture Gang to Program Coupler au moniteur programme + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Audio MPEG-4 (.m4a), AAC-LC stéréo 48 kHz à 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits AIFF, stereo 48 kHz 16-bit PCM AIFF, PCM stéréo 48 kHz 16 bits diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 79c7d025..630cbd4d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -2963,6 +2963,9 @@ Step Forward 1 Frame 1 フレーム進む Match Playhead 再生ヘッドに合わせる Gang to Program プログラムと連動 + AAC Audio AAC Audio + AAC Audio 48 kHz 320 kbps AAC Audio 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 オーディオ (.m4a)、ステレオ 48 kHz AAC-LC 320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16-bit AIFF, stereo 48 kHz 16-bit PCM AIFF、ステレオ 48 kHz 16-bit PCM diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 16472ef0..2d518866 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -2957,6 +2957,9 @@ Step Forward 1 Frame 前进 1 帧 Match Playhead 匹配播放头 Gang to Program 联动到节目 + AAC Audio AAC 音频 + AAC Audio 48 kHz 320 kbps AAC 音频 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps MPEG-4 音频 (.m4a),立体声 48 kHz AAC-LC,320 kbps AIFF AIFF AIFF 48 kHz 16-bit AIFF 48 kHz 16 位 AIFF, stereo 48 kHz 16-bit PCM AIFF,立体声 48 kHz 16 位 PCM diff --git a/docs/architecture.md b/docs/architecture.md index ed1486af..cbd1e6ed 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -664,6 +664,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui | `prores` | `filmcraft-prores` (Proxy / LT / 422 / HQ) | MOV | | `dnxhr` | `filmcraft-dnx` (LB / SQ / HQ / HQX) | MOV (`AVdh`) | | `mjpeg` | built in | MOV | +| `aac` | `filmcraft-aac` (AAC-LC, the audio settings' bitrate) | audio only: MPEG-4 audio `.m4a` (`isobmff`, priming in an edit list), or raw ADTS when the path ends in `.aac` | | `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) | | `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`_A1.mxf` …, `Report::extra_files`) | | `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `000.`, `001.` … | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 58c45cb6..ee18a834 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -68,7 +68,7 @@ only). "Tested" names the evidence. | WAV / BWF (timecode, iXML partial) | read + write | yes | yes | 90% | | AIFF / AIFC | read + write | yes | yes | 95% | | MP3 | read + write | read | **no** | 60% | -| AAC / M4A | read + write | yes | inside MP4 / MOV only, **no AAC-only file** | 70% | +| AAC / M4A | read + write | yes | yes (in MP4 / MOV, and AAC Audio: `.m4a` or ADTS `.aac`, #814) | 95% | | FLAC, Ogg, Opus | partial | yes | no | ahead on read | | WMA | read (Windows) | no | no | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index aec13ce2..c5baf693 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -187,10 +187,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G13. Delivery formats - **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF, - AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio, - ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding, + AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 audio, interlaced encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does). -- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779). +- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); AAC Audio + (audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342). - **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md). ## G14. Localization: 8 of the 12 key languages missing From fe52bc0b2cc4d1aefb532027c8ce95d7b67f7332 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:58:15 +0200 Subject: [PATCH 060/203] #821: import Targa and DPX stills and image sequences Problem: Premiere imports Targa and DPX as stills and numbered sequences. FilmCraft exports both (#779) but could not read either: the extensions were not importable and the image crate has no DPX reader, so even our own Targa / DPX renders could not come back in. Fix: clean-room decoders in filmcraft-media. Targa from the TGA 2.0 spec: image types 1/2/3 and RLE 9/10/11, colour maps, 15/16/24/32-bit, 8/16-bit grayscale, any origin. DPX from SMPTE ST 268: first element, luma / RGB / RGBA / ABGR, 8/16-bit, filled 10/12-bit (methods A and B), either byte order, padded lines, orientations 0-3. Both bound allocations by the data present and reject hostile headers with errors. Stills and image sequences share one decode_raster entry point; "tga" and "dpx" join the importable still extensions. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/media_oracle.rs | 63 +++++- crates/media/src/dpx.rs | 273 +++++++++++++++++++++++++ crates/media/src/lib.rs | 6 +- crates/media/src/sequence.rs | 15 +- crates/media/src/still.rs | 41 +++- crates/media/src/tga.rs | 299 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 6 +- docs/gaps.md | 7 +- 8 files changed, 683 insertions(+), 27 deletions(-) create mode 100644 crates/media/src/dpx.rs create mode 100644 crates/media/src/tga.rs diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index db7745dd..4fc6638e 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -1,6 +1,7 @@ -//! Image sequences and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, JPEG, -//! TIFF, BMP, WebP when available) and BWF files; the sequence frames must equal ffmpeg's decode of -//! the same image2 sequence (lossless formats exactly, JPEG within a few levels), and the BWF +//! Image sequences, stills and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, +//! JPEG, TIFF, BMP, WebP when available, Targa, DPX), single Targa / DPX stills in every layout it +//! has, and BWF files; our frames must equal ffmpeg's decode of the same files (lossless formats +//! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF //! TimeReference must give the start timecode. mod common; @@ -114,6 +115,62 @@ fn webp_sequence_lossless() { check(&ff, "webp", &["-c:v", "libwebp", "-lossless", "1"], 0); } +#[test] +fn targa_and_dpx_sequences_exact() { + let ff = filmcraft_testkit::require_ffmpeg!(); + check(&ff, "tga", &[], 0); + check(&ff, "dpx", &["-pix_fmt", "gbrp10be"], 1); +} + +/// One `ext` still in `pix_fmt` (95 × 63, so scan lines need padding; translucent when the format +/// has alpha) opened like an imported file must match ffmpeg's RGBA decode within `tol` (exactly +/// when `tol` is 0). +fn still_matches(ff: &Path, ext: &str, pix_fmt: &str, extra: &[&str], tol: u8) { + let name = format!("still_{pix_fmt}{}.{ext}", extra.join("")); + let alpha = if pix_fmt.contains('a') && pix_fmt != "pal8" { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale=95:63{alpha}"); + let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt]; + args.extend_from_slice(extra); + let Some(f) = fixture(ff, &name, &args) else { + eprintln!("SKIPPED: ffmpeg cannot write {name}"); + return; + }; + let src = filmcraft_codecs::open_bytes(&name, bytes(&f)).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let v = src.info().video.as_ref().unwrap(); + assert_eq!((v.width, v.height), (95, 63), "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + // deeper than 8 bits: ffmpeg's 16-bit decode, rounded to 8 bits (its own 8-bit conversion dithers) + let want: Vec = if tol == 0 { + ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba", "-"]) + } else { + let wide = ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect() + }; + assert_eq!(ours.len(), want.len(), "{name}"); + let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(d <= tol, "{name}: differs by {d}"); + assert_eq!(v.has_alpha, want.as_chunks::<4>().0.iter().any(|p| p[3] != 255), "{name}"); + eprintln!("{name}: max diff {d}"); +} + +#[test] +fn targa_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for pix_fmt in ["bgr24", "bgra", "rgb555le", "gray", "pal8"] { + still_matches(&ff, "tga", pix_fmt, &[], 0); // run-length encoded (ffmpeg's default) + still_matches(&ff, "tga", pix_fmt, &["-rle", "0"], 0); + } +} + +#[test] +fn dpx_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (pix_fmt, tol) in [("rgb24", 0), ("rgba", 0), ("gray", 0), ("rgb48be", 1), ("rgba64be", 1), ("gray16be", 1), ("gbrp10be", 1), ("gbrp12be", 1)] { + still_matches(&ff, "dpx", pix_fmt, &[], tol); + } +} + #[test] fn missing_frames_hold_the_previous_frame() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs new file mode 100644 index 00000000..2d5832fa --- /dev/null +++ b/crates/media/src/dpx.rs @@ -0,0 +1,273 @@ +//! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange). +//! +//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per +//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into +//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the +//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit +//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line +//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing +//! density) transfer is shown as stored, without a film-log conversion. +//! +//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed" +//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed +//! orientations. + +use crate::{MediaError, Result, still::Raster}; + +/// Generic file + image information header: everything we read lies below this. +const HEADER: usize = 1408; +/// First image element of the image information header (72 bytes each). +const ELEMENT: usize = 780; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; +/// "Undefined" value of numeric header fields. +const UNDEFINED: u32 = u32::MAX; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: DPX: {what}")) +} + +fn unsupported(name: &str, what: String) -> MediaError { + MediaError::Unsupported(format!("{name}: DPX {what}")) +} + +/// Whether `bytes` starts with the DPX magic number in either byte order. +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"SDPX") || bytes.starts_with(b"XPDS") +} + +/// Header and sample reads in the file's byte order. +#[derive(Clone, Copy)] +struct Order { + big: bool, +} + +impl Order { + fn u16(self, b: &[u8], at: usize) -> u16 { + let v = [b.get(at).copied().unwrap_or(0), b.get(at + 1).copied().unwrap_or(0)]; + if self.big { u16::from_be_bytes(v) } else { u16::from_le_bytes(v) } + } + + fn u32(self, b: &[u8], at: usize) -> u32 { + let v = core::array::from_fn(|i| b.get(at + i).copied().unwrap_or(0)); + if self.big { u32::from_be_bytes(v) } else { u32::from_le_bytes(v) } + } +} + +/// How components are stored in a scan line. +#[derive(Clone, Copy)] +enum Packing { + /// One byte per component. + Bytes, + /// One 16-bit word per component; the value is `word >> shift` (12-bit method A: 4). + Words { shift: u32 }, + /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. + Ten { pad: u32 }, +} + +impl Packing { + /// Bytes of a scan line holding `comps` components, before end-of-line padding (lines start on + /// a 32-bit boundary). + fn line_bytes(self, comps: usize) -> Option { + let raw = match self { + Packing::Bytes => comps, + Packing::Words { .. } => comps.checked_mul(2)?, + Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + }; + raw.checked_next_multiple_of(4) + } + + /// The `comps` component values of one scan line. + fn unpack(self, line: &[u8], comps: usize, order: Order, out: &mut Vec) { + out.clear(); + match self { + Packing::Bytes => out.extend(line.iter().take(comps).map(|&b| u16::from(b))), + Packing::Words { shift } => out.extend((0..comps).map(|i| order.u16(line, i * 2) >> shift)), + Packing::Ten { pad } => { + for w in 0..comps.div_ceil(3) { + let word = order.u32(line, w * 4); + for s in [20 + pad, 10 + pad, pad] { + if out.len() < comps { + out.push(((word >> s) & 0x3FF) as u16); + } + } + } + } + } + } +} + +/// Decode the first image element of a DPX file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let order = match bytes.get(..4) { + Some(b"SDPX") => Order { big: true }, + Some(b"XPDS") => Order { big: false }, + _ => return Err(err(name, "not a DPX file")), + }; + if bytes.len() < HEADER { + return Err(err(name, "truncated header")); + } + let orientation = order.u16(bytes, 768); + let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize); + let el = ELEMENT; + let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let bits = bytes.get(el + 23).copied().unwrap_or(0); + let packing = order.u16(bytes, el + 24); + let encoding = order.u16(bytes, el + 26); + let element_offset = order.u32(bytes, el + 28); + let eol_padding = order.u32(bytes, el + 32); + if w == 0 || h == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + // component order per pixel: indices of R, G, B, A (None: opaque) in the stored components + let (n, map): (usize, [Option; 4]) = match descriptor { + 6 => (1, [Some(0), Some(0), Some(0), None]), + 50 => (3, [Some(0), Some(1), Some(2), None]), + 51 => (4, [Some(0), Some(1), Some(2), Some(3)]), + 52 => (4, [Some(3), Some(2), Some(1), Some(0)]), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + if encoding != 0 { + return Err(unsupported(name, "run-length encoding".into())); + } + let layout = match (bits, packing) { + (8, _) => Packing::Bytes, + (16, _) => Packing::Words { shift: 0 }, + (10, 1) => Packing::Ten { pad: 2 }, + (10, 2) => Packing::Ten { pad: 0 }, + (12, 1) => Packing::Words { shift: 4 }, + (12, 2) => Packing::Words { shift: 0 }, + (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), + }; + let (flip_x, flip_y) = match orientation { + 0 => (false, false), + 1 => (true, false), + 2 => (false, true), + 3 => (true, true), + o => return Err(unsupported(name, format!("orientation {o}"))), + }; + let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?; + let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize }; + let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?; + let offset = match element_offset { + 0 | UNDEFINED => order.u32(bytes, 4) as usize, + o => o as usize, + }; + // the last line needs no end-of-line padding + let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?; + let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?; + let max = (1u32 << bits) - 1; + let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8; + let mut rgba = vec![0u8; pixels * 4]; + let mut line = Vec::with_capacity(comps); + for y in 0..h { + let src = data.get(y * stride..).unwrap_or_default(); + layout.unpack(src, comps, order, &mut line); + let dy = if flip_y { h - 1 - y } else { y }; + for x in 0..w { + let dx = if flip_x { w - 1 - x } else { x }; + let o = (dy * w + dx) * 4; + let Some(px) = rgba.get_mut(o..o + 4) else { continue }; + for (c, slot) in map.iter().enumerate() { + let v = match slot { + Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)), + None => 255, + }; + if let Some(d) = px.get_mut(c) { + *d = v; + } + } + } + } + let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal DPX file (header + `data`) in the given byte order. + fn file(big: bool, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, orientation: u16, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let p32 = |f: &mut Vec, at: usize, v: u32| f[at..at + 4].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + let p16 = |f: &mut Vec, at: usize, v: u16| f[at..at + 2].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + f[0..4].copy_from_slice(if big { b"SDPX" } else { b"XPDS" }); + p32(&mut f, 4, 2048); + p16(&mut f, 768, orientation); + p16(&mut f, 770, 1); + p32(&mut f, 772, w); + p32(&mut f, 776, h); + f[ELEMENT + 20] = descriptor; + f[ELEMENT + 23] = bits; + p16(&mut f, ELEMENT + 24, packing); + p32(&mut f, ELEMENT + 28, UNDEFINED); + p32(&mut f, ELEMENT + 32, UNDEFINED); + f.extend_from_slice(data); + f + } + + #[test] + fn ten_bit_method_a_and_b() { + let word = |r: u32, g: u32, b: u32, pad: u32| ((r << (20 + pad)) | (g << (10 + pad)) | (b << pad)).to_be_bytes(); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 2)); + data.extend_from_slice(&word(4, 1019, 0, 2)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + assert!(!r.has_alpha); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 0)); + data.extend_from_slice(&word(4, 1019, 0, 0)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 2, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + } + + #[test] + fn rgba_components_roll_across_ten_bit_words() { + // 2 RGBA pixels = 8 components = 3 words; the last word is half empty + let comps = [1023u32, 0, 0, 1023, 0, 1023, 0, 0]; + let mut data = Vec::new(); + for c in comps.chunks(3) { + let get = |i: usize| c.get(i).copied().unwrap_or(0); + data.extend_from_slice(&((get(0) << 22) | (get(1) << 12) | (get(2) << 2)).to_le_bytes()); + } + let r = decode("a.dpx", &file(false, 2, 1, 51, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + assert!(r.has_alpha); + } + + #[test] + fn eight_bit_lines_pad_to_words_and_orientation_flips() { + // 1 × 2 RGB: each 3-byte line padded to 4; bottom-to-top, right-to-left orientation 3 + let r = decode("a.dpx", &file(false, 1, 2, 50, 8, 0, 3, &[1, 2, 3, 0, 4, 5, 6, 0])).unwrap(); + assert_eq!(r.rgba, [4, 5, 6, 255, 1, 2, 3, 255]); + // 2 × 1 luma, 16-bit big-endian; ABGR, 12-bit method A + let r = decode("a.dpx", &file(true, 2, 1, 6, 16, 0, 0, &[0xFF, 0xFF, 0x80, 0x00])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 128, 128, 128, 255]); + let r = decode("a.dpx", &file(true, 1, 1, 52, 12, 1, 0, &[0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0])).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 128]); + } + + #[test] + fn hostile_headers_fail_cleanly() { + // huge dimensions with no data, truncated data, unsupported layouts + assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err()); + assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err()); + assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", b"SDPX").is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[4..8].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(decode("a.dpx", &f).is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[ELEMENT + 32..ELEMENT + 36].copy_from_slice(&(u32::MAX - 1).to_be_bytes()); + assert!(decode("a.dpx", &file(true, 1, 2, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &f).is_ok()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index bfc94de7..a73451e7 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -12,11 +12,13 @@ pub mod cache; pub mod cancel; pub mod digits; +pub mod dpx; pub mod generators; pub mod pending; pub mod reader; pub mod sequence; pub mod still; +pub mod tga; pub mod wav; use std::path::Path; @@ -169,7 +171,7 @@ pub trait MediaSource: Send + Sync { pub type SharedSource = Arc; /// File extensions we recognise at import (the media browser filters on these). -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx"]; pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"]; pub const VIDEO_EXTENSIONS: &[&str] = &[ "mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv", @@ -193,7 +195,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result bool { - b.starts_with(b"\x89PNG") +/// Whether `b` (the contents of the file `name`) is a still image we decode. +pub fn sniff(name: &str, b: &[u8]) -> bool { + crate::dpx::sniff(b) + || crate::tga::sniff(name, b) + || b.starts_with(b"\x89PNG") || b.starts_with(&[0xFF, 0xD8, 0xFF]) || b.starts_with(b"GIF8") || (b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP") @@ -20,6 +23,31 @@ pub fn sniff(b: &[u8]) -> bool { || b.starts_with(b"BM") } +/// A decoded picture: straight-alpha RGBA8, top row first. +pub struct Raster { + pub width: u32, + pub height: u32, + pub rgba: Vec, + /// Whether any pixel is not fully opaque. + pub has_alpha: bool, +} + +/// Decode a still image file (`name` picks Targa, which has no magic number; the content picks +/// every other format). +pub fn decode_raster(name: &str, bytes: &[u8]) -> Result { + if crate::dpx::sniff(bytes) { + return crate::dpx::decode(name, bytes); + } + if crate::tga::sniff(name, bytes) { + return crate::tga::decode(name, bytes); + } + let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; + let has_alpha = img.color().has_alpha(); + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + Ok(Raster { width, height, rgba: rgba.into_raw(), has_alpha }) +} + pub struct StillSource { info: MediaInfo, frame: Arc, @@ -27,11 +55,8 @@ pub struct StillSource { impl StillSource { pub fn decode(name: &str, bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; - let has_alpha = img.color().has_alpha(); - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - Ok(Self::from_rgba(name, w, h, rgba.into_raw(), has_alpha, bytes.len() as u64)) + let r = decode_raster(name, bytes)?; + Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64)) } pub fn from_rgba(name: &str, w: u32, h: u32, rgba: Vec, has_alpha: bool, file_size: u64) -> Self { diff --git a/crates/media/src/tga.rs b/crates/media/src/tga.rs new file mode 100644 index 00000000..06e9295d --- /dev/null +++ b/crates/media/src/tga.rs @@ -0,0 +1,299 @@ +//! Targa (`.tga`) stills, decoded clean-room from the Truevision TGA File Format Specification 2.0. +//! +//! Image types 1/2/3 (colour-mapped, true-colour, grayscale) and their run-length encoded forms +//! 9/10/11; 15/16-bit (5-5-5, the top bit as alpha when the descriptor says one attribute bit), +//! 24-bit BGR and 32-bit BGRA true-colour, 8-bit grayscale and 16-bit grayscale + alpha, colour maps +//! of 15/16/24/32-bit entries indexed by 8 or 16 bits; any origin corner. + +use crate::{MediaError, Result, still::Raster}; + +/// Length of the fixed header. +const HEADER: usize = 18; +/// TGA 2.0 files end with this signature (after two 32-bit area offsets). +const FOOTER: &[u8] = b"TRUEVISION-XFILE.\0"; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: Targa: {what}")) +} + +/// Whether `bytes` looks like a Targa file: a TGA 2.0 footer, or (for the original format, which +/// has no signature) a `.tga` name and a plausible header. +pub fn sniff(name: &str, bytes: &[u8]) -> bool { + let header_ok = || { + let Some(h) = bytes.first_chunk::
() else { return false }; + let (map_type, kind, depth) = (h[1], h[2], h[16]); + let (w, ht) = (u16::from_le_bytes([h[12], h[13]]), u16::from_le_bytes([h[14], h[15]])); + map_type <= 1 && matches!(kind, 1 | 2 | 3 | 9 | 10 | 11) && w > 0 && ht > 0 && matches!(depth, 8 | 15 | 16 | 24 | 32) + }; + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + let named = matches!(ext.as_deref(), Some("tga" | "targa" | "icb" | "vda" | "vst")); + header_ok() && (named || bytes.ends_with(FOOTER)) +} + +/// Pixel layout of the stored values (after the colour-map lookup, for mapped images). +#[derive(Clone, Copy)] +enum Layout { + /// 15/16-bit `ARRRRRGG GGGBBBBB`, little-endian; `alpha`: the top bit is an alpha bit. + Rgb555 { + alpha: bool, + }, + Bgr24, + Bgra32, + Gray8, + GrayAlpha16, +} + +impl Layout { + fn true_colour(depth: u8, attr_bits: u8) -> Option { + Some(match depth { + 15 => Layout::Rgb555 { alpha: false }, + 16 => Layout::Rgb555 { alpha: attr_bits == 1 }, + 24 => Layout::Bgr24, + 32 => Layout::Bgra32, + _ => return None, + }) + } + + fn bytes(self) -> usize { + match self { + Layout::Rgb555 { .. } | Layout::GrayAlpha16 => 2, + Layout::Bgr24 => 3, + Layout::Bgra32 => 4, + Layout::Gray8 => 1, + } + } + + /// RGBA of one stored value (`p` holds exactly [`bytes`](Self::bytes) bytes). + fn rgba(self, p: &[u8]) -> [u8; 4] { + let b = |i: usize| p.get(i).copied().unwrap_or(0); + match self { + Layout::Rgb555 { alpha } => { + let v = u16::from_le_bytes([b(0), b(1)]); + let five = |s: u16| { + let c = ((v >> s) & 31) as u8; + (c << 3) | (c >> 2) + }; + let a = if !alpha || v & 0x8000 != 0 { 255 } else { 0 }; + [five(10), five(5), five(0), a] + } + Layout::Bgr24 => [b(2), b(1), b(0), 255], + Layout::Bgra32 => [b(2), b(1), b(0), b(3)], + Layout::Gray8 => [b(0), b(0), b(0), 255], + Layout::GrayAlpha16 => [b(0), b(0), b(0), b(1)], + } + } +} + +/// Decode a Targa file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let h = bytes.first_chunk::
().ok_or_else(|| err(name, "truncated header"))?; + let (id_len, map_type, kind) = (usize::from(h[0]), h[1], h[2]); + let map_first = usize::from(u16::from_le_bytes([h[3], h[4]])); + let map_len = usize::from(u16::from_le_bytes([h[5], h[6]])); + let map_depth = h[7]; + let (w, ht) = (usize::from(u16::from_le_bytes([h[12], h[13]])), usize::from(u16::from_le_bytes([h[14], h[15]]))); + let (depth, descriptor) = (h[16], h[17]); + let attr_bits = descriptor & 0x0F; + let (right_to_left, top_to_bottom) = (descriptor & 0x10 != 0, descriptor & 0x20 != 0); + if w == 0 || ht == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(ht).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + let rle = kind >= 9; + // stored value layout and, for colour-mapped images, the palette + let mut palette: Vec<[u8; 4]> = Vec::new(); + let map_entry_layout = if map_type == 1 { + Some(Layout::true_colour(map_depth, if map_depth == 16 { attr_bits.min(1) } else { 0 }).ok_or_else(|| err(name, "colour map entry size"))?) + } else { + None + }; + let mut at = HEADER.checked_add(id_len).ok_or_else(|| err(name, "header"))?; + if let Some(l) = map_entry_layout { + let size = map_len.checked_mul(l.bytes()).ok_or_else(|| err(name, "colour map"))?; + let end = at.checked_add(size).ok_or_else(|| err(name, "colour map"))?; + let map = bytes.get(at..end).ok_or_else(|| err(name, "truncated colour map"))?; + palette = map.chunks_exact(l.bytes()).map(|e| l.rgba(e)).collect(); + at = end; + } + let (layout, mapped) = match kind & 7 { + 1 => { + if map_type != 1 || !matches!(depth, 8 | 16) { + return Err(err(name, "colour-mapped picture without an 8/16-bit index and a colour map")); + } + (if depth == 8 { Layout::Gray8 } else { Layout::GrayAlpha16 }, true) + } + 2 => (Layout::true_colour(depth, attr_bits).ok_or_else(|| err(name, "true-colour pixel depth"))?, false), + 3 => match depth { + 8 => (Layout::Gray8, false), + 16 => (Layout::GrayAlpha16, false), + _ => return Err(err(name, "grayscale pixel depth")), + }, + _ => return Err(MediaError::Unsupported(format!("{name}: Targa image type {kind}"))), + }; + let data = bytes.get(at..).unwrap_or_default(); + let bpp = layout.bytes(); + // the stored values, in file order + let stored: Vec = if rle { + unpack_rle(name, data, pixels, bpp)? + } else { + let n = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + data.get(..n).ok_or_else(|| err(name, "truncated pixel data"))?.to_vec() + }; + let mut rgba = vec![0u8; pixels * 4]; + for (i, v) in stored.chunks_exact(bpp).enumerate().take(pixels) { + let (x, y) = (i % w, i / w); + let x = if right_to_left { w - 1 - x } else { x }; + let y = if top_to_bottom { y } else { ht - 1 - y }; + let px = if mapped { + let byte = |i: usize| v.get(i).copied().unwrap_or(0); + let index = if bpp == 1 { usize::from(byte(0)) } else { usize::from(u16::from_le_bytes([byte(0), byte(1)])) }; + *index.checked_sub(map_first).and_then(|k| palette.get(k)).ok_or_else(|| err(name, "colour index outside the colour map"))? + } else { + layout.rgba(v) + }; + let o = (y * w + x) * 4; + if let Some(d) = rgba.get_mut(o..o + 4) { + d.copy_from_slice(&px); + } + } + // 32-bit files from writers that leave the attribute bits unset often store zero alpha + // everywhere: such a picture is opaque, not invisible + let alpha_used = match (layout, mapped) { + (Layout::Bgra32, false) => attr_bits != 0 || rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 0), + _ => true, + }; + if !alpha_used { + rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255); + } + let has_alpha = rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: ht as u32, rgba, has_alpha }) +} + +/// Expand run-length packets into `pixels` stored values of `bpp` bytes each. Packets may span +/// scan lines (the TGA 2.0 spec discourages it; older writers do it). +fn unpack_rle(name: &str, mut data: &[u8], pixels: usize, bpp: usize) -> Result> { + // a two-byte packet expands to at most 128 values: a header asking for more is lying + let bound = data.len().saturating_mul(64).saturating_add(128); + if pixels > bound { + return Err(err(name, "truncated run-length data")); + } + let total = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + let mut out = Vec::with_capacity(total); + while out.len() < total { + let (&head, rest) = data.split_first().ok_or_else(|| err(name, "truncated run-length data"))?; + let count = usize::from(head & 0x7F) + 1; + let want = (total - out.len()).min(count * bpp); + if head & 0x80 != 0 { + let v = rest.get(..bpp).ok_or_else(|| err(name, "truncated run-length data"))?; + for _ in 0..want / bpp { + out.extend_from_slice(v); + } + data = rest.get(bpp..).unwrap_or_default(); + } else { + let n = count * bpp; + let v = rest.get(..n).ok_or_else(|| err(name, "truncated run-length data"))?; + out.extend_from_slice(v.get(..want).unwrap_or(v)); + data = rest.get(n..).unwrap_or_default(); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec { + let mut b = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + b.extend_from_slice(&w.to_le_bytes()); + b.extend_from_slice(&h.to_le_bytes()); + b.extend_from_slice(&[depth, descriptor]); + b + } + + #[test] + fn bottom_up_bgr_is_flipped() { + let mut f = header(2, 2, 2, 24, 0); + // bottom row first: blue, green; then top row: red, white + f.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.width, r.height, r.has_alpha), (2, 2, false)); + assert_eq!(r.rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]); + } + + #[test] + fn rle_runs_and_raw_packets_cross_lines() { + let mut f = header(10, 3, 2, 32, 0x28); + // a run of 4 translucent red, then 2 raw pixels + f.extend_from_slice(&[0x83, 0, 0, 255, 128, 0x01, 1, 2, 3, 255, 4, 5, 6, 255]); + let r = decode("a.tga", &f).unwrap(); + assert!(r.has_alpha); + assert_eq!(&r.rgba[..16], &[255, 0, 0, 128].repeat(4)[..]); + assert_eq!(&r.rgba[16..], &[3, 2, 1, 255, 6, 5, 4, 255]); + } + + #[test] + fn colour_map_and_16_bit_alpha() { + let mut f = header(1, 2, 1, 8, 0x20); + f[1] = 1; // colour map present + f[3..5].copy_from_slice(&1u16.to_le_bytes()); // first entry index 1 + f[5..7].copy_from_slice(&2u16.to_le_bytes()); + f[7] = 24; + f.extend_from_slice(&[10, 20, 30, 40, 50, 60, 2, 1]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [60, 50, 40, 255, 30, 20, 10, 255]); + f[18 + 6] = 0; // index 0 is below the map's first entry + assert!(decode("a.tga", &f).is_err()); + + let mut f = header(2, 2, 1, 16, 0x21); + f.extend_from_slice(&0xFC00u16.to_le_bytes()); // opaque full red + f.extend_from_slice(&0x03E0u16.to_le_bytes()); // transparent full green + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + } + + #[test] + fn zero_alpha_32_bit_without_attribute_bits_is_opaque() { + let mut f = header(2, 1, 1, 32, 0); + f.extend_from_slice(&[1, 2, 3, 0]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.rgba.as_slice(), r.has_alpha), (&[3, 2, 1, 255][..], false)); + } + + #[test] + fn hostile_headers_fail_without_allocating() { + // 65535 × 65535 RLE from a few bytes, truncated raw data, bad types + let mut f = header(10, 65535, 65535, 32, 0); + f.extend_from_slice(&[0xFF, 1, 2, 3, 4]); + assert!(decode("a.tga", &f).is_err()); + let mut f = header(2, 4, 4, 24, 0); + f.extend_from_slice(&[0; 10]); + assert!(decode("a.tga", &f).is_err()); + assert!(decode("a.tga", &header(2, 1, 1, 7, 0)).is_err()); + assert!(decode("a.tga", &header(1, 1, 1, 8, 0)).is_err()); + assert!(decode("a.tga", &[0; 5]).is_err()); + for n in 0..40 { + let mut f = header(9, 3, 3, 8, 0); + f[1] = 1; + f[5] = 2; + f[7] = 16; + f.extend_from_slice(&[0x82, 1, 0, 1, 0x01, 0, 0]); + f.truncate(n); + let _ = decode("a.tga", &f); + } + } + + #[test] + fn sniffs_by_footer_or_name() { + let mut f = header(2, 1, 1, 24, 0); + f.extend_from_slice(&[0, 0, 0]); + assert!(sniff("x.TGA", &f)); + assert!(!sniff("x.bin", &f)); + f.extend_from_slice(&[0; 8]); + f.extend_from_slice(FOOTER); + assert!(sniff("x.bin", &f)); + assert!(!sniff("x.tga", b"\x89PNG\r\n\x1a\n0000000000")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b62d7853..2ac9b655 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -56,8 +56,8 @@ only). "Tested" names the evidence. | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% | -| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% | -| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% | +| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% | +| Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | @@ -84,7 +84,7 @@ only). "Tested" names the evidence. ## Summary **File formats dimension: ~60%, 140–240 h** (codecs counted separately). The beta blocker is -`.prproj`. After it, by user impact: AVI and WMV import, EXR / DPX / PSD stills, AAF / OMF +`.prproj`. After it, by user impact: AVI and WMV import, EXR / PSD stills, AAF / OMF validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b3..8b4b1bb5 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,9 +165,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** substreams (played as the 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / - JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; - transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV - multi-audio (#601). + JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer + DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import + since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in + `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). From 116364cf083bf3e6c7aa95f5d71318ae366ee802 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:58:45 +0200 Subject: [PATCH 061/203] #820: fall back to DX12, then a software adapter, when the graphics start fails Problem: a graphics driver that crashes inside wgpu instance or device creation (Intel Vulkan igvk64.dll #512 #413 #793, AMD atio6axx.dll #477) killed every launch the same way until the user set WGPU_BACKEND by hand; a start-up error wgpu did report ended in an error dialog. Fix: each start leaves a marker in the data folder (graphics-start.json), cleared once the window has painted two frames. A marker still pending at the next launch moves on along the platform's ladder (Windows: Vulkan+DX12, DX12 only, software/WARP; Linux: default, software/lavapipe or llvmpipe) and keeps the mode that works. A wgpu start-up error moves on within the same process (eframe reuses its event loop). Fallback modes don't register the GPU export renderer, whose own instance would load the failed driver. An explicit WGPU_BACKEND leaves the marker alone. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/filmcraft/Cargo.toml | 2 + apps/filmcraft/src/args.rs | 2 +- apps/filmcraft/src/graphics.rs | 391 +++++++++++++++++++++++++++++++-- apps/filmcraft/src/main.rs | 361 ++++++++++++++++-------------- docs/gaps.md | 12 +- 6 files changed, 582 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5529201a..77d37854 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1687,6 +1687,7 @@ dependencies = [ "objc2-app-kit 0.3.2", "objc2-foundation 0.3.2", "open", + "pollster 0.4.0", "rfd", "serde_json", "sys-locale", diff --git a/apps/filmcraft/Cargo.toml b/apps/filmcraft/Cargo.toml index 85175489..3ae35e6c 100644 --- a/apps/filmcraft/Cargo.toml +++ b/apps/filmcraft/Cargo.toml @@ -41,6 +41,8 @@ open = "5" [dev-dependencies] glob = "0.3" +# graphics.rs: enumerate the real adapters in a test. +pollster = "0.4" [target.'cfg(target_os = "macos")'.dependencies] muda = "0.21" diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a968..c923112c 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -26,7 +26,7 @@ pub enum Cli { } /// Options of a normal start. -#[derive(Debug, PartialEq)] +#[derive(Clone, Debug, PartialEq)] pub struct Launch { pub control_port: Option, /// A project and / or media files to open. diff --git a/apps/filmcraft/src/graphics.rs b/apps/filmcraft/src/graphics.rs index b2be99cb..511ec982 100644 --- a/apps/filmcraft/src/graphics.rs +++ b/apps/filmcraft/src/graphics.rs @@ -1,33 +1,237 @@ -//! Windows graphics instance configuration, applied before wgpu can initialize any driver. +//! Graphics start-up: the wgpu backends and adapter the window is created with, and the fallback +//! when a start fails (docs/gaps.md G4, #820). +//! +//! A graphics driver can crash inside wgpu instance or device creation (Intel's Vulkan driver +//! `igvk64.dll`, #512 #413; AMD's OpenGL driver `atio6axx.dll`, #477), where no Rust error +//! handling runs, so every launch died the same way until the user set `WGPU_BACKEND` by hand. +//! Each start now leaves a marker in the data folder ([`STATE_FILE`]) that is cleared once the +//! window has painted. A marker still pending at the next launch means that start died: the launch +//! moves on to the next [`Mode`] of the platform's [`LADDER`] and keeps it. A start-up error that +//! wgpu does report (no adapter, device creation failed) moves on within the same process. An +//! explicit `WGPU_BACKEND` leaves the marker alone: the user is in charge. -use eframe::{NativeOptions, egui_wgpu::WgpuSetup, wgpu::Backends}; +use std::io::Read as _; +use std::path::{Path, PathBuf}; +use std::sync::Arc; -/// Restrict the wgpu instance to the primary backends (Vulkan, DirectX 12, Metal), i.e. leave -/// OpenGL out, unless `backend_override` (normally `Backends::from_env()`, i.e. `WGPU_BACKEND`) -/// names other backends. -pub fn configure(options: &mut NativeOptions, backend_override: Option) { - if let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { - // eframe's default includes GL. Creating that backend can crash inside AMD's - // atio6axx.dll before adapter selection or any Rust error handling runs, so the - // window never appears. Choosing an adapter afterwards is too late: only leaving GL - // out of the instance avoids it. Keep Vulkan and DX12 both: forcing DX12 alone sends - // shaders through FXC (which rejects fx.wgsl, silently falling back to the CPU) and - // leaves machines without DX12 with no backend at all. - create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY); +use eframe::{ + NativeOptions, + egui_wgpu::WgpuSetup, + wgpu::{self, Backends}, +}; + +/// The start marker in the data folder: `{"mode": "", "pending": }`. Delete it to +/// start with the default graphics mode again. +pub const STATE_FILE: &str = "graphics-start.json"; + +/// How the window's graphics device is created. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// The platform default: Vulkan or DirectX 12 on Windows (no OpenGL, see [`apply`]), Metal on + /// macOS, Vulkan or OpenGL elsewhere. + Default, + /// DirectX 12 only (Windows), for Vulkan drivers that crash at start-up. + Dx12, + /// A software adapter (WARP on Windows, lavapipe / llvmpipe elsewhere); the preview then + /// composites on the CPU (`FilmcraftApp::set_wgpu`). + Software, +} + +impl Mode { + pub fn name(self) -> &'static str { + match self { + Mode::Default => "default", + Mode::Dx12 => "dx12", + Mode::Software => "software", + } } + + fn parse(name: &str) -> Option { + [Mode::Default, Mode::Dx12, Mode::Software].into_iter().find(|m| m.name() == name) + } +} + +/// The modes tried in order on this platform. macOS has only Metal, and no software adapter. +pub const LADDER: &[Mode] = if cfg!(target_os = "windows") { + &[Mode::Default, Mode::Dx12, Mode::Software] +} else if cfg!(target_os = "macos") { + &[Mode::Default] +} else { + &[Mode::Default, Mode::Software] +}; + +/// The mode after `mode` in `ladder`, if any. +fn next_in(ladder: &[Mode], mode: Mode) -> Option { + let i = ladder.iter().position(|m| *m == mode)?; + ladder.get(i.checked_add(1)?).copied() +} + +/// This launch's mode from the saved marker (`(mode, pending)`; `None` when there is none or it +/// is unreadable), and whether it moved on because the last start died. +fn launch_mode(saved: Option<(Mode, bool)>, ladder: &[Mode]) -> (Mode, bool) { + let first = ladder.first().copied().unwrap_or(Mode::Default); + match saved { + // A mode this platform does not use (a data folder copied from another OS): start over. + Some((mode, _)) if !ladder.contains(&mode) => (first, false), + Some((mode, true)) => match next_in(ladder, mode) { + Some(next) => (next, true), + // Nothing left to try: the last mode is still the best chance. + None => (mode, false), + }, + Some((mode, false)) => (mode, false), + None => (first, false), + } +} + +/// `(mode, pending)` from the marker's text; `None` for anything else. +fn parse_state(text: &str) -> Option<(Mode, bool)> { + let v: serde_json::Value = serde_json::from_str(text).ok()?; + Some((Mode::parse(v.get("mode")?.as_str()?)?, v.get("pending")?.as_bool()?)) +} + +fn read_state(path: &Path) -> Option<(Mode, bool)> { + // The marker is a few bytes; anything longer is not ours. + let mut text = String::new(); + std::fs::File::open(path).ok()?.take(4096).read_to_string(&mut text).ok()?; + parse_state(&text) +} + +fn write_state(path: &Path, mode: Mode, pending: bool) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + // Written beside and renamed, so a start that dies mid-write leaves the old marker whole. + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, text)?; + std::fs::rename(&tmp, path) +} + +/// This launch's graphics mode and its start marker. +#[derive(Clone, Debug)] +pub struct GraphicsStart { + /// The marker; `None` without a data folder or with `WGPU_BACKEND` set. + path: Option, + mode: Mode, +} + +impl GraphicsStart { + /// Pick this launch's mode from the marker in `data_dir` and mark the start pending. + /// `explicit_backend` (`WGPU_BACKEND` set) starts in [`Mode::Default`] and leaves the marker + /// alone. + pub fn begin(data_dir: Option<&Path>, explicit_backend: bool) -> Self { + let path = if explicit_backend { None } else { data_dir.map(|d| d.join(STATE_FILE)) }; + let saved = path.as_deref().and_then(read_state); + let (mode, moved_on) = launch_mode(saved, LADDER); + let file = path.as_deref().map(|p| p.display().to_string()).unwrap_or_default(); + if moved_on { + let failed = saved.map_or("default", |(m, _)| m.name()); + log::warn!( + "the last start did not get its window up with graphics mode {failed}; starting with {} (delete {file} to try the default again)", + mode.name() + ); + } else if mode != Mode::Default { + log::info!("graphics mode {} after an earlier failed start (delete {file} to try the default again)", mode.name()); + } + let start = Self { path, mode }; + start.save(true); + start + } + + pub fn mode(&self) -> Mode { + self.mode + } + + /// Move on to the next mode after a start-up error wgpu reported; `false` when none is left. + pub fn step_down(&mut self) -> bool { + let Some(next) = next_in(LADDER, self.mode) else { + return false; + }; + log::warn!("graphics mode {} failed to start; trying {}", self.mode.name(), next.name()); + self.mode = next; + self.save(true); + true + } + + /// The window is up: keep this mode for the next launches. + pub fn confirm(&self) { + self.save(false); + } + + fn save(&self, pending: bool) { + if let Some(path) = &self.path + && let Err(e) = write_state(path, self.mode, pending) + { + log::warn!("can't write {}: {e}", path.display()); + } + } +} + +/// Configure `options` for `mode`, before eframe creates the wgpu instance. `backend_override` +/// is normally `Backends::from_env()`, i.e. `WGPU_BACKEND`. +pub fn apply(options: &mut NativeOptions, mode: Mode, backend_override: Option) { + apply_for(options, mode, backend_override, cfg!(target_os = "windows")); +} + +fn apply_for(options: &mut NativeOptions, mode: Mode, backend_override: Option, windows: bool) { + let WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup else { + return; + }; + match mode { + // eframe's default (`WGPU_BACKEND`, else PRIMARY | GL), except on Windows: there it + // includes GL. Creating that backend can crash inside AMD's atio6axx.dll before adapter + // selection or any Rust error handling runs, so the window never appears. Choosing an + // adapter afterwards is too late: only leaving GL out of the instance avoids it. Keep + // Vulkan and DX12 both: forcing DX12 alone sends shaders through FXC (which rejects + // fx.wgsl, silently falling back to the CPU) and leaves machines without DX12 with no + // backend at all. + Mode::Default if windows => create.instance_descriptor.backends = backend_override.unwrap_or(Backends::PRIMARY), + Mode::Default => {} + // Without Vulkan, whose driver is what crashes; GPU effects may fall back to the CPU. + Mode::Dx12 => create.instance_descriptor.backends = Backends::DX12, + Mode::Software => { + create.instance_descriptor.backends = software_backends(windows); + create.native_adapter_selector = Some(Arc::new(pick_software)); + } + } +} + +/// The backends a software adapter is looked for in: WARP is a DX12 adapter, and GL stays out on +/// Windows for the reason in [`apply_for`]. +fn software_backends(windows: bool) -> Backends { + if windows { Backends::DX12 } else { Backends::VULKAN | Backends::GL } +} + +/// The first software adapter that can draw to the window's surface. +fn pick_software(adapters: &[wgpu::Adapter], surface: Option<&wgpu::Surface<'_>>) -> Result { + adapters + .iter() + .find(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_some() && surface.is_none_or(|s| a.is_surface_supported(s))) + .cloned() + .ok_or_else(|| format!("no software graphics adapter (WARP, lavapipe, llvmpipe) among {} adapters", adapters.len())) +} + +/// Whether `err` from `eframe::run_native` is a graphics start-up failure that another mode may +/// avoid (as opposed to a display, event-loop or app error). +pub fn is_graphics_failure(err: &eframe::Error) -> bool { + matches!(err, eframe::Error::Wgpu(_)) } #[cfg(test)] mod tests { use super::*; - fn configured_backends(backend_override: Option) -> Backends { + const WINDOWS: &[Mode] = &[Mode::Default, Mode::Dx12, Mode::Software]; + const LINUX: &[Mode] = &[Mode::Default, Mode::Software]; + const MACOS: &[Mode] = &[Mode::Default]; + + fn configured(mode: Mode, backend_override: Option, windows: bool) -> (Backends, bool) { let mut options = NativeOptions::default(); - configure(&mut options, backend_override); + apply_for(&mut options, mode, backend_override, windows); let WgpuSetup::CreateNew(create) = options.wgpu_options.wgpu_setup else { panic!("default native options must create a wgpu instance"); }; - create.instance_descriptor.backends + (create.instance_descriptor.backends, create.native_adapter_selector.is_some()) } #[test] @@ -37,16 +241,159 @@ mod tests { panic!("default native options must create a wgpu instance"); }; assert!(eframe_default.instance_descriptor.backends.contains(Backends::GL)); - let configured = configured_backends(None); - assert_eq!(configured, Backends::PRIMARY); - assert!(!configured.contains(Backends::GL)); - assert!(configured.contains(Backends::DX12) && configured.contains(Backends::VULKAN)); + let (backends, selector) = configured(Mode::Default, None, true); + assert_eq!(backends, Backends::PRIMARY); + assert!(!backends.contains(Backends::GL)); + assert!(backends.contains(Backends::DX12) && backends.contains(Backends::VULKAN)); + assert!(!selector); } #[test] fn explicit_backend_override_is_preserved() { for backends in [Backends::VULKAN, Backends::GL, Backends::DX12, Backends::VULKAN | Backends::DX12, Backends::empty()] { - assert_eq!(configured_backends(Some(backends)), backends); + assert_eq!(configured(Mode::Default, Some(backends), true).0, backends); } } + + #[test] + fn fallback_modes_leave_out_the_crashing_backends() { + // DX12 only: Intel's and AMD's Vulkan drivers are what crashed (#512, #413). + assert_eq!(configured(Mode::Dx12, None, true), (Backends::DX12, false)); + // Software on Windows is WARP (DX12), never GL (atio6axx.dll, #477). + assert_eq!(configured(Mode::Software, None, true), (Backends::DX12, true)); + assert_eq!(configured(Mode::Software, None, false), (Backends::VULKAN | Backends::GL, true)); + // Off Windows the default is eframe's own. + let WgpuSetup::CreateNew(eframe_default) = NativeOptions::default().wgpu_options.wgpu_setup else { + panic!("default native options must create a wgpu instance"); + }; + assert_eq!(configured(Mode::Default, None, false), (eframe_default.instance_descriptor.backends, false)); + } + + #[test] + fn a_start_that_died_moves_on_to_the_next_mode_and_keeps_it() { + // first launch + assert_eq!(launch_mode(None, WINDOWS), (Mode::Default, false)); + // the default start died in the driver: DX12 next, then software + assert_eq!(launch_mode(Some((Mode::Default, true)), WINDOWS), (Mode::Dx12, true)); + assert_eq!(launch_mode(Some((Mode::Dx12, true)), WINDOWS), (Mode::Software, true)); + assert_eq!(launch_mode(Some((Mode::Default, true)), LINUX), (Mode::Software, true)); + // a confirmed mode is kept + assert_eq!(launch_mode(Some((Mode::Dx12, false)), WINDOWS), (Mode::Dx12, false)); + assert_eq!(launch_mode(Some((Mode::Default, false)), WINDOWS), (Mode::Default, false)); + // nothing left to try: stay + assert_eq!(launch_mode(Some((Mode::Software, true)), WINDOWS), (Mode::Software, false)); + assert_eq!(launch_mode(Some((Mode::Default, true)), MACOS), (Mode::Default, false)); + // a mode this platform has not: start over + assert_eq!(launch_mode(Some((Mode::Dx12, false)), LINUX), (Mode::Default, false)); + assert_eq!(launch_mode(Some((Mode::Software, true)), MACOS), (Mode::Default, false)); + assert_eq!(launch_mode(None, &[]), (Mode::Default, false)); + } + + #[test] + fn the_marker_round_trips_and_rejects_anything_else() { + for mode in WINDOWS { + for pending in [false, true] { + let text = serde_json::json!({ "mode": mode.name(), "pending": pending }).to_string(); + assert_eq!(parse_state(&text), Some((*mode, pending))); + } + } + for bad in [ + "", + "{", + "null", + "[]", + r#"{"mode":"vulkan","pending":true}"#, + r#"{"mode":"dx12"}"#, + r#"{"mode":1,"pending":true}"#, + r#"{"mode":"dx12","pending":"yes"}"#, + ] { + assert_eq!(parse_state(bad), None, "{bad}"); + } + } + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("filmcraft-graphics-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + dir + } + + #[test] + fn begin_marks_the_start_pending_and_confirm_clears_it() { + let dir = temp_dir("marker"); + let file = dir.join(STATE_FILE); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + assert_eq!(read_state(&file), Some((Mode::Default, true))); + start.confirm(); + assert_eq!(read_state(&file), Some((Mode::Default, false))); + // a confirmed start is kept on the next launch + assert_eq!(GraphicsStart::begin(Some(&dir), false).mode(), Mode::Default); + // ... and one that never confirmed moves on (Windows and Linux; macOS has nowhere to go) + let next = GraphicsStart::begin(Some(&dir), false); + assert_eq!(next.mode(), LADDER.get(1).copied().unwrap_or(Mode::Default)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_corrupt_or_huge_marker_starts_with_the_default() { + let dir = temp_dir("corrupt"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + for text in ["garbage".to_string(), format!(r#"{{"mode":"dx12","pending":true,"pad":"{}"}}"#, "x".repeat(10_000))] { + let _ = std::fs::write(&file, text); + let start = GraphicsStart::begin(Some(&dir), false); + assert_eq!(start.mode(), Mode::Default); + // and it is replaced with a well-formed pending marker + assert_eq!(read_state(&file), Some((Mode::Default, true))); + } + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn an_explicit_backend_leaves_the_marker_alone() { + let dir = temp_dir("explicit"); + let file = dir.join(STATE_FILE); + let _ = std::fs::create_dir_all(&dir); + let _ = write_state(&file, Mode::Software, true); + let mut start = GraphicsStart::begin(Some(&dir), true); + assert_eq!(start.mode(), Mode::Default); + start.confirm(); + let _ = start.step_down(); + assert_eq!(read_state(&file), Some((Mode::Software, true))); + // no data folder: nothing is written, the default still starts + assert_eq!(GraphicsStart::begin(None, false).mode(), Mode::Default); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn step_down_walks_the_ladder_and_records_each_attempt() { + let dir = temp_dir("step"); + let file = dir.join(STATE_FILE); + let mut start = GraphicsStart::begin(Some(&dir), false); + for expected in LADDER.iter().skip(1) { + assert!(start.step_down()); + assert_eq!(start.mode(), *expected); + assert_eq!(read_state(&file), Some((*expected, true))); + } + assert!(!start.step_down(), "nothing after the last mode"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn the_software_selector_only_picks_a_software_adapter() { + if wgpu::Instance::enabled_backend_features().is_empty() { + return; + } + // only the backends software mode uses: no driver this test loads is one the app avoids + let backends = software_backends(cfg!(target_os = "windows")); + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends, ..wgpu::InstanceDescriptor::new_without_display_handle() }); + let adapters = pollster::block_on(instance.enumerate_adapters(backends)); + match pick_software(&adapters, None) { + Ok(adapter) => assert!(filmcraft_ui_egui::software_rasterizer(&adapter.get_info()).is_some()), + Err(why) => { + assert!(adapters.iter().all(|a| filmcraft_ui_egui::software_rasterizer(&a.get_info()).is_none()), "{why}"); + } + } + assert!(pick_software(&[], None).is_err()); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 591bc00f..86bac782 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -31,7 +31,6 @@ mod control_server; #[cfg(target_os = "linux")] mod dev_icon; mod file_filters; -#[cfg(any(target_os = "windows", test))] mod graphics; mod logging; #[cfg(target_os = "macos")] @@ -44,6 +43,8 @@ use filmcraft_engine::Session; use filmcraft_engine::autosave::{AutosaveConfig, default_data_dir}; use filmcraft_ui_egui::FilmcraftApp; use serde_json::json; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; /// Reverse-DNS app id: Linux `.desktop` file, hicolor icon name and Wayland app id. const APP_ID: &str = "ai.storyteller.filmcraft"; @@ -80,7 +81,7 @@ fn main() -> eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let launch = match args::parse(env_port, std::env::args().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -95,7 +96,8 @@ fn main() -> eframe::Result { std::process::exit(2); } }; - let log_dir = data_dir.clone().or_else(default_data_dir).map(|d| d.join(logging::LOG_DIR)); + let data_root = launch.data_dir.clone().or_else(default_data_dir); + let log_dir = data_root.as_ref().map(|d| d.join(logging::LOG_DIR)); log::info!("FilmCraft {} ({} {})", env!("CARGO_PKG_VERSION"), std::env::consts::OS, std::env::consts::ARCH); // The log file lives next to the crash logs; opened after the arguments, so `--help`, // `--version` and usage errors leave no file behind. Records logged until now go in first. @@ -118,165 +120,35 @@ fn main() -> eframe::Result { // OS hardware video decoders (VideoToolbox on macOS) in front of our own; Settings ▸ Playback ▸ // Hardware decoding switches them off. Unsupported streams and failures use our decoders. register_hardware_decoders(); - register_gpu_frame_renderer(); - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("FilmCraft") - .with_inner_size([1600.0, 980.0]) - .with_min_inner_size([900.0, 560.0]) - .with_drag_and_drop(true) - .with_fullsize_content_view(true) - .with_titlebar_shown(false) - .with_title_shown(false) - // Started for an agent (`--control`): open without taking the user's keyboard focus. - .with_active(control_port.is_none()) - .with_icon(app_icon()) - // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. - .with_app_id(APP_ID), - event_loop_builder: agent_event_loop(control_port.is_some()), - ..Default::default() + // Which graphics backends and adapter the window starts with: the mode after the last one + // if the previous start died before its window painted (a driver crash), see graphics.rs. + let backend_override = eframe::wgpu::Backends::from_env(); + let mut graphics = graphics::GraphicsStart::begin(data_root.as_deref(), backend_override.is_some()); + // The GPU export renderer creates its own wgpu instance with every backend; after a start + // fell back, that would load the driver that failed. Exports then composite on the CPU. + if graphics.mode() == graphics::Mode::Default { + register_gpu_frame_renderer(); + } + let started = loop { + let mut options = native_options(launch.control_port.is_some()); + // Before eframe creates the wgpu instance (e.g. no OpenGL on Windows, see graphics.rs). + graphics::apply(&mut options, graphics.mode(), backend_override); + let app_created = Arc::new(AtomicBool::new(false)); + let result = eframe::run_native("FilmCraft", options, app_creator(launch.clone(), graphics.clone(), Arc::clone(&app_created))); + let app_created = app_created.load(Ordering::Relaxed); + match &result { + // wgpu reported the failure (no adapter, device creation failed) before the app + // existed: try the next mode in this process. + Err(e) if !app_created && graphics::is_graphics_failure(e) && graphics.step_down() => { + log::warn!("graphics start failed: {e}"); + continue; + } + // Closed before the second frame: the window still came up. + Ok(()) if app_created => graphics.confirm(), + _ => {} + } + break result; }; - // Before eframe creates the wgpu instance: leave OpenGL out on Windows (see graphics.rs). - #[cfg(target_os = "windows")] - let options = { - let mut options = options; - graphics::configure(&mut options, eframe::wgpu::Backends::from_env()); - options - }; - let started = eframe::run_native( - "FilmCraft", - options, - Box::new(move |cc| { - let mut session = Session::default(); - if let Some(dir) = data_dir.clone().or_else(default_data_dir) { - let mut cfg = AutosaveConfig::new(dir); - cfg.local_offset = local_offset; - if let Err(e) = session.start_autosave(cfg) { - log::warn!("auto-save and crash recovery unavailable: {e}"); - } - } - // voice-over recording reads the microphone through cpal - session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); - let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); - if let Some(p) = project { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { - // Settings ▸ General ▸ At Startup ▸ Open Most Recent - let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); - match recent { - Some(p) => { - if let Err(e) = session.execute("file.open", json!({"path": p})) { - log::error!("{e}"); - } - } - None => { - let _ = session.execute("file.openDemoProject", json!({})); - } - } - } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { - } else if demo { - let _ = session.execute("file.openDemoProject", json!({})); - } - let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); - if !media.is_empty() { - let _ = session.execute("file.import", json!({"paths": media})); - } - if recover == Some(true) && !session.recovery_candidates().is_empty() { - let id = session.recovery_candidates()[0].id.clone(); - match session.execute("file.recover", json!({"id": id})) { - Ok(r) => log::info!("recovered {r}"), - Err(e) => log::error!("recovery failed: {e}"), - } - } - let mut app = FilmcraftApp::new(session); - if recover == Some(false) { - app.dialog = None; - } - app.integrated_titlebar = cfg!(target_os = "macos"); - if let Some(rs) = cc.wgpu_render_state.clone() - && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() - { - app.set_wgpu(rs); - } - // Keep device selection available even when the default device is unavailable. - // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). - app.audio = Some(Box::new(audio::CpalOut::new())); - app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_files() - .unwrap_or_default() - .into_iter() - .map(|p| p.to_string_lossy().to_string()) - .collect() - })); - // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. - app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save = Some(Box::new(|name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .set_file_name(name) - .save_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); - app.hooks.pick_folder_at = - Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); - app.hooks.open_path = Some(Box::new(open_path)); - // Help menu, About and Discord links (#642): eframe is built without its `links` - // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher - // that waits for the browser never freezes the UI. - app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); - app.hooks.clipboard_media = Some(Box::new(clipboard_media)); - // Settings ▸ General ▸ Interface Language ▸ System Language (#218). - app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); - app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); - // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose - // compositor reports no theme to winit (no polling: see appearance.rs). - app.hooks.system_theme = appearance::service(); - app.hooks.raise_without_focus = Some(Box::new(|| { - window_raise::raise_without_focus(); - })); - app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - app.hooks.pick_open_project = Some(Box::new(|| { - rfd::FileDialog::new() - .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) - .pick_file() - .map(|p| p.to_string_lossy().to_string()) - })); - #[cfg(target_os = "macos")] - { - let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); - app.command_inbox = Some(rx); - app.hooks.shortcuts_changed = Some(update); - app.ui.show_menu_bar = false; - } - if let Some(port) = control_port { - let rx = control_server::start(port, cc.egui_ctx.clone()); - app = app.with_control(rx); - } - Ok(Box::new(app)) - }), - ); // The window could not be created (no usable GPU / graphics driver, no display): say so // instead of exiting silently, and keep the reason in the crash log. if let Err(e) = &started { @@ -288,6 +160,173 @@ fn main() -> eframe::Result { started } +/// The main window's options; built for each start attempt (they hold a one-shot hook). +fn native_options(agent: bool) -> eframe::NativeOptions { + eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("FilmCraft") + .with_inner_size([1600.0, 980.0]) + .with_min_inner_size([900.0, 560.0]) + .with_drag_and_drop(true) + .with_fullsize_content_view(true) + .with_titlebar_shown(false) + .with_title_shown(false) + // Started for an agent (`--control`): open without taking the user's keyboard focus. + .with_active(!agent) + .with_icon(app_icon()) + // Wayland matches the window to `packaging/linux/ai.storyteller.filmcraft.desktop`. + .with_app_id(APP_ID), + event_loop_builder: agent_event_loop(agent), + ..Default::default() + } +} + +/// Creates the app once eframe has its window and graphics device; sets `created` when it runs, +/// and confirms the graphics mode once two frames have painted. +fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc) -> eframe::AppCreator<'static> { + let Launch { control_port, files, demo, startup_flag, recover, data_dir } = launch; + Box::new(move |cc| { + created.store(true, Ordering::Relaxed); + let confirmed = AtomicBool::new(false); + cc.egui_ctx.on_end_pass( + "graphics-start", + Arc::new(move |ui| { + if ui.ctx().cumulative_frame_nr() >= 2 && !confirmed.swap(true, Ordering::Relaxed) { + graphics.confirm(); + } + }), + ); + let mut session = Session::default(); + if let Some(dir) = data_dir.clone().or_else(default_data_dir) { + let mut cfg = AutosaveConfig::new(dir); + cfg.local_offset = local_offset; + if let Err(e) = session.start_autosave(cfg) { + log::warn!("auto-save and crash recovery unavailable: {e}"); + } + } + // voice-over recording reads the microphone through cpal + session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); + let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); + if let Some(p) = project { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } else if !startup_flag && session.prefs.general.at_startup == "openMostRecent" { + // Settings ▸ General ▸ At Startup ▸ Open Most Recent + let recent = session.prefs.general.recent_projects.iter().find(|p| std::path::Path::new(p).exists()).cloned(); + match recent { + Some(p) => { + if let Err(e) = session.execute("file.open", json!({"path": p})) { + log::error!("{e}"); + } + } + None => { + let _ = session.execute("file.openDemoProject", json!({})); + } + } + } else if !startup_flag && session.prefs.general.at_startup == "emptyProject" { + } else if demo { + let _ = session.execute("file.openDemoProject", json!({})); + } + let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); + if !media.is_empty() { + let _ = session.execute("file.import", json!({"paths": media})); + } + if recover == Some(true) && !session.recovery_candidates().is_empty() { + let id = session.recovery_candidates()[0].id.clone(); + match session.execute("file.recover", json!({"id": id})) { + Ok(r) => log::info!("recovered {r}"), + Err(e) => log::error!("recovery failed: {e}"), + } + } + let mut app = FilmcraftApp::new(session); + if recover == Some(false) { + app.dialog = None; + } + app.integrated_titlebar = cfg!(target_os = "macos"); + if let Some(rs) = cc.wgpu_render_state.clone() + && std::env::var_os("FILMCRAFT_CPU_COMPOSITE").is_none() + { + app.set_wgpu(rs); + } + // Keep device selection available even when the default device is unavailable. + // Settings ▸ Audio Hardware is applied on the first frame (`apply_prefs`). + app.audio = Some(Box::new(audio::CpalOut::new())); + app.hooks.pick_files = Some(Box::new(|exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })); + // Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: one path, not imported. + app.hooks.pick_file_for_relink = Some(Box::new(|exts: &[&str], _hint| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("Media"), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save = Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_save_as = Some(Box::new(|filter: &str, exts: &[&str], name: &str| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string()))); + app.hooks.pick_folder_at = + Some(Box::new(|dir: &str| rfd::FileDialog::new().set_directory(dir).pick_folder().map(|p| p.to_string_lossy().into_owned()))); + app.hooks.open_path = Some(Box::new(open_path)); + // Help menu, About and Discord links (#642): eframe is built without its `links` + // feature, so `ctx.open_url` does nothing on the desktop. Detached, so a launcher + // that waits for the browser never freezes the UI. + app.hooks.open_url = Some(Box::new(|url: &str| open::that_detached(url).map_err(|e| e.to_string()))); + app.hooks.clipboard_media = Some(Box::new(clipboard_media)); + // Settings ▸ General ▸ Interface Language ▸ System Language (#218). + app.hooks.cursor_screen_position = Some(Box::new(filmcraft_platform::cursor::cursor_screen_position)); + app.hooks.system_languages = Some(Box::new(|| sys_locale::get_locales().collect())); + // Settings ▸ Appearance ▸ Appearance Mode ▸ Sync with system on Linux desktops whose + // compositor reports no theme to winit (no polling: see appearance.rs). + app.hooks.system_theme = appearance::service(); + app.hooks.raise_without_focus = Some(Box::new(|| { + window_raise::raise_without_focus(); + })); + app.hooks.pick_open_file = Some(Box::new(|filter: &str, exts: &[&str]| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts)) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + app.hooks.pick_open_project = Some(Box::new(|| { + rfd::FileDialog::new() + .add_filter(filmcraft_ui_egui::i18n::t("FilmCraft Project"), &file_filters::extensions(&["fcproj"])) + .pick_file() + .map(|p| p.to_string_lossy().to_string()) + })); + #[cfg(target_os = "macos")] + { + let (rx, update) = native_menu::install(&app, cc.egui_ctx.clone()); + app.command_inbox = Some(rx); + app.hooks.shortcuts_changed = Some(update); + app.ui.show_menu_bar = false; + } + if let Some(port) = control_port { + let rx = control_server::start(port, cc.egui_ctx.clone()); + app = app.with_control(rx); + } + Ok(Box::new(app)) + }) +} + /// Files copied in the file manager, else an image, on the system clipboard (Paste, #611). fn clipboard_media() -> Option { let mut clipboard = arboard::Clipboard::new().ok()?; diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b3..7d3bd17b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -79,6 +79,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G4. Crashes at start-up, and no test CI on pull requests +- **Have:** a graphics start-up fallback (#820): a start that dies before its window paints (a + driver crash in wgpu instance / device creation) leaves a marker in the data folder, and the next + launch moves on to DirectX 12 only (Windows), then a software adapter (WARP, lavapipe, llvmpipe) + with CPU compositing, and keeps the mode that works; a start-up error wgpu reports moves on in + the same process. An explicit `WGPU_BACKEND` stays in charge. - **Missing:** a launch that never fails, and CI that runs the gates. - **Evidence:** open issues: macOS 12 Monterey aborts on a missing VideoToolbox symbol (#468, #661, #655), Intel UHD graphics (`igvk64.dll`, #512), Windows 11 does not run or fails after setup @@ -87,9 +92,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** `cargo test` or `cargo xtask ci` on a pull request**, so 243 PRs merged since 10-05 were gated by contributors' local runs (main needed "green again after the batch merges" on 10-10). - **Impact:** a user who cannot start the app gets zero value; regressions land unnoticed. -- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, a software / safe-mode - start when the GPU adapter fails, a PR workflow running `cargo xtask ci` on macOS, Windows and - Linux, and a crash-report triage pass. **40–80 h.** +- **Done when:** weak-linked or runtime-loaded OS symbols on old macOS, ~~a software / safe-mode + start when the GPU adapter fails~~ (done, #820; a way back to the default mode from Settings is + still missing: today the marker file is deleted by hand), a PR workflow running `cargo xtask ci` + on macOS, Windows and Linux, and a crash-report triage pass. **40–80 h.** ## G5. Hardware acceleration is partial From f114b5f10c73795e0bd57dbc2220174d293f7542 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:01:15 -0400 Subject: [PATCH 062/203] Revert #783 (Linux H.265 through NVENC): intermittent heap corruption in the CLI With NVENC registered on Linux, `filmcraft-cli --test cli` aborts in 2 of 45 runs on an RTX 3090 (`malloc(): mismatching next->prev_size`, `malloc(): unaligned fastbin chunk detected`) right after an export finishes; 0 of 30 runs fail with this reverted. Co-Authored-By: Claude Opus 5.5 --- README.md | 6 +-- crates/export/src/settings.rs | 2 +- crates/platform/README.md | 24 +++++------ crates/platform/src/lib.rs | 42 ++++++++----------- crates/platform/src/nvenc/export.rs | 5 ++- crates/platform/src/nvenc/mod.rs | 4 +- crates/platform/tests/nvenc_hevc.rs | 2 +- crates/platform/tests/nvenc_hevc_export.rs | 2 +- .../platform/tests/nvenc_hevc_hdr_export.rs | 2 +- crates/platform/tests/nvenc_hevc_main10.rs | 2 +- crates/platform/tests/nvenc_hevc_probe.rs | 2 +- crates/platform/tests/nvenc_hevc_warm.rs | 2 +- crates/platform/tests/nvenc_rgba_input.rs | 4 +- docs/codec-parity.md | 2 +- docs/target-app-parity.md | 4 +- docs/testing.md | 2 +- 16 files changed, 51 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index 81968754..e74820ab 100644 --- a/README.md +++ b/README.md @@ -185,7 +185,7 @@ No FFmpeg inside. The video codecs, AAC, Opus and the containers are our own Rus | **Matroska / WebM** | ✓ | | Lacing, Cues, header stripping, HDR colour metadata | | **Stills** | ✓ | ✓ | Import PNG, JPEG, GIF, WebP, TIFF and BMP; export PNG sequences and animated GIF | -Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows and Linux, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)). +Also imported: MPEG-2 / MPEG-1 video, AC-3, MP2, MXF (OP1a / OP-Atom), MPEG transport and program streams (AVCHD, broadcast, DVD), Ogg and image sequences. Not yet: AV1 export, camera RAW, E-AC-3. Hardware decoding works on macOS (VideoToolbox), Windows (Media Foundation) and, for H.264 and HEVC, Linux (VA-API); H.264 export can use NVIDIA's encoder on Windows and Linux (opt-in), and H.265 (HEVC Main, 8-bit) export works through it (NVENC on Windows, VideoToolbox on macOS) ([#30](https://github.com/storytold/filmcraft/issues/30)).
@@ -289,10 +289,10 @@ FilmCraft is in **alpha**. We track two numbers ([docs/target-app-parity.md](doc The biggest gaps today: -- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on all three (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). +- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)). - **Premiere projects.** `.prproj` files can't be opened yet; bring sequences over as FCP7 XML, AAF or OTIO. - **No plugins.** No VST3 / Audio Units or OpenFX hosting. -- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows and Linux: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. +- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet. - **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS. - **AI features.** Few so far; speech to text is optional and off by default. diff --git a/crates/export/src/settings.rs b/crates/export/src/settings.rs index 162c958a..ed0a928b 100644 --- a/crates/export/src/settings.rs +++ b/crates/export/src/settings.rs @@ -62,7 +62,7 @@ impl H264Profile { } /// Export ▸ Hardware encoding: whether H.264 may be encoded by the system's hardware video encoder -/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows and Linux) instead of FilmCraft's own encoder. Off +/// (VideoToolbox on macOS, NVENC on NVIDIA GPUs on Windows) instead of FilmCraft's own encoder. Off /// by default: hardware encoders make different streams, and exports are otherwise byte-identical /// from run to run and machine to machine. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/platform/README.md b/crates/platform/README.md index cefdfa0d..b78c5773 100644 --- a/crates/platform/README.md +++ b/crates/platform/README.md @@ -61,7 +61,7 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") - After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache always seeks after a flush, and a caller that continues from the middle of a GOP gets an error that `HybridDecoder` answers by replaying the run in software. -- **Windows and 64-bit Linux: NVIDIA NVENC H.264 and H.265 (HEVC) encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's +- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding, and H.265 (HEVC) on Windows** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's `nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time, so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed. @@ -76,8 +76,8 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") It declines two-pass VBR, HDR, MXF, interlaced output, sizes outside NVENC's limits and systems without an NVIDIA GPU or driver, and the software encoder runs instead. A failure during an export ends it with an error, since a hardware stream cannot be finished in software. The same session, - ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding - (NVENC)](#hardware-h265-hevc-encoding-nvenc). + ring and input path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows, + NVENC)](#hardware-h265-hevc-encoding-windows-nvenc). - **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the @@ -188,7 +188,7 @@ The sample entry's `hvcC` record is the one VideoToolbox wrote for the stream (r format description's sample description extension atoms, with the VPS / SPS / PPS), so profile, level and flags are the encoder's own. If it is missing the export stops with that reason. -## Hardware H.265 (HEVC) encoding (NVENC, Windows and Linux) +## Hardware H.265 (HEVC) encoding (Windows, NVENC) One codec enum (`nvenc::Codec`, chosen by `Profile::HevcMain` the way `VtProfile::HevcMain` does it for VideoToolbox) parameterises the NVENC session: the codec and profile GUIDs, the capability @@ -295,13 +295,13 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main | `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 | | `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right | | `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters | -| `tests/nvenc_hevc.rs` (Windows / Linux, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | -| `tests/nvenc_hevc_export.rs` (Windows / Linux, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | -| `tests/nvenc_rgba_input.rs` (Windows / Linux, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | -| `tests/nvenc_hevc_probe.rs` (Windows / Linux) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | -| `tests/nvenc_hevc_warm.rs` (Windows / Linux) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | -| `tests/nvenc_hevc_main10.rs` (Windows / Linux, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | -| `tests/nvenc_hevc_hdr_export.rs` (Windows / Linux, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | +| `tests/nvenc_hevc.rs` (Windows, NVIDIA with HEVC) | HEVC from NVENC (1280×720, 6 Mbps, 72 frames, keyframe every 24) decodes with our HEVC decoder at worst 48.9 dB luma / 51.0 dB chroma PSNR; IDR at 0, 24, 48; dts strictly increasing, pts a permutation, `sps_max_num_reorder_pics` within the dts shift; no parameter sets in the samples; the `hvcC`'s profile / tier / level bytes are the SPS's own; VUI BT.709 limited and timing `(1, 24)`; hostile configurations, sizes, planes and encoders dropped mid-stream give errors, never panics; keyframes every 1–2 pictures without B-frames | +| `tests/nvenc_hevc_export.rs` (Windows, NVIDIA with HEVC) | H.265 export through the real pipeline, MP4 and QuickTime, toggle Auto and Off: counters (72 frames, 1 session, 0 declined), our decoder against the software H.264 export at worst 54.8 dB luma PSNR, ffprobe `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 limited, 72 frames, 24/1, keyframes at 0 and 48, start 0, `ffmpeg -xerror` clean; with AAC audio; 1920×1080 and 642×362 cropped back from the coded size; every decline (HDR, analysis pass, two-pass, interlaced, non-square pixels, MXF, sizes over 65535, odd sizes, sizes outside the GPU's limits) an error naming NVENC, counted once; H.264 with hardware encoding Off never touches NVENC | +| `tests/nvenc_rgba_input.rs` (Windows, NVIDIA) | RGBA input, HEVC Main and H.264 High, 1280×720: solid red, green, blue, white, black and grey decode (our decoder) to the exact BT.709 limited codes of `rgba_to_yuv420_8`, within 2; a moving picture at worst 47.7 dB luma / 50.7 dB chroma PSNR against that conversion; Main 10 has no RGBA input; short pictures and planar / RGBA pictures for the other kind of encoder are errors | +| `tests/nvenc_hevc_probe.rs` (Windows) | the HEVC probe in a fresh process: its cost, the cached answer, `available(Hevc)` following it after `register()` (twice) | +| `tests/nvenc_hevc_warm.rs` (Windows) | `register()` answers the HEVC and the Main 10 questions on a thread of its own, before anyone asks; the answers are kept and `hdr_available(Hevc)` follows the Main 10 probe | +| `tests/nvenc_hevc_main10.rs` (Windows, NVIDIA with 10-bit HEVC) | HEVC Main 10 in process, PQ and HLG: a float picture (grey ramp to peak white, a moving box, saturated BT.2020 bars) is converted with `rgbf_to_yuv420_10`, encoded and decoded with our HEVC decoder (`Yuv16`, 10 bits, codes below 1024): worst 71.1 dB luma / 70.6 dB chroma PSNR on the 10-bit scale, 803 distinct luma levels on a ramp row, peak code 940 survives; IDR at 0 and 24; the VUI is BT.2020 / 16 or 18 / BT.2020 NCL / limited; the IDR samples carry two prefix SEI NAL units whose messages are byte-for-byte the expected 137 / 144 payloads (HLG: none; other pictures: none); Main and Main 10 refuse each other's pictures; hostile sizes, bitrates, signals, huge or empty SEI payloads, encoders dropped with SEI in flight | +| `tests/nvenc_hevc_hdr_export.rs` (Windows, NVIDIA with 10-bit HEVC) | HDR sequences through the real export job (PQ MP4, HLG MP4, PQ QuickTime): 24 frames, 1 session, 0 declined; ffprobe `hevc` / `Main 10` / `hvc1` / `yuv420p10le`, `bt2020` / `smpte2084` or `arib-std-b67` / `bt2020nc` / `tv`; PQ first-frame side data Mastering display (max 10000000/10000, min 1/10000) and Content light level, HLG none; `ffmpeg -xerror` clean; ffmpeg's yuv420p10le picture equals our decoder's, code for code; our importer reads PQ / HLG, BT.2020, 10-bit, and the `colr` / `mdcv` / `clli` boxes; luma PSNR against a ProRes HDR export of the same sequence 62.6 dB (PQ) / 61.8 dB (HLG); `settings.sdr` and SDR sequences stay Main 8-bit BT.709 with no HDR boxes; the factory rejects mismatched or wrong-length pictures and clamps NaN / infinity | | `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) | | `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages | | `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` | @@ -344,7 +344,7 @@ Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on mac textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from -the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows and Linux vendors, and encoders +the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders from other vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows. ### Linux NVENC sources and limits diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index e42db755..8744da0e 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -8,7 +8,7 @@ //! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a //! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an //! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on -//! macOS and with NVENC ([`nvenc`], Windows and Linux) it also makes the H.265 export format available, and with NVENC +//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows //! (Main 10) lets HDR sequences export as HDR H.265. //! //! Hardware decoding never makes a file undecodable: @@ -68,6 +68,8 @@ pub enum Availability { /// harmless). Streams they do not take, and every stream while hardware decoding is Off, keep /// using FilmCraft's own decoders. pub fn register() -> Availability { + #[cfg(all(target_os = "linux", target_pointer_width = "64"))] + filmcraft_export::register_encoder(nvenc::export::factory); #[cfg(target_os = "macos")] { filmcraft_codecs::register_video_decoder(videotoolbox_factory); @@ -79,9 +81,23 @@ pub fn register() -> Availability { filmcraft_codecs::hw::set_hw_backend("VideoToolbox"); Availability::Available("VideoToolbox") } + // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows, so the format is available + // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of + // its own now rather than by the first draw of the format list. #[cfg(target_os = "windows")] { - register_nvenc(); + filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); + // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere + // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before + filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); + nvenc::warm_hevc_probe(); + } + #[cfg(target_os = "windows")] + { + static ENCODERS: std::sync::Once = std::sync::Once::new(); + // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an + // export only when asked for and when NVENC can do it + ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); filmcraft_codecs::register_video_decoder(media_foundation_factory); filmcraft_codecs::hw::set_hw_backend("Media Foundation"); Availability::Available("Media Foundation") @@ -114,11 +130,6 @@ pub fn register() -> Availability { } Err(why) => log::info!("no NVDEC hardware decoding: {why}"), } - // After the VA-API probe, never beside it: libva-nvidia-driver calls `cuInit` from its - // library constructor (under the loader lock) while a first `cuInit` on another thread - // loads libraries under CUDA's own lock, and the two wait for each other forever. - #[cfg(target_pointer_width = "64")] - register_nvenc(); available } #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] @@ -127,23 +138,6 @@ pub fn register() -> Availability { } } -/// NVENC: the Export encoder factory, and what makes the H.265 format (and HDR H.265) available. -#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] -fn register_nvenc() { - // Export ▸ H.265: NVENC is FilmCraft's only HEVC encoder on Windows and Linux, so the format is available - // when a small HEVC session opens. That takes about half a second, so it is asked on a thread of - // its own now rather than by the first draw of the format list. - filmcraft_export::register_format_probe(filmcraft_export::Format::Hevc, nvenc::hevc_available); - // HDR sequences export as HEVC Main 10 (PQ / HLG) only where NVENC has a 10-bit encoder; elsewhere - // (and on macOS, whose VideoToolbox path is 8-bit) they are tone-mapped to SDR, as before - filmcraft_export::register_hdr_probe(filmcraft_export::Format::Hevc, nvenc::hevc_hdr_available); - nvenc::warm_hevc_probe(); - static ENCODERS: std::sync::Once = std::sync::Once::new(); - // Export ▸ Hardware encoding (NVENC H.264): in front of the software encoder, taking an - // export only when asked for and when NVENC can do it - ENCODERS.call_once(|| filmcraft_export::register_encoder(nvenc::export::factory)); -} - /// Whether [`register`] has put a hardware decoder factory in front of our decoders. pub fn registered() -> bool { #[cfg(target_os = "macos")] diff --git a/crates/platform/src/nvenc/export.rs b/crates/platform/src/nvenc/export.rs index a796742f..4344cb52 100644 --- a/crates/platform/src/nvenc/export.rs +++ b/crates/platform/src/nvenc/export.rs @@ -211,7 +211,8 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) - /// The Export encoder factory (see the module documentation). pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { if format == Format::Hevc { - return hevc_factory(w, h, rate, s); + // H.265 through NVENC is Windows-only for now; on Linux this backend takes H.264 only + return if cfg!(target_os = "windows") { hevc_factory(w, h, rate, s) } else { None }; } factory_with(format, w, h, rate, s, |cfg| open(cfg, &Signal::default())) } @@ -260,7 +261,7 @@ fn factory_with( } } -/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows and Linux, so the Hardware encoding toggle +/// The H.265 side of [`factory`]: FilmCraft's only HEVC encoder on Windows, so the Hardware encoding toggle /// does not apply and a request NVENC cannot take is an error, not a fall-through. fn hevc_factory(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { let declined = |why: &str| { diff --git a/crates/platform/src/nvenc/mod.rs b/crates/platform/src/nvenc/mod.rs index d15928e1..4fe2357f 100644 --- a/crates/platform/src/nvenc/mod.rs +++ b/crates/platform/src/nvenc/mod.rs @@ -1,5 +1,5 @@ -//! NVIDIA NVENC hardware encoding: H.264 and H.265 (HEVC) Main / Main 10 (Windows and -//! 64-bit Linux). +//! NVIDIA NVENC hardware encoding: H.264 (Windows and 64-bit Linux) and H.265 (HEVC) Main / Main 10 +//! (Windows). //! //! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` on Windows //! (on a Direct3D 11 device) or `libnvidia-encode.so.1` on Linux (on the CUDA driver's primary diff --git a/crates/platform/tests/nvenc_hevc.rs b/crates/platform/tests/nvenc_hevc.rs index d5f1ca9d..acd2bee9 100644 --- a/crates/platform/tests/nvenc_hevc.rs +++ b/crates/platform/tests/nvenc_hevc.rs @@ -2,7 +2,7 @@ //! with our own HEVC decoder to pictures close to the source, keyframes and timestamps are right, the //! samples carry no parameter sets and the `hvcC` describes the SPS it was built from. Skips //! without an NVIDIA GPU / driver with HEVC encoding. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use filmcraft_bitstream::unescape_rbsp; use filmcraft_hevc::params::Sps; diff --git a/crates/platform/tests/nvenc_hevc_export.rs b/crates/platform/tests/nvenc_hevc_export.rs index 1b449188..95f074bb 100644 --- a/crates/platform/tests/nvenc_hevc_export.rs +++ b/crates/platform/tests/nvenc_hevc_export.rs @@ -4,7 +4,7 @@ //! counters; and what NVENC declines (an error that says why, counted, never a crash). Its own test //! binary: the encoder registry and the counters are process-wide. Skips without an NVIDIA GPU with //! an HEVC encoder. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_hdr_export.rs b/crates/platform/tests/nvenc_hevc_hdr_export.rs index 157e536e..8acbc1ca 100644 --- a/crates/platform/tests/nvenc_hevc_hdr_export.rs +++ b/crates/platform/tests/nvenc_hevc_hdr_export.rs @@ -5,7 +5,7 @@ //! our importer reads back as PQ / HLG, 10-bit; whose pixels follow a ProRes HDR export of the same //! sequence. SDR stays Main 8-bit BT.709, `settings.sdr` forces it. Its own test binary: the encoder //! registry and the counters are process-wide. Skips (SKIPPED) only without a 10-bit HEVC encoder. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::path::Path; use std::process::Command; diff --git a/crates/platform/tests/nvenc_hevc_main10.rs b/crates/platform/tests/nvenc_hevc_main10.rs index 74a8e280..76d0c9cc 100644 --- a/crates/platform/tests/nvenc_hevc_main10.rs +++ b/crates/platform/tests/nvenc_hevc_main10.rs @@ -3,7 +3,7 @@ //! pictures that come out of our own HEVC decoder are 10-bit and close to them (smooth ramps keep far more //! than 256 levels, bright code values survive), the VUI and the HDR10 SEI messages are right, and //! hostile inputs are errors. Skips (with SKIPPED) only on a machine without a 10-bit HEVC encoder. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use filmcraft_bitstream::unescape_rbsp; use filmcraft_export::{ColorSignal, rgbf_to_yuv420_10}; diff --git a/crates/platform/tests/nvenc_hevc_probe.rs b/crates/platform/tests/nvenc_hevc_probe.rs index 28f28f34..e2a41cd7 100644 --- a/crates/platform/tests/nvenc_hevc_probe.rs +++ b/crates/platform/tests/nvenc_hevc_probe.rs @@ -1,7 +1,7 @@ //! The HEVC availability probe of the NVENC backend: it opens one small encoder session, once. Its own //! test binary so that "first call" means a fresh process (driver load, Direct3D 11 device, NVENC //! session and encoder initialisation all included). -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_hevc_warm.rs b/crates/platform/tests/nvenc_hevc_warm.rs index 1a616328..adc0ebb2 100644 --- a/crates/platform/tests/nvenc_hevc_warm.rs +++ b/crates/platform/tests/nvenc_hevc_warm.rs @@ -1,7 +1,7 @@ //! `register` asks the NVENC HEVC probe on a thread of its own, so the first draw of Export's format //! list does not open an encoder session on the UI thread. Its own test binary: anything else asking //! `hevc_available` first would make the wait meaningless. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use std::time::{Duration, Instant}; diff --git a/crates/platform/tests/nvenc_rgba_input.rs b/crates/platform/tests/nvenc_rgba_input.rs index 62b8b9bd..4009f3e6 100644 --- a/crates/platform/tests/nvenc_rgba_input.rs +++ b/crates/platform/tests/nvenc_rgba_input.rs @@ -1,7 +1,7 @@ -//! NVENC with packed RGBA input (Windows and Linux, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the +//! NVENC with packed RGBA input (Windows, NVIDIA GPU): the GPU's RGB → 4:2:0 conversion matches the //! export's own BT.709 limited-range conversion (`filmcraft_export::rgba_to_yuv420_8`), for HEVC Main //! and H.264. Skips without an NVIDIA GPU / driver. -#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))] +#![cfg(target_os = "windows")] use filmcraft_isobmff::{AvcConfig, SampleEntry}; use filmcraft_platform::nvenc::{Codec, Config, Nvenc, Packet, Profile, Signal, available, hevc_available}; diff --git a/docs/codec-parity.md b/docs/codec-parity.md index 8fe1ff6b..8aaa7655 100644 --- a/docs/codec-parity.md +++ b/docs/codec-parity.md @@ -16,7 +16,7 @@ contents). Percent is ready-for-real-work for that codec: depth, profiles, speed | Codec | Premiere | Our decode | Our encode | Fidelity and tests | % | Est. | |---|---|---|---|---|---|---| | H.264 / AVC | decode all common profiles incl. High 10, 4:2:2 (XAVC S-I, AVC-Intra); encode 8/10-bit | own decoder, **8-bit 4:2:0 only** (Baseline / Main / High, CABAC / CAVLC); no High 10 / 4:2:2 / 4:4:4, interlaced field / MBAFF, lossless | own encoder (High / Main / Baseline, B-frames, CBR / VBR / 2-pass); 8-bit 4:2:0 only; hardware VideoToolbox / NVENC | decode bit-exact on 37+ conformance streams, 500–600 fps 1080p; encode ffprobe-checked, byte-identical across machines | 65% | 20–35 h | -| HEVC / H.265 | decode Main, Main 10, 4:2:2 10 (XAVC HS); encode 8/10-bit, HDR | own decoder Main / Main 10 / Main Still Picture, bit-exact on 41 fixtures, ~225 fps 1080p; **no 4:2:2 / RExt** | **hardware only** (VideoToolbox 8-bit; NVENC 8-bit and Main 10 HDR on Windows and Linux); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 h | +| HEVC / H.265 | decode Main, Main 10, 4:2:2 10 (XAVC HS); encode 8/10-bit, HDR | own decoder Main / Main 10 / Main Still Picture, bit-exact on 41 fixtures, ~225 fps 1080p; **no 4:2:2 / RExt** | **hardware only** (VideoToolbox 8-bit; NVENC 8-bit and Main 10 HDR on Windows); no software encoder | NVENC round trip 48.9 dB (8-bit), 71.1 dB (10-bit); ffprobe | 60% | 30–60 h | | ProRes 422 family / 4444 / XQ | decode + encode all | own decode + encode, all six profiles, alpha | yes (export UI exposes 422 flavours; 4444 / XQ request #342) | ±1 LSB, 318 fps | 85% | 3–6 h | | ProRes RAW | decode | none (#345) | — | — | 0% | 30–50 h (spec availability uncertain) | | DNxHD / DNxHR | decode + encode, MXF / MOV | own decode (all SMPTE ST 2019-1 CIDs, 8/10/12-bit, 4:2:2 / 4:4:4, interlaced, alpha) and encode LB / SQ / HQ / HQX / 444 | yes, MOV + MXF | within IDCT precision of ffmpeg on 22 fixtures | 90% | 2–4 h | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index e74879f6..13bb67a4 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -139,8 +139,8 @@ Evidence for the cross-cutting rows: - **Hardware ~45%.** Hardware decode exists on all three desktop OSs (VideoToolbox; Media Foundation / D3D11 incl. VP9 and AV1; VA-API and NVDEC for H.264 / HEVC on Linux). Hardware - encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 and HEVC incl. Main 10 HDR (Windows, - Linux). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, + encode: VideoToolbox H.264 / HEVC 8-bit; NVENC H.264 (Windows, Linux) and HEVC incl. Main 10 HDR + (Windows). Missing against Premiere: Intel Quick Sync and AMD encoders, 10-bit HEVC on macOS, ProRes hardware, zero-copy upload, ~48 of Premiere's accelerated effects, external video I/O, control surfaces. Detail: [hardware-parity.md](hardware-parity.md). - **Performance ~55%.** 4K H.264 / HEVC play with no dropped frames with hardware decoding (M4 diff --git a/docs/testing.md b/docs/testing.md index a35f48b4..856dad73 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -77,7 +77,7 @@ are never linked or shipped ([AGENTS.md](../AGENTS.md) §2). The NVENC tests (`crates/platform/tests/nvenc.rs` and `nvenc_export.rs` for H.264, `nvenc_rgba_input.rs` for the GPU's RGB → 4:2:0 conversion, `nvenc_hevc.rs`, `nvenc_hevc_export.rs`, `nvenc_hevc_probe.rs` and `nvenc_hevc_warm.rs` for H.265, `nvenc_hevc_main10.rs` and -`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows and Linux) skip, printing `SKIPPED`, without an NVIDIA GPU +`nvenc_hevc_hdr_export.rs` for Main 10 HDR; Windows only) skip, printing `SKIPPED`, without an NVIDIA GPU with NVENC (H.265: when the HEVC probe says there is no HEVC encoder; Main 10: when the Main 10 probe says there is no 10-bit encoder; on a machine that has one, a configuration NVENC refuses fails the test). The FFI layout tests in `crates/platform/src/nvenc/abi_tests.rs` were generated from a C program built with MSVC From d3444534a66f98c883528c2504f8554b9cd103b0 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:04:44 +1100 Subject: [PATCH 063/203] Fix Clear on Close Sequence also dropping File Close (Cmd+W). MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File ▸ Close aliases Close Sequence. The shortcut editor targeted sequence.close while Cmd+W lived on file.close (and Timeline), so Clear left the chord bound. Treat the pair as one shortcut family. Fixes storytold/filmcraft#773 --- crates/engine/src/shortcuts.rs | 29 +++++++++++++++++++++++----- crates/engine/src/shortcuts_tests.rs | 20 +++++++++++++++++++ 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/shortcuts.rs b/crates/engine/src/shortcuts.rs index 0c9e4771..b2436298 100644 --- a/crates/engine/src/shortcuts.rs +++ b/crates/engine/src/shortcuts.rs @@ -737,7 +737,7 @@ impl Shortcuts { let nb = Binding { command: command.into(), keys: chord.canonical(), panel: panel.map(str::to_string) }; let before = self.snapshot(); if !add { - self.bindings.retain(|b| !(b.command == command && b.context() == nb.context())); + self.bindings.retain(|b| !(same_shortcut_command(&b.command, command) && b.context() == nb.context())); } let mut reassigned = Vec::new(); if !keep_conflicts { @@ -768,9 +768,19 @@ impl Shortcuts { }; let before = self.snapshot(); let n0 = self.bindings.len(); + let twins = shortcut_twins(command); self.bindings.retain(|b| { - let hit = b.command == command && chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)) && panel.as_ref().is_none_or(|p| &b.panel == p); - !hit + let cmd = same_shortcut_command(&b.command, command); + let key_ok = chord.as_ref().is_none_or(|c| b.chord().as_ref() == Some(c)); + // File ▸ Close and Close Sequence share Cmd+W (app + Timeline). The editor's Clear + // always sends panel Application; treating that as every context for this pair is + // what actually frees the chord (#773). + let panel_ok = match &panel { + None => true, + Some(p) if p.is_none() && twins => true, + Some(p) => &b.panel == p, + }; + !(cmd && key_ok && panel_ok) }); let n = n0 - self.bindings.len(); if n > 0 { @@ -909,12 +919,12 @@ impl Shortcuts { // ---------------------------------------------------------------- queries pub fn for_command(&self, id: &str) -> Vec<&Binding> { - self.bindings.iter().filter(|b| b.command == id).collect() + self.bindings.iter().filter(|b| same_shortcut_command(&b.command, id)).collect() } /// The shortcut menus show: the first application-wide one. pub fn primary(&self, id: &str) -> Option { - self.bindings.iter().find(|b| b.command == id && b.panel.is_none()).map(|b| b.keys.clone()) + self.bindings.iter().find(|b| same_shortcut_command(&b.command, id) && b.panel.is_none()).map(|b| b.keys.clone()) } /// The command a chord runs with `panel` focused (panel shortcuts first). @@ -998,6 +1008,15 @@ fn panel_opt(p: &str) -> Option<&str> { if p.is_empty() { None } else { Some(p) } } +/// `file.close` is File ▸ Close: the same action as `sequence.close` (Close Sequence). +fn shortcut_twins(id: &str) -> bool { + id == "file.close" || id == "sequence.close" +} + +fn same_shortcut_command(bound: &str, target: &str) -> bool { + bound == target || (shortcut_twins(bound) && shortcut_twins(target)) +} + fn check_panel(panel: Option<&str>) -> std::result::Result, String> { match panel { None => Ok(None), diff --git a/crates/engine/src/shortcuts_tests.rs b/crates/engine/src/shortcuts_tests.rs index 9ffd35f4..c589086d 100644 --- a/crates/engine/src/shortcuts_tests.rs +++ b/crates/engine/src/shortcuts_tests.rs @@ -113,6 +113,26 @@ fn assign_reassigns_conflicts_and_undoes() { assert!(s.shortcuts.for_command("edit.undo").is_empty()); } +#[test] +fn clear_close_sequence_also_clears_file_close() { + // #773: File ▸ Close (`file.close`, Cmd+W) aliases Close Sequence (`sequence.close`). + // The editor's Clear sends panel Application against `sequence.close`, which had only a + // Timeline binding — Cmd+W stayed on `file.close`. + let mut s = Session::default(); + assert_eq!(s.shortcuts.primary("file.close").as_deref(), Some("Cmd+W")); + assert_eq!(s.shortcuts.primary("sequence.close").as_deref(), Some("Cmd+W")); + assert!(s.shortcuts.for_command("sequence.close").iter().any(|b| b.keys == "Cmd+W")); + let r = s.execute("shortcuts.clear", json!({"command": "sequence.close", "panel": "Application"})).unwrap(); + assert!(r["removed"].as_u64().unwrap() >= 1, "{r}"); + assert!(s.shortcuts.for_command("sequence.close").is_empty(), "{:?}", s.shortcuts.for_command("sequence.close")); + assert!(s.shortcuts.primary("file.close").is_none()); + let w = Chord::parse("Cmd+W").unwrap(); + assert!(s.shortcuts.resolve(&w, None, Platform::current()).is_none()); + assert!(s.shortcuts.resolve(&w, Some("Timeline"), Platform::current()).is_none()); + s.execute("shortcuts.set", json!({"command": "sequence.addEdit", "keys": "Cmd+W", "add": true})).unwrap(); + assert_eq!(s.shortcuts.resolve(&w, None, Platform::current()).unwrap().command, "sequence.addEdit"); +} + #[test] fn panel_shortcuts_override_application_ones_with_focus() { let s = Session::default(); From 995053922ed8c18ef50e40c046434fff96a626a1 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:09:36 +1100 Subject: [PATCH 064/203] Show Parametric EQ in Effect Controls on linked A/V clips. Audio effects applied to the video side of a pair now land on the linked audio, and Effect Controls lists that clip's parameters and Custom Setup while the picture is selected. --- crates/engine/src/audio_effects_tests.rs | 16 +++++++++ crates/engine/src/commands.rs | 35 ++++++++++++++++++++ crates/ui-egui/src/panels/effect_controls.rs | 20 ++++++++--- crates/ui-egui/tests/clip_audio_ui.rs | 15 +++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) diff --git a/crates/engine/src/audio_effects_tests.rs b/crates/engine/src/audio_effects_tests.rs index b85463e4..e3992640 100644 --- a/crates/engine/src/audio_effects_tests.rs +++ b/crates/engine/src/audio_effects_tests.rs @@ -111,3 +111,19 @@ fn shared_names_resolve_by_track_kind() { let it = s.active_sequence().unwrap().find_item(ClipId(v)).unwrap().1.clone(); assert!(it.effects.iter().any(|e| e.effect == "invert")); } + +/// #696: applying an audio effect to the video side of an A/V pair lands on the linked audio. +#[test] +fn audio_effect_on_video_clip_applies_to_linked_audio() { + let mut s = demo(); + let seq = s.active_sequence().unwrap(); + let v = seq.video_tracks[0].items[0].clone(); + let a = seq.audio_tracks[0].items.iter().find(|i| i.link == v.link).expect("linked audio").clone(); + let n0 = a.effects.len(); + s.execute("effects.apply", json!({"clips": [v.id.0], "effect": "parametric_eq"})).unwrap(); + let a = s.active_sequence().unwrap().find_item(a.id).unwrap().1.clone(); + let v = s.active_sequence().unwrap().find_item(v.id).unwrap().1.clone(); + assert_eq!(a.effects.len(), n0 + 1); + assert_eq!(a.effects.last().unwrap().effect, "parametric_eq"); + assert!(v.effects.iter().all(|e| e.effect != "parametric_eq"), "EQ stays off the picture clip"); +} diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 72a32cb8..067ed2c7 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -411,6 +411,35 @@ pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit: Ok(()) } +/// Clips an audio effect should land on: audio ids as given, plus the linked audio of any video +/// clip in the list (so applying Parametric EQ to an A/V pair hits the sound). +pub fn audio_effect_targets(s: &Session, clips: &[ClipId]) -> Vec { + let Some(seq) = s.active_sequence() else { return clips.to_vec() }; + let mut out = Vec::new(); + for c in clips { + let Some((tid, it)) = seq.find_item(*c) else { continue }; + let kind = seq.track(tid).map(|t| t.kind); + if kind == Some(TrackKind::Audio) { + if !out.contains(c) { + out.push(*c); + } + continue; + } + if kind != Some(TrackKind::Video) { + continue; + } + let Some(link) = it.link else { continue }; + for t in &seq.audio_tracks { + for i in &t.items { + if i.link == Some(link) && !out.contains(&i.id) { + out.push(i.id); + } + } + } + } + out +} + /// Expand a clip selection with linked partners (when linked selection is on). pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { let Some(seq) = s.active_sequence() else { return clips.to_vec() }; @@ -2558,6 +2587,12 @@ fn build() -> Vec { return Err(bad("effects.apply", "select clips first")); } let is_audio = def.kind == filmcraft_project::EffectKind::Audio; + // Audio effects belong on audio clips. A video clip in an A/V pair is retargeted to + // its linked audio so double-click / drop on the picture still applies Parametric EQ. + let clips = if is_audio { audio_effect_targets(s, &clips) } else { clips }; + if clips.is_empty() { + return Err(bad("effects.apply", "audio effects need an audio clip (or a video clip with linked audio)")); + } let (fw, fh) = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080)); let label = format!("Apply {}", def.name); s.edit_sequence(&label, |q, _, _| { diff --git a/crates/ui-egui/src/panels/effect_controls.rs b/crates/ui-egui/src/panels/effect_controls.rs index 6b071c4c..e8356050 100644 --- a/crates/ui-egui/src/panels/effect_controls.rs +++ b/crates/ui-egui/src/panels/effect_controls.rs @@ -62,7 +62,8 @@ fn selected_clip(app: &FilmcraftApp) -> Option<(ClipId, TrackItem, TrackKind)> { selected_clips(app).into_iter().next() } -/// The selected clip (video first) and, for a video clip, the selected audio linked to it. +/// The selected clip (video first) and the audio linked to it when that audio is selected or +/// linked selection is on — so clip audio FX (Parametric EQ, Custom Setup) stay visible on A/V pairs. fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> { let Some(seq) = app.session.active_sequence() else { return Vec::new() }; let picked: Vec<(ClipId, &TrackItem, TrackKind)> = app @@ -77,10 +78,19 @@ fn selected_clips(app: &FilmcraftApp) -> Vec<(ClipId, TrackItem, TrackKind)> { .collect(); let Some(&(clip, it, kind)) = picked.iter().find(|p| p.2 == TrackKind::Video).or(picked.first()) else { return Vec::new() }; let mut out = vec![(clip, it.clone(), kind)]; - if kind == TrackKind::Video - && let Some(&(ac, ai, ak)) = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link) - { - out.push((ac, ai.clone(), ak)); + if kind == TrackKind::Video { + let linked = picked.iter().find(|p| p.2 == TrackKind::Audio && p.1.link.is_some() && p.1.link == it.link).copied().or_else(|| { + if !app.session.state.linked_selection { + return None; + } + let link = it.link?; + seq.audio_tracks.iter().find_map(|t| { + t.items.iter().find(|i| i.link == Some(link)).map(|i| (i.id, i, TrackKind::Audio)) + }) + }); + if let Some((ac, ai, ak)) = linked { + out.push((ac, ai.clone(), ak)); + } } out } diff --git a/crates/ui-egui/tests/clip_audio_ui.rs b/crates/ui-egui/tests/clip_audio_ui.rs index 3ca87a33..f7f40114 100644 --- a/crates/ui-egui/tests/clip_audio_ui.rs +++ b/crates/ui-egui/tests/clip_audio_ui.rs @@ -143,6 +143,21 @@ fn effect_controls_show_the_audio_of_a_video_clip() { d.rect("effectControls.effect.motion"); } +/// #696: Parametric EQ on a linked A/V pair shows in Effect Controls (and Custom Setup) while the +/// video clip is selected — Effect Controls used to keep only the picture clip. +#[test] +fn effect_controls_show_audio_fx_on_a_linked_pair() { + let (mut d, pair) = Driver::demo(&["EffectControls"]); + d.exec("timeline.select", json!({"clips": [pair.video]})); + d.exec("effects.apply", json!({"clips": [pair.video], "effect": "parametric_eq"})); + assert_eq!(d.item(pair.audio).effects.last().map(|e| e.effect.as_str()), Some("parametric_eq")); + for fx in ["motion", "opacity", "time_remap", "volume", "channel_volume", "panner"] { + d.click(&format!("effectControls.effect.{fx}")); + } + d.rect("effectControls.effect.parametric_eq"); + d.rect("effectControls.effect.parametric_eq.edit"); +} + #[test] fn properties_show_the_audio_of_a_video_clip() { let (mut d, pair) = Driver::demo(&["Properties"]); From 03bf8b1f33b099d0638e861bb47397cfb314d262 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:09:44 +1100 Subject: [PATCH 065/203] Fix OS drops on an empty Timeline creating a sequence. Dragging files from the explorer onto the Timeline imported them into the Project bin but never placed them when no sequence was open, because only the clip-area interact path consumed pending_timeline_drop. The empty Timeline now creates a sequence from the first imported item and places any further items after it, matching the existing Project-panel drop. --- crates/ui-egui/src/panels/timeline.rs | 20 +++++++++++++ crates/ui-egui/tests/timeline_drop_ui.rs | 37 ++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ca4fab0d..adfed455 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -627,6 +627,26 @@ fn empty_state(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { { let _ = app.session.execute("file.newSequence", json!({"fromItem": item.0})); } + // OS files dropped on an empty Timeline: import already happened; create a sequence from the first item + if let Some((items, p)) = app.pending_timeline_drop.clone() + && rect.contains(p) + && let Some((first, rest)) = items.split_first() + { + app.pending_timeline_drop = None; + if let Err(e) = app.session.execute("file.newSequence", json!({"fromItem": first.0})) { + app.ui.status = e.to_string(); + } else { + let mut t = app.session.project.item(*first).map(|i| i.duration()).unwrap_or(Tick::ZERO); + for item in rest { + let params = json!({"item": item.0, "time": t.0, "insert": false}); + if let Err(e) = app.session.execute("timeline.place", params) { + app.ui.status = e.to_string(); + } else { + t = Tick(t.0.saturating_add(app.session.project.item(*item).map(|i| i.duration().0.max(0)).unwrap_or(0))); + } + } + } + } } /// The through-edit mark: two small triangles pointing at the cut, centred on the track, with a diff --git a/crates/ui-egui/tests/timeline_drop_ui.rs b/crates/ui-egui/tests/timeline_drop_ui.rs index 76bdde9f..3ad0510c 100644 --- a/crates/ui-egui/tests/timeline_drop_ui.rs +++ b/crates/ui-egui/tests/timeline_drop_ui.rs @@ -143,6 +143,43 @@ fn tone_dropped_on_a_video_row_goes_to_the_audio_track_without_error() { assert_eq!(clip_counts(&h), (0, 1), "status: {}", h.state().ui.status); } +/// Empty project: no sequence yet. Dropping on the empty Timeline must import *and* make a sequence. +fn empty_harness() -> Harness<'static, FilmcraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).build_eframe(move |_cc| FilmcraftApp::new(Session::default())); + for _ in 0..4 { + step(&mut h); + } + h +} + +fn drop_on_empty_timeline(h: &mut Harness<'static, FilmcraftApp>, path: &Path) { + let panel = control_rect(h, "panel.Timeline"); + pointer_to(h, panel.center()); + h.input_mut().dropped_files.push(std::sync::Arc::new(OsFile(path.to_path_buf()))); + step(h); + h.input_mut().dropped_files.clear(); + step(h); +} + +#[test] +fn still_dropped_on_empty_timeline_creates_a_sequence() { + let mut h = empty_harness(); + assert!(h.state().session.active_sequence().is_none()); + drop_on_empty_timeline(&mut h, &still("drop_still_empty_timeline.png")); + assert_eq!(h.state().session.project.items.len(), 2, "still plus the new sequence"); + assert!(h.state().session.active_sequence().is_some(), "status: {}", h.state().ui.status); + assert_eq!(clip_counts(&h), (1, 0), "status: {}", h.state().ui.status); +} + +#[test] +fn with_drop_import_off_empty_timeline_does_not_create_a_sequence() { + let mut h = empty_harness(); + h.state_mut().session.prefs.timeline.drop_import_to_timeline = false; + drop_on_empty_timeline(&mut h, &still("drop_still_empty_pref_off.png")); + assert_eq!(h.state().session.project.items.len(), 1, "imported to the bin only"); + assert!(h.state().session.active_sequence().is_none()); +} + #[test] fn with_drop_import_off_the_file_is_only_imported() { let mut h = harness(); From f8b37e146373ea5e896d69e55b96a22c76efae6d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:15:53 +0200 Subject: [PATCH 066/203] #831: cargo xtask parity measures effects and transitions (--area) Problem: `cargo xtask parity` (#781, #782) measured only the menu bar; the Effects panel's effects and transitions had no measured parity figure (docs/gaps.md G7). Fix: `--area effects|transitions` compares the catalogue the engine's effect definitions list in the Effects panel (new `effect_catalog` example in filmcraft-project, the panel's own top-folder filter) with a reference of folder paths or bare names (default the local, never committed plan/premiere/.json). Same report, caps and `--min` as menus; placement by folder; Presets / Lumetri Presets and the other area's items are out of scope, so one panel snapshot serves both areas. `--area menus` (default) is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/project/examples/effect_catalog.rs | 34 ++ docs/gaps.md | 9 +- docs/target-app-parity.md | 4 +- xtask/src/main.rs | 9 +- xtask/src/parity.rs | 403 +++++++++++++++++----- 5 files changed, 370 insertions(+), 89 deletions(-) create mode 100644 crates/project/examples/effect_catalog.rs diff --git a/crates/project/examples/effect_catalog.rs b/crates/project/examples/effect_catalog.rs new file mode 100644 index 00000000..12f17fe7 --- /dev/null +++ b/crates/project/examples/effect_catalog.rs @@ -0,0 +1,34 @@ +//! Print FilmCraft's effect and transition catalog as the Effects panel lists it, as JSON: one +//! object per item with its `id`, display `name`, `kind` (`Video`, `Audio`, `VideoTransition`, +//! `AudioTransition`) and Effects-panel folder `path` (e.g. `["Video Effects", "Blur & Sharpen"]`). +//! Items with no Effects-panel folder (intrinsic Motion / Opacity / Volume…, graphic layers) and +//! the hidden obsolete transitions are left out, as in the panel (the same top-level folder filter +//! as `panels/effects.rs`). `cargo xtask parity --area effects|transitions` reads this to measure +//! coverage against a reference list. +//! +//! ```sh +//! cargo run -p filmcraft-project --example effect_catalog +//! ``` + +use std::process::ExitCode; + +use filmcraft_project::vtransition::EFFECT_TOP_FOLDERS; +use serde_json::json; + +fn main() -> ExitCode { + let items: Vec = filmcraft_project::effect_defs() + .iter() + .filter(|d| d.category.first().is_some_and(|top| EFFECT_TOP_FOLDERS.contains(top))) + .map(|d| json!({"id": d.id, "name": d.name, "kind": format!("{:?}", d.kind), "path": d.category})) + .collect(); + match serde_json::to_string_pretty(&items) { + Ok(text) => { + println!("{text}"); + ExitCode::SUCCESS + } + Err(e) => { + eprintln!("effect_catalog: {e}"); + ExitCode::FAILURE + } + } +} diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b3..015fccae 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -127,8 +127,13 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** `plan/premiere/menus.json`; presence and placement reported separately (same path, elsewhere, missing), app / Help / recent / account / macOS items out of scope, a markdown checklist, and `--min` to fail below a presence threshold. Without a reference it prints our inventory only. -- **Missing:** the other areas (commands, effects, transitions, panels, preferences, formats), - fidelity tiers (exact, approximate, stub), a CI job, and a committed `docs/parity-checklist.md`. + `--area effects` and `--area transitions` (#831) measure the Effects panel the same way: the + effects and transitions the engine's effect definitions list (`examples/effect_catalog.rs` in + `filmcraft-project`) against a reference of folder paths or bare names (default the local + `plan/premiere/effects.json` / `transitions.json`), placement by folder, Presets and the other + area out of scope. +- **Missing:** the other areas (commands, panels, preferences, formats), fidelity tiers (exact, + approximate, stub), a CI job, and a committed `docs/parity-checklist.md`. - **Evidence:** the 92% menu figure in this pass came from an ad hoc label-matching script; it has not yet been re-measured with the tool. - **Estimate:** 8–17 h left (the menu snapshot is local-only, so CI needs a committed, Adobe-free diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 13bb67a4..8c37bb0f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -51,7 +51,9 @@ issues now document. That is new evidence, not drift: see [Calibration](#calibra - **Effects (measured by tests).** `premiere_26_video_effects_catalogue`, `premiere_audio_set_is_complete_and_foldered` and `premiere_26_transition_tree` check the catalogue against Premiere's Effects panel: video effects 93/93 (+Legacy, Obsolete), audio - effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3. + effects 53/53, video transitions 84/84 (+21 Legacy), audio transitions 3/3. `cargo xtask + parity --area effects|transitions` measures the shipped catalogue against a reference list too + (presence and folder placement, [G7](gaps.md#g7-no-measured-parity-tool-in-ci)). - **The installed app (file names and listings only, per [AGENTS.md](../AGENTS.md) §2; nothing inside the bundle was opened or read).** `Contents/Resources/*.lproj`: de, en, es, fr, it, ja, ko, pt, ru, zh_CN (10 UI languages). `Contents/MediaIO/systempresets/` folder names are diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 1ea276af..9f61b7c3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -11,9 +11,10 @@ //! - `fixtures [crate…]`: pre-generate the ffmpeg fixture matrix of the oracle tests (runs each //! crate's ignored `generate_fixtures` test, i.e. the same generators the tests use) and print //! what was made, reused or skipped. -//! - `parity [--reference FILE] [--out FILE] [--min PERCENT]`: measured menu parity of our shipped -//! menu bar against a reference list of menu paths (default: the local `plan/premiere/menus.json`), -//! presence and placement reported separately (`parity.rs`, docs/gaps.md G7). +//! - `parity [--area menus|effects|transitions] [--reference FILE] [--out FILE] [--min PERCENT]`: +//! measured parity of our shipped menu bar, or of the effects / transitions the Effects panel +//! lists, against a reference list (default: the local `plan/premiere/.json`), presence and +//! placement reported separately (`parity.rs`, docs/gaps.md G7). //! - `ico …`: pack PNGs into a Windows `.ico` (used by `packaging/icons.sh`). //! - `ci`: fmt check, clippy -D warnings, tests, layers, assets, wasm. @@ -500,7 +501,7 @@ fn main() -> ExitCode { ico::run(&rest.iter().map(String::as_str).collect::>()) } _ => Err( - "usage: cargo xtask " + "usage: cargo xtask " .into(), ), }; diff --git a/xtask/src/parity.rs b/xtask/src/parity.rs index e11a570f..774a0a76 100644 --- a/xtask/src/parity.rs +++ b/xtask/src/parity.rs @@ -1,20 +1,31 @@ -//! `cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT]`: measured -//! menu parity (docs/gaps.md G7). +//! `cargo xtask parity [--area menus|effects|transitions] [--reference FILE] [--ours FILE] +//! [--out FILE] [--min PERCENT]`: measured parity (docs/gaps.md G7). //! -//! Compares FilmCraft's shipped menu bar (`crates/ui-egui/examples/menu_tree.rs`, or a saved dump -//! of it given with `--ours`) with a reference list of menu paths, and reports presence and -//! placement separately: a reference item is *same place* when we have its label at the same menu -//! path, *elsewhere* when we have the label under another path, and *missing* otherwise. A label -//! match is presence only, not behaviour. +//! Compares one area of what FilmCraft ships with a reference list, and reports presence and +//! placement separately: a reference item is *same place* when we have its label at the same path, +//! *elsewhere* when we have the label under another path, and *missing* otherwise. A label match is +//! presence only, not behaviour. The areas: //! -//! The reference defaults to the maintainers' local menu snapshot `plan/premiere/menus.json` -//! (gitignored, never committed). Without one the tool prints our own menu inventory and measures -//! nothing. The reference may be any JSON of menu paths: an array of paths (`["File", "New", -//! "Sequence…"]`) or of `"File > New > Sequence…"` strings, a tree of nodes (`{"name": …, -//! "children" | "items" | "submenu": […]}`), or nested objects keyed by label. App, Apple and Help -//! menus, recent-file lists, account / cloud items and macOS system items are counted out of scope. +//! - `menus` (default): the shipped menu bar (`crates/ui-egui/examples/menu_tree.rs`); paths are +//! menu paths. App, Apple and Help menus, recent-file lists, account / cloud items and macOS +//! system items are counted out of scope. +//! - `effects` / `transitions`: the effects (video and audio) or transitions (video and audio) the +//! Effects panel lists, from the engine's effect definitions +//! (`crates/project/examples/effect_catalog.rs`); paths are Effects-panel folders +//! (`Video Effects > Blur & Sharpen > Gaussian Blur`). A reference entry may also be a bare name, +//! which has no folder and so is matched by name alone. Presets and Lumetri Presets are out of +//! scope, and so are the other area's items (a folder named `… Transitions`), so one reference +//! of the whole Effects panel serves both areas. //! -//! The report goes to `target/parity/parity-checklist.md` (or `--out`); `--min` fails when presence +//! `--ours` reads a saved dump of our side instead of running the example. The reference defaults +//! to the maintainers' local snapshot `plan/premiere/.json` (gitignored, never committed). +//! Without one the tool prints our own inventory and measures nothing. The reference may be any +//! JSON of paths: an array of paths (`["File", "New", "Sequence…"]`) or of +//! `"File > New > Sequence…"` strings, a tree of nodes (`{"name": …, "children" | "items" | +//! "submenu": […]}`), or nested objects keyed by label. +//! +//! The report goes to `target/parity/parity-checklist.md` for menus and +//! `target/parity/parity-.md` for the other areas (or `--out`); `--min` fails when presence //! is below the given percentage. use std::collections::{BTreeMap, HashMap, HashSet}; @@ -30,10 +41,99 @@ const MAX_ITEMS: usize = 20_000; /// Keys holding a node's label, and its children. const LABEL_KEYS: [&str; 3] = ["name", "title", "label"]; const CHILD_KEYS: [&str; 6] = ["children", "items", "submenu", "menu", "menuItems", "menu_items"]; -/// Keys of a wrapper object around the whole menu bar. -const WRAPPER_KEYS: [&str; 5] = ["menus", "menuBar", "menu_bar", "menubar", "items"]; +/// Keys of a wrapper object around the whole menu bar, or the whole effect list. +const MENU_WRAPPER_KEYS: [&str; 5] = ["menus", "menuBar", "menu_bar", "menubar", "items"]; +const EFFECT_WRAPPER_KEYS: [&str; 4] = ["effects", "transitions", "folders", "items"]; + +/// What a parity run measures. +#[derive(Clone, Copy, Debug, PartialEq)] +enum Area { + Menus, + Effects, + Transitions, +} + +impl Area { + fn parse(s: &str) -> Result { + match s { + "menus" => Ok(Area::Menus), + "effects" => Ok(Area::Effects), + "transitions" => Ok(Area::Transitions), + other => Err(format!("--area {other}: expected menus, effects or transitions")), + } + } + + fn name(self) -> &'static str { + match self { + Area::Menus => "menus", + Area::Effects => "effects", + Area::Transitions => "transitions", + } + } + + /// Our side, as the report names it. + fn ours(self) -> &'static str { + match self { + Area::Menus => "shipped menu bar", + Area::Effects => "Effects panel effects", + Area::Transitions => "Effects panel transitions", + } + } + + /// What a path is made of: "menu path" or "folder". + fn place(self) -> &'static str { + match self { + Area::Menus => "menu path", + Area::Effects | Area::Transitions => "folder", + } + } + + fn out_file(self) -> String { + match self { + Area::Menus => "parity-checklist.md".into(), + other => format!("parity-{}.md", other.name()), + } + } + + /// Fewest path parts a reference item has: a menu item needs its menu, an effect may be a + /// bare name. + fn min_len(self) -> usize { + match self { + Area::Menus => 2, + Area::Effects | Area::Transitions => 1, + } + } + + fn wrapper_keys(self) -> &'static [&'static str] { + match self { + Area::Menus => &MENU_WRAPPER_KEYS, + Area::Effects | Area::Transitions => &EFFECT_WRAPPER_KEYS, + } + } + + /// The effect kinds (`EffectKind` names in the catalog dump) this area counts. + fn kinds(self) -> &'static [&'static str] { + match self { + Area::Menus => &[], + Area::Effects => &["Video", "Audio"], + Area::Transitions => &["VideoTransition", "AudioTransition"], + } + } + + /// The group a path is tallied under in the report: the top-level menu, or the folder path. + fn group(self, path: &[String]) -> String { + match self { + Area::Menus => path.first().cloned().unwrap_or_default(), + Area::Effects | Area::Transitions => match path.split_last() { + Some((_, folders)) if !folders.is_empty() => show(folders), + _ => "(no folder)".into(), + }, + } + } +} struct Args { + area: Area, reference: Option, ours: Option, out: Option, @@ -41,11 +141,12 @@ struct Args { } fn parse_args(args: &[&str]) -> Result { - let mut a = Args { reference: None, ours: None, out: None, min: None }; + let mut a = Args { area: Area::Menus, reference: None, ours: None, out: None, min: None }; let mut it = args.iter(); while let Some(flag) = it.next() { let mut value = || it.next().map(|v| v.to_string()).ok_or_else(|| format!("{flag} needs a value")); match *flag { + "--area" => a.area = Area::parse(&value()?)?, "--reference" => a.reference = Some(value()?.into()), "--ours" => a.ours = Some(value()?.into()), "--out" => a.out = Some(value()?.into()), @@ -59,7 +160,7 @@ fn parse_args(args: &[&str]) -> Result { } other => { return Err(format!( - "parity: unknown argument {other} (usage: cargo xtask parity [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" + "parity: unknown argument {other} (usage: cargo xtask parity [--area menus|effects|transitions] [--reference FILE] [--ours FILE] [--out FILE] [--min PERCENT])" )); } } @@ -69,30 +170,36 @@ fn parse_args(args: &[&str]) -> Result { pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { let args = parse_args(args)?; + let area = args.area; let ours_json = match &args.ours { Some(p) => std::fs::read_to_string(p).map_err(|e| format!("{}: {e}", p.display()))?, - None => dump_ours()?, + None => dump_ours(area)?, }; - let ours = our_items(&serde_json::from_str(&ours_json).map_err(|e| format!("our menu dump: {e}"))?)?; - let default_ref = root.join("plan/premiere/menus.json"); + let ours_value: Value = serde_json::from_str(&ours_json).map_err(|e| format!("our {} dump: {e}", area.name()))?; + let ours = match area { + Area::Menus => our_items(&ours_value)?, + Area::Effects | Area::Transitions => our_catalog(&ours_value, area)?, + }; + let default_ref = root.join(format!("plan/premiere/{}.json", area.name())); let reference = match &args.reference { Some(p) => Some(p.clone()), None => default_ref.exists().then_some(default_ref), }; let Some(reference) = reference else { - print!("{}", inventory(&ours)); - println!("\nno reference menu list (plan/premiere/menus.json, or --reference FILE): menu parity not measured"); + print!("{}", inventory(area, &ours)); + let a = area.name(); + println!("\nno reference list (plan/premiere/{a}.json, or --reference FILE): {a} parity not measured"); return match args.min { - Some(_) => Err("--min needs a reference menu list".into()), + Some(_) => Err("--min needs a reference list".into()), None => Ok(()), }; }; let text = std::fs::read_to_string(&reference).map_err(|e| format!("{}: {e}", reference.display()))?; let value: Value = serde_json::from_str(&text).map_err(|e| format!("{}: {e}", reference.display()))?; - let paths = reference_paths(&value)?; + let paths = reference_paths(&value, area)?; let name = reference.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); - let report = compare(&paths, &ours); - let out = args.out.unwrap_or_else(|| crate::target_dir().join("parity/parity-checklist.md")); + let report = compare(area, &paths, &ours); + let out = args.out.unwrap_or_else(|| crate::target_dir().join("parity").join(area.out_file())); if let Some(dir) = out.parent() { std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; } @@ -100,20 +207,25 @@ pub fn run(root: &Path, args: &[&str]) -> Result<(), String> { println!("{}", report.summary()); println!("report: {}", out.display()); match args.min { - Some(min) if report.presence() < min => Err(format!("menu presence {:.1}% is below --min {min}%", report.presence())), + Some(min) if report.presence() < min => Err(format!("{} presence {:.1}% is below --min {min}%", area.name(), report.presence())), _ => Ok(()), } } -/// Our menu bar as JSON, from the `menu_tree` example. -fn dump_ours() -> Result { +/// Our side as JSON: the menu bar from the `menu_tree` example, or the Effects panel catalog from +/// the `effect_catalog` example. +fn dump_ours(area: Area) -> Result { + let (package, example) = match area { + Area::Menus => ("filmcraft-ui-egui", "menu_tree"), + Area::Effects | Area::Transitions => ("filmcraft-project", "effect_catalog"), + }; let mut cmd = Command::new(env!("CARGO")); - cmd.args(["run", "-q", "-p", "filmcraft-ui-egui", "--example", "menu_tree"]).stderr(Stdio::inherit()); - let out = cmd.output().map_err(|e| format!("menu_tree: {e}"))?; + cmd.args(["run", "-q", "-p", package, "--example", example]).stderr(Stdio::inherit()); + let out = cmd.output().map_err(|e| format!("{example}: {e}"))?; if !out.status.success() { - return Err(format!("menu_tree failed: {}", out.status)); + return Err(format!("{example} failed: {}", out.status)); } - String::from_utf8(out.stdout).map_err(|e| format!("menu_tree: {e}")) + String::from_utf8(out.stdout).map_err(|e| format!("{example}: {e}")) } /// Full paths (menu path + label) of our menu items, from the `menu_tree` dump. @@ -130,32 +242,47 @@ fn our_items(v: &Value) -> Result>, String> { .collect()) } -/// Item counts per top-level menu, for runs without a reference. -fn inventory(ours: &[Vec]) -> String { - let mut per: BTreeMap<&str, usize> = BTreeMap::new(); +/// Full paths (Effects-panel folders + name) of the catalog items of `area`, from the +/// `effect_catalog` dump. +fn our_catalog(v: &Value, area: Area) -> Result>, String> { + let items = v.as_array().ok_or("our effect catalog dump is not an array")?; + Ok(items + .iter() + .filter(|it| it.get("kind").and_then(Value::as_str).is_some_and(|k| area.kinds().contains(&k))) + .filter_map(|it| { + let name = it.get("name")?.as_str()?; + let mut path: Vec = it.get("path")?.as_array()?.iter().filter_map(|p| p.as_str().map(str::to_string)).collect(); + path.push(name.to_string()); + Some(path) + }) + .collect()) +} + +/// Item counts per group (top-level menu, or folder), for runs without a reference. +fn inventory(area: Area, ours: &[Vec]) -> String { + let mut per: BTreeMap = BTreeMap::new(); for p in ours { - if let Some(top) = p.first() { - *per.entry(top.as_str()).or_default() += 1; - } + *per.entry(area.group(p)).or_default() += 1; } - let mut s = format!("FilmCraft menu bar: {} items\n", ours.len()); - for (top, n) in per { - s.push_str(&format!(" {top:<22} {n}\n")); + let mut s = format!("FilmCraft {}: {} items\n", area.ours(), ours.len()); + let width = per.keys().map(|g| g.chars().count()).max().unwrap_or(0).max(22); + for (group, n) in per { + s.push_str(&format!(" {group: Result>, String> { +/// Leaf paths (top-level menu or folder first, item label last) of a reference file. +fn reference_paths(v: &Value, area: Area) -> Result>, String> { let mut root = v; if let Value::Object(o) = v && label_of(o).is_none() - && let Some(inner) = WRAPPER_KEYS.iter().find_map(|k| o.get(*k).filter(|c| c.is_array() || c.is_object())) + && let Some(inner) = area.wrapper_keys().iter().find_map(|k| o.get(*k).filter(|c| c.is_array() || c.is_object())) { root = inner; } let mut out = Vec::new(); - walk(root, &[], 0, &mut out)?; + walk(root, &[], 0, area.min_len(), &mut out)?; Ok(out) } @@ -169,16 +296,16 @@ fn is_separator(label: &str) -> bool { l.is_empty() || l.chars().all(|c| matches!(c, '-' | '—' | '–' | '_')) || l.eq_ignore_ascii_case("missing value") || l.eq_ignore_ascii_case("separator") } -fn push_leaf(prefix: &[String], label: &str, out: &mut Vec>) -> Result<(), String> { +fn push_leaf(prefix: &[String], label: &str, min: usize, out: &mut Vec>) -> Result<(), String> { if is_separator(label) || prefix.iter().any(|p| is_separator(p)) { return Ok(()); } if out.len() >= MAX_ITEMS { - return Err(format!("reference has more than {MAX_ITEMS} menu items")); + return Err(format!("reference has more than {MAX_ITEMS} items")); } let mut path = prefix.to_vec(); path.push(label.trim().to_string()); - if path.len() >= 2 { + if path.len() >= min.max(1) { out.push(path); } Ok(()) @@ -189,9 +316,9 @@ fn split_path(s: &str) -> Option> { (parts.len() >= 2 && parts.iter().all(|p| !p.is_empty())).then_some(parts) } -fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) -> Result<(), String> { +fn walk(v: &Value, prefix: &[String], depth: usize, min: usize, out: &mut Vec>) -> Result<(), String> { if depth > MAX_DEPTH { - return Err(format!("reference menus are nested deeper than {MAX_DEPTH} levels")); + return Err(format!("reference is nested deeper than {MAX_DEPTH} levels")); } let with = |label: &str| { let mut p = prefix.to_vec(); @@ -201,10 +328,10 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) match v { Value::String(s) => match split_path(s).filter(|_| prefix.is_empty()) { Some(parts) => match parts.split_last() { - Some((label, menus)) => push_leaf(menus, label, out), + Some((label, menus)) => push_leaf(menus, label, min, out), None => Ok(()), }, - None => push_leaf(prefix, s, out), + None => push_leaf(prefix, s, min, out), }, Value::Array(a) => { for el in a { @@ -212,10 +339,10 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) Value::Array(path) if !path.is_empty() && path.iter().all(Value::is_string) => { let parts: Vec = prefix.iter().cloned().chain(path.iter().filter_map(|p| p.as_str().map(str::to_string))).collect(); if let Some((label, menus)) = parts.split_last() { - push_leaf(menus, label, out)?; + push_leaf(menus, label, min, out)?; } } - _ => walk(el, prefix, depth + 1, out)?, + _ => walk(el, prefix, depth + 1, min, out)?, } } Ok(()) @@ -224,17 +351,17 @@ fn walk(v: &Value, prefix: &[String], depth: usize, out: &mut Vec>) Some(label) => { let children = CHILD_KEYS.iter().find_map(|k| o.get(*k).filter(|c| has_entries(c))); match children { - Some(c) if !is_separator(label) => walk(c, &with(label), depth + 1, out), + Some(c) if !is_separator(label) => walk(c, &with(label), depth + 1, min, out), Some(_) => Ok(()), - None => push_leaf(prefix, label, out), + None => push_leaf(prefix, label, min, out), } } None => { for (k, val) in o { if has_entries(val) { - walk(val, &with(k), depth + 1, out)?; + walk(val, &with(k), depth + 1, min, out)?; } else { - push_leaf(prefix, k, out)?; + push_leaf(prefix, k, min, out)?; } } Ok(()) @@ -259,7 +386,26 @@ fn norm(label: &str) -> String { } /// Why a reference item is not counted, if it isn't. -fn out_of_scope(path: &[String]) -> Option<&'static str> { +fn out_of_scope(area: Area, path: &[String]) -> Option<&'static str> { + match area { + Area::Menus => menu_out_of_scope(path), + Area::Effects | Area::Transitions => { + let top = norm(path.first()?); + if path.len() >= 2 && (top == "presets" || top == "lumetri presets") { + return Some("presets"); + } + let folders = path.get(..path.len().saturating_sub(1)).unwrap_or_default(); + let transition = folders.iter().any(|f| norm(f).ends_with("transitions")); + match area { + Area::Effects if transition => Some("transitions (--area transitions)"), + Area::Transitions if !folders.is_empty() && !transition => Some("effects (--area effects)"), + _ => None, + } + } + } +} + +fn menu_out_of_scope(path: &[String]) -> Option<&'static str> { let (top, label) = (norm(path.first()?), norm(path.last()?)); if top == "apple" || top == "help" || top.contains("premiere") { return Some("app, Apple and Help menus"); @@ -284,13 +430,14 @@ enum Placement { } struct Report { + area: Area, /// (reference path, where it is in FilmCraft). items: Vec<(Vec, Placement)>, out_of_scope: BTreeMap<&'static str, usize>, ours_only: Vec>, } -fn compare(reference: &[Vec], ours: &[Vec]) -> Report { +fn compare(area: Area, reference: &[Vec], ours: &[Vec]) -> Report { let key = |p: &[String]| p.iter().map(|s| norm(s)).collect::>(); let our_paths: HashSet> = ours.iter().map(|p| key(p)).collect(); let mut by_label: HashMap> = HashMap::new(); @@ -299,18 +446,19 @@ fn compare(reference: &[Vec], ours: &[Vec]) -> Report { by_label.entry(norm(l)).or_insert(p); } } - let mut report = Report { items: Vec::new(), out_of_scope: BTreeMap::new(), ours_only: Vec::new() }; + let mut report = Report { area, items: Vec::new(), out_of_scope: BTreeMap::new(), ours_only: Vec::new() }; let mut seen = HashSet::new(); for p in reference { if !seen.insert(key(p)) { continue; } - if let Some(why) = out_of_scope(p) { + if let Some(why) = out_of_scope(area, p) { *report.out_of_scope.entry(why).or_default() += 1; continue; } let label = p.last().map(|l| norm(l)).unwrap_or_default(); - let placement = if our_paths.contains(&key(p)) { + // a bare name (no folder) has no place to compare: found is all it can be + let placement = if our_paths.contains(&key(p)) || (p.len() == 1 && by_label.contains_key(&label)) { Placement::Same } else if let Some(at) = by_label.get(&label) { Placement::Elsewhere((*at).clone()) @@ -349,7 +497,8 @@ impl Report { let (same, elsewhere, missing) = self.counts(); let n = self.items.len(); format!( - "menus: presence {}/{n} ({:.1}%), same place {same}/{n} ({:.1}%), elsewhere {elsewhere}, missing {missing}, out of scope {}", + "{}: presence {}/{n} ({:.1}%), same place {same}/{n} ({:.1}%), elsewhere {elsewhere}, missing {missing}, out of scope {}", + self.area.name(), same + elsewhere, self.presence(), pct(same, n), @@ -360,17 +509,30 @@ impl Report { fn markdown(&self, reference: &str, ours: usize) -> String { let (same, elsewhere, missing) = self.counts(); let n = self.items.len(); - let mut s = String::from("# Menu parity checklist\n\n"); + let area = self.area; + let (title, column) = match area { + Area::Menus => ("Menu", "Menu"), + Area::Effects => ("Effect", "Folder"), + Area::Transitions => ("Transition", "Folder"), + }; + let mut s = format!("# {title} parity checklist\n\n"); s.push_str(&format!( - "Generated by `cargo xtask parity` from `{reference}` ({n} items in scope, {} out of scope) against FilmCraft's \ - shipped menu bar ({ours} items). *Same place*: we have the label at the same menu path; *elsewhere*: under \ - another path; *missing*: nowhere. A label match is presence only, not behaviour.\n\n", - self.out_of_scope.values().sum::() + "Generated by `cargo xtask parity --area {}` from `{reference}` ({n} items in scope, {} out of scope) against \ + FilmCraft's {} ({ours} items). *Same place*: we have the label at the same {place}; *elsewhere*: under \ + another {place}; *missing*: nowhere. A label match is presence only, not behaviour.{}\n\n", + area.name(), + self.out_of_scope.values().sum::(), + area.ours(), + match area { + Area::Menus => "", + Area::Effects | Area::Transitions => " A bare name with no folder in the reference is matched by name alone.", + }, + place = area.place(), )); - s.push_str("| Menu | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n"); - let mut per: Vec<(&str, [usize; 3])> = Vec::new(); + s.push_str(&format!("| {column} | Items | Same place | Elsewhere | Missing | Presence |\n|---|---|---|---|---|---|\n")); + let mut per: Vec<(String, [usize; 3])> = Vec::new(); for (p, pl) in &self.items { - let top = p.first().map(String::as_str).unwrap_or_default(); + let top = area.group(p); let i = match per.iter().position(|(t, _)| *t == top) { Some(i) => i, None => { @@ -440,7 +602,7 @@ mod tests { json!({"Edit": {"Undo": "Cmd+Z"}, "File": {"New": ["Sequence…"], "-": null, "Save": null}}), ]; for f in forms { - let mut got = reference_paths(&f).unwrap(); + let mut got = reference_paths(&f, Area::Menus).unwrap(); got.sort(); let mut w = want.clone(); w.sort(); @@ -454,11 +616,15 @@ mod tests { for _ in 0..200 { v = json!({ "name": "m", "children": [v] }); } - assert!(reference_paths(&v).unwrap_err().contains("nested deeper")); - let many: Vec = (0..MAX_ITEMS + 1).map(|i| format!("File > Item {i}")).collect(); - assert!(reference_paths(&json!(many)).unwrap_err().contains("more than")); + for area in [Area::Menus, Area::Effects] { + assert!(reference_paths(&v, area).unwrap_err().contains("nested deeper")); + let many: Vec = (0..MAX_ITEMS + 1).map(|i| format!("File > Item {i}")).collect(); + assert!(reference_paths(&json!(many), area).unwrap_err().contains("more than")); + let bare: Vec = (0..MAX_ITEMS + 1).map(|i| format!("Effect {i}")).collect(); + assert!(area == Area::Menus || reference_paths(&json!(bare), area).unwrap_err().contains("more than")); + } // odd values are skipped, not trusted - assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]])).unwrap(), Vec::>::new()); + assert_eq!(reference_paths(&json!([1, null, true, {"name": 5}, ["File"]]), Area::Menus).unwrap(), Vec::>::new()); } #[test] @@ -474,7 +640,7 @@ mod tests { &["Edit", "Spelling"], ]); let ours = paths(&[&["File", "New", "Sequence…"], &["File", "Export", "Export Frame"], &["Edit", "Find and Replace…"], &["Clip", "Nest…"]]); - let r = compare(&reference, &ours); + let r = compare(Area::Menus, &reference, &ours); assert_eq!(r.counts(), (2, 1, 1), "duplicates count once; out-of-scope items are not counted"); assert!((r.presence() - 75.0).abs() < 1e-9); assert_eq!(r.out_of_scope.values().sum::(), 3); @@ -488,7 +654,7 @@ mod tests { #[test] fn empty_reference_measures_zero_without_dividing_by_zero() { - let r = compare(&[], &paths(&[&["File", "Save"]])); + let r = compare(Area::Menus, &[], &paths(&[&["File", "Save"]])); assert_eq!(r.presence(), 0.0); assert!(r.summary().contains("presence 0/0 (0.0%)")); } @@ -502,6 +668,79 @@ mod tests { assert!(parse_args(&["--min", "x"]).is_err()); assert!(parse_args(&["--out"]).is_err()); assert!(parse_args(&["--bogus"]).is_err()); - assert!(parse_args(&["--min", "90", "--reference", "r.json"]).is_ok_and(|a| a.min == Some(90.0) && a.reference.is_some())); + assert!(parse_args(&["--min", "90", "--reference", "r.json"]).is_ok_and(|a| a.min == Some(90.0) && a.reference.is_some() && a.area == Area::Menus)); + assert!(parse_args(&["--area", "transitions"]).is_ok_and(|a| a.area == Area::Transitions)); + assert!(parse_args(&["--area", "panels"]).is_err()); + assert!(parse_args(&["--area"]).is_err()); + } + + fn catalog() -> Value { + json!([ + {"id": "gaussian_blur", "name": "Gaussian Blur", "kind": "Video", "path": ["Video Effects", "Blur & Sharpen"]}, + {"id": "mosaic", "name": "Mosaic", "kind": "Video", "path": ["Video Effects", "Stylize"]}, + {"id": "volume_x", "name": "Hard Limiter", "kind": "Audio", "path": ["Audio Effects", "Amplitude and Compression"]}, + {"id": "cross_dissolve", "name": "Cross Dissolve", "kind": "VideoTransition", "path": ["Video Transitions", "Dissolve"]}, + {"id": "push_legacy", "name": "Push (Legacy)", "kind": "VideoTransition", "path": ["Legacy", "Video Transitions"]}, + {"id": "constant_power", "name": "Constant Power", "kind": "AudioTransition", "path": ["Audio Transitions", "Crossfade"]}, + {"bad": true}, + {"name": "No kind", "path": []} + ]) + } + + #[test] + fn effect_catalog_splits_into_effects_and_transitions() { + let fx = our_catalog(&catalog(), Area::Effects).unwrap(); + assert_eq!( + fx, + paths(&[ + &["Video Effects", "Blur & Sharpen", "Gaussian Blur"], + &["Video Effects", "Stylize", "Mosaic"], + &["Audio Effects", "Amplitude and Compression", "Hard Limiter"] + ]) + ); + let tr = our_catalog(&catalog(), Area::Transitions).unwrap(); + assert_eq!(tr.len(), 3); + assert!(tr.contains(&paths(&[&["Legacy", "Video Transitions", "Push (Legacy)"]])[0])); + assert!(our_catalog(&json!({"effects": []}), Area::Effects).is_err()); + let inv = inventory(Area::Effects, &fx); + assert!(inv.starts_with("FilmCraft Effects panel effects: 3 items"), "{inv}"); + assert!(inv.contains("Video Effects ▸ Stylize"), "{inv}"); + } + + #[test] + fn effects_reference_of_bare_names_and_folders_is_measured() { + // one reference of the whole Effects panel: folders, bare names, presets, both areas + let reference = json!({"effects": { + "Presets": {"Bevel Edges": ["Bevel Edges Thick"]}, + "Video Effects": {"Blur & Sharpen": ["Gaussian Blur", "Camera Blur"], "Distort": ["Mosaic"]}, + "Video Transitions": {"Dissolve": ["Cross Dissolve", "Film Dissolve"]}, + "Audio Transitions": {"Crossfade": ["Constant Power"]} + }}); + let bare = json!(["Hard Limiter", "Echo"]); + let mut fx_ref = reference_paths(&reference, Area::Effects).unwrap(); + assert_eq!(fx_ref.len(), 7); + fx_ref.extend(reference_paths(&bare, Area::Effects).unwrap()); + // menus need a menu: bare names are not menu items + assert!(reference_paths(&bare, Area::Menus).unwrap().is_empty()); + let ours = our_catalog(&catalog(), Area::Effects).unwrap(); + let r = compare(Area::Effects, &fx_ref, &ours); + // Gaussian Blur same; Mosaic elsewhere (Stylize); Hard Limiter by bare name; Camera Blur, Echo missing + assert_eq!(r.counts(), (2, 1, 2), "{:?}", r.summary()); + assert_eq!(r.out_of_scope.get("presets"), Some(&1)); + assert_eq!(r.out_of_scope.get("transitions (--area transitions)"), Some(&3)); + let md = r.markdown("effects.json", ours.len()); + assert!(md.starts_with("# Effect parity checklist"), "{md}"); + assert!(md.contains("| Folder | Items |"), "{md}"); + assert!(md.contains("| Video Effects ▸ Blur & Sharpen | 2 | 1 | 0 | 1 | 50.0% |"), "{md}"); + assert!(md.contains("| (no folder) | 2 | 1 | 0 | 1 | 50.0% |"), "{md}"); + assert!(md.contains("- Video Effects ▸ Distort ▸ Mosaic → Video Effects ▸ Stylize ▸ Mosaic"), "{md}"); + assert!(md.contains("- [ ] Echo"), "{md}"); + assert!(r.summary().starts_with("effects: presence 3/5 (60.0%)"), "{}", r.summary()); + + let tr_ref = reference_paths(&reference, Area::Transitions).unwrap(); + let tr = compare(Area::Transitions, &tr_ref, &our_catalog(&catalog(), Area::Transitions).unwrap()); + assert_eq!(tr.counts(), (2, 0, 1), "{}", tr.summary()); + assert_eq!(tr.out_of_scope.get("effects (--area effects)"), Some(&3)); + assert!(tr.markdown("effects.json", 3).contains("- [ ] Video Transitions ▸ Dissolve ▸ Film Dissolve")); } } From f79a51dd24b3973d603650fa24bc2abdada28f5e Mon Sep 17 00:00:00 2001 From: MrCloudy2 Date: Sat, 10 Oct 2026 17:14:34 +0200 Subject: [PATCH 067/203] #480: exports no longer stop for good when a clip's decoding uses rayon A GopCache decodes with its lock held, and that decoding starts rayon work (rotating iPhone frames, cropping, slice-threaded decoders). On the global pool a worker that took part of it could, waiting inside it, pick up another render job asking the same cache for a frame and block on the lock, while the lock holder waited for the work on that worker's stack: exports of rotated iPhone HEVC clips stopped making frames in about half the runs. The locked decoding now runs on a separate decode pool, whose threads never wait for a cache's lock; a caller that is a rayon worker waits on a plain thread so it does no other rayon work while holding the lock. The cancel/catch-up/draft/ background hints travel with it (filmcraft_media::cancel::Context). Intra-only streams crop and rotate before taking the lock again. Second version of #662 (reverted): the regression test's stand-in decoder summed with overflow, which panics in debug builds; it now wraps explicitly. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + crates/codecs/Cargo.toml | 1 + crates/codecs/src/gop.rs | 164 +++++++++++++++++++++++++++++++++++-- crates/media/src/cancel.rs | 54 ++++++++++++ 4 files changed, 211 insertions(+), 9 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5529201a..068d93ea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1830,6 +1830,7 @@ dependencies = [ "filmcraft-vp9", "image", "log", + "rayon", "serde_json", "symphonia", "thiserror 2.0.21", diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 3a490e2c..7caf5eb9 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -31,6 +31,7 @@ filmcraft-av1 = { workspace = true } filmcraft-apv = { workspace = true } filmcraft-aac = { workspace = true } filmcraft-opus = { workspace = true } +rayon = { workspace = true } thiserror = { workspace = true } log = { workspace = true } # Decode timing counters (`gop_stats`); std's Instant panics on wasm32. diff --git a/crates/codecs/src/gop.rs b/crates/codecs/src/gop.rs index c1395e43..497683e1 100644 --- a/crates/codecs/src/gop.rs +++ b/crates/codecs/src/gop.rs @@ -109,8 +109,9 @@ impl std::ops::Sub for GopStats { } } -/// A container's video sample table, in decode (file) order. -pub trait VideoSamples { +/// A container's video sample table, in decode (file) order. `Sync`: a cache decodes on its own +/// threads ([`isolated`]). +pub trait VideoSamples: Sync { fn count(&self) -> usize; /// Presentation timestamp of sample `i` (track units). fn pts(&self, i: usize) -> i64; @@ -437,6 +438,57 @@ const MIN_FRAMES: usize = 64; /// rather than restarting at the sync sample. const CONTINUE_THROUGH: usize = 48; +/// Threads that run the caches' decoding (see [`isolated`]); `None` where none can be made. +#[cfg(not(target_arch = "wasm32"))] +fn decode_pool() -> Option<&'static rayon::ThreadPool> { + static POOL: std::sync::OnceLock> = std::sync::OnceLock::new(); + POOL.get_or_init(|| { + let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1); + rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok() + }) + .as_ref() +} + +/// Run a cache's decoding, its lock held by the caller, on [`decode_pool`], the caller waiting +/// without doing other rayon work meanwhile (#480). +/// +/// That decoding starts rayon work (a decoder's slices, cropping and rotating the frames). On the +/// pool of the render jobs that ask the cache for frames, a worker that took part of it could, +/// waiting inside it, pick up another of those jobs and block on the lock: the lock holder then +/// waits for its work on the blocked worker's stack, and that worker for the lock, for good (an +/// export of rotated iPhone clips stopped making frames). On the decode pool that work only ever +/// runs beside other caches' decoding, which never waits for a cache's lock. A caller that is a +/// rayon worker would also run other jobs of its pool while it waits for the decode pool, some +/// of them asking for a cache's lock while it holds this one: it waits on a plain thread instead. +fn isolated(f: impl FnOnce() -> crate::Result + Send) -> crate::Result { + let ctx = filmcraft_media::cancel::Context::current(); + let job = move || ctx.run(f); + #[cfg(not(target_arch = "wasm32"))] + if let Some(pool) = decode_pool() { + if rayon::current_thread_index().is_none() { + return pool.install(job); + } + let slot = Mutex::new(Some(job)); + let waited = std::thread::scope(|scope| { + let spawned = std::thread::Builder::new().name("decode-wait".into()).spawn_scoped(scope, || { + let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take(); + job.map(|job| pool.install(job)) + }); + spawned.map(|h| h.join()) + }); + return match waited { + Ok(Ok(Some(r))) => r, + Ok(Err(_)) => Err(CodecError::Decode("internal error while decoding".into())), + // no thread to wait on: decode here, as before + Ok(Ok(None)) | Err(_) => match slot.into_inner().unwrap_or_else(PoisonError::into_inner) { + Some(job) => job(), + None => Err(CodecError::Decode("internal error while decoding".into())), + }, + }; + } + job() +} + impl GopCache { pub fn new(explicit_color: Option) -> Self { // unit tests count decoder restarts: each of their caches gets a pool of its own @@ -494,7 +546,11 @@ impl GopCache { } fn store(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { - let f = self.finish(f); + self.store_finished(st, pts, self.finish(f), draft); + } + + /// Cache a frame [`Self::finish`] has been applied to. + fn store_finished(&self, st: &mut State, pts: i64, f: VideoFrame, draft: bool) { st.last_used = Instant::now(); if draft { st.drafts.insert(pts); @@ -616,6 +672,22 @@ impl GopCache { if st.intra { return self.intra_frame(st, s, i, want_pts); } + // with the lock held: see `isolated` + let st = &mut *st; + isolated(|| self.decode_until(st, s, i, want_pts, late_before, draft)) + } + + /// Decode (the lock held) until the frame with `want_pts` (sample `i`) is cached; return it. + fn decode_until( + &self, + st: &mut State, + s: &dyn VideoSamples, + i: usize, + want_pts: i64, + late_before: Option, + draft: bool, + ) -> crate::Result> { + let n = s.count(); let mut key = decode_start(s, i); // Continue the running decoder when it has passed the wanted sample's sync sample and // either has not reached the sample yet or has been fed it without outputting it yet @@ -669,14 +741,14 @@ impl GopCache { let out = timed(|| decoder.decode(&data, s.pts(k)))?; st.next += 1; DECODED.fetch_add(1, Ordering::Relaxed); - self.store_output(&mut st, out); + self.store_output(st, out); if st.cached(want_pts, draft).is_some() { break; } } if st.cached(want_pts, draft).is_none() { let out = st.decoder.as_mut().map(|d| timed(|| d.flush())).unwrap_or_default(); - self.store_output(&mut st, out); + self.store_output(st, out); st.next = usize::MAX; } // nearest decoded frame at or before the wanted pts (robust to decoder pts quirks) @@ -761,14 +833,17 @@ impl GopCache { DECODED.fetch_add(1, Ordering::Relaxed); timed(|| dec.decode(&data, want_pts)) }); + // crop and rotate before taking the lock: that work runs on rayon (see `isolated`) + let res = res.map(|out| { + count_frames(&out); + out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::>() + }); let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner()); if st.spare.len() < 16 { st.spare.push(dec); } - let res = res?; - count_frames(&res); - for d in res { - self.store(&mut st, d.pts, d.frame, d.draft); + for (pts, frame, draft) in res? { + self.store_finished(&mut st, pts, frame, draft); } st.at_or_before(want_pts, true).ok_or_else(|| CodecError::Decode("frame not produced".into())) } @@ -900,6 +975,77 @@ mod tests { assert!(s.decodes.load(Ordering::Relaxed) <= 60 + 24 + 1); } + /// A decoder that, like a slice-threaded one, splits each picture's work over rayon. + struct ParDec(Box); + + impl VideoDecoder for ParDec { + fn decode(&mut self, sample: &[u8], pts: i64) -> crate::Result> { + use rayon::prelude::*; + let rows: u64 = (0..64u32) + .into_par_iter() + .with_max_len(1) + .map(|r| (0..20_000u64).fold(u64::from(r), |a, x| a.wrapping_mul(31).wrapping_add(x))) + .reduce(|| 0, u64::wrapping_add); + std::hint::black_box(rows); + self.0.decode(sample, pts) + } + fn flush(&mut self) -> Vec { + self.0.flush() + } + fn reset(&mut self) { + self.0.reset() + } + fn name(&self) -> &str { + "par" + } + } + + struct ParSamples(Samples); + + impl VideoSamples for ParSamples { + fn count(&self) -> usize { + self.0.count() + } + fn pts(&self, i: usize) -> i64 { + self.0.pts(i) + } + fn sync_before(&self, i: usize) -> usize { + self.0.sync_before(i) + } + fn sample_at(&self, t: i64) -> Option { + self.0.sample_at(t) + } + fn read(&self, i: usize) -> crate::Result> { + self.0.read(i) + } + fn make_decoder(&self) -> crate::Result> { + Ok(Box::new(ParDec(self.0.make_decoder()?))) + } + } + + /// #480: render jobs on rayon asking one source for frames while its decoding (a decoder's + /// slices, rotating the frames) runs on rayon too. A worker that took part of that work and, + /// waiting inside it, picked up another frame request blocked on the cache's lock for good: + /// an export of rotated iPhone clips stopped making frames. + #[test] + fn rayon_frame_requests_while_decoding_on_rayon_never_deadlock() { + use rayon::prelude::*; + let s = Arc::new(ParSamples(samples(240, 30, 4, false))); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + for round in 0..40usize { + // export-like: many frame workers, frames of several GOPs of a fresh source at once + let c = GopCache::new(None).with_rotation(1); + (0..96usize).into_par_iter().with_max_len(1).for_each(|k| { + let i = (k * 37 + round * 53) % 240; + assert_eq!(index_of(&c.frame(&*s, i as i64 * 1000).expect("frame")), i); + }); + } + let _ = tx.send(()); + }); + assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked"); + } + #[test] fn running_decoder_continues_through_a_nearby_sync_sample() { let s = samples(200, 30, 0, false); diff --git a/crates/media/src/cancel.rs b/crates/media/src/cancel.rs index bfb391dd..5c2b5f28 100644 --- a/crates/media/src/cancel.rs +++ b/crates/media/src/cancel.rs @@ -96,10 +96,64 @@ pub fn cancelled() -> bool { CURRENT.with(|c| c.borrow().as_ref().is_some_and(|f| f.load(Ordering::Relaxed))) } +/// The hints of the work running on this thread ([`with_cancel`], [`with_catch_up`], +/// [`with_draft`], [`with_background`]), to carry them to another thread doing part of that work. +#[derive(Clone, Default)] +pub struct Context { + cancel: Option>, + catch_up: Option, + draft: bool, + background: bool, +} + +impl Context { + /// This thread's hints. + pub fn current() -> Self { + Context { cancel: CURRENT.with(|c| c.borrow().clone()), catch_up: catch_up(), draft: draft(), background: background() } + } + + /// Run `f` with these hints, restoring this thread's however `f` ends (a pool thread must + /// not keep another job's hints after a panic). + pub fn run(self, f: impl FnOnce() -> R) -> R { + struct Restore(Context); + impl Drop for Restore { + fn drop(&mut self) { + let prev = std::mem::take(&mut self.0); + CURRENT.with(|c| *c.borrow_mut() = prev.cancel); + CATCH_UP.with(|c| c.set(prev.catch_up)); + DRAFT.with(|c| c.set(prev.draft)); + BACKGROUND.with(|c| c.set(prev.background)); + } + } + let _restore = Restore(Context::current()); + CURRENT.with(|c| *c.borrow_mut() = self.cancel); + CATCH_UP.with(|c| c.set(self.catch_up)); + DRAFT.with(|c| c.set(self.draft)); + BACKGROUND.with(|c| c.set(self.background)); + f() + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn context_carries_the_hints_to_another_thread_and_restores_its_own() { + let flag = Arc::new(AtomicBool::new(true)); + let ctx = with_cancel(&flag, || with_draft(true, || with_background(true, || with_catch_up(Some(Tick(5)), Context::current)))); + std::thread::scope(|s| { + s.spawn(|| { + let seen = with_draft(false, || ctx.clone().run(|| (cancelled(), draft(), background(), catch_up()))); + assert_eq!(seen, (true, true, true, Some(Tick(5)))); + // a panic inside leaves this thread's own hints in place + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| ctx.clone().run(|| panic!("job")))); + assert!(r.is_err()); + assert_eq!((cancelled(), draft(), background(), catch_up()), (false, false, false, None)); + }); + }); + } + #[test] fn flag_is_scoped_to_the_closure() { let flag = Arc::new(AtomicBool::new(false)); From 199223f3b994128c1c03cd7493dba43295002a5f Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 10:16:50 +1100 Subject: [PATCH 068/203] #697: Paste cut clips from the keyboard even when old OS files linger Copy and Cut snapshot files or an image already on the system clipboard. Paste ignores that same media so Cmd+V and the Edit menu paste the clips instead of re-importing stale files the platform left beside the clip names. --- crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/menus.rs | 29 +++++++++++- crates/ui-egui/tests/clipboard_paste_ui.rs | 51 ++++++++++++++++++++-- 3 files changed, 79 insertions(+), 5 deletions(-) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5b6208ed..c1d3cb81 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -322,6 +322,9 @@ pub struct FilmcraftApp { pub command_inbox: Option>, /// A V key press (or a Ctrl+V paste) reached the app and its release hasn't yet. v_down: bool, + /// OS files/image that were already on the clipboard when the last Copy/Cut ran. Paste ignores + /// that same media so it pastes the copied clips instead (#697). + pub(crate) stale_clipboard_media: Option, /// GPU compositor (when running on wgpu): device state + compositor + the egui texture it feeds. pub gpu: Option, /// Preview render job being watched (job id, where to start playing when it completes). @@ -535,6 +538,7 @@ impl FilmcraftApp { tl: Default::default(), command_inbox: None, v_down: false, + stale_clipboard_media: None, gpu: None, watched_render: None, applied_prefs: None, diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 1cca0b69..a6b8fea0 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -510,8 +510,9 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: return Ok(json!({"dialog": "revert"})); } // Files or an image on the system clipboard paste into the Timeline, or import into the - // Project panel (#611). Copying clips puts their names there as text, so media on the - // clipboard was copied after them; without media, Paste pastes the copied clips. + // Project panel (#611). Copying clips puts their names there as text, so media copied + // *after* them still wins; media that was already there when Copy/Cut ran is ignored so + // Paste of cut/copied clips still works (#697). "edit.paste" | "edit.pasteInsert" if params.as_object().is_none_or(|m| m.is_empty()) && matches!(app.ui.focused, PanelKind::Timeline | PanelKind::Project) => { @@ -556,11 +557,32 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: app.ui.status = e.clone(); } if r.is_ok() && matches!(id, "edit.copy" | "edit.cut") { + remember_stale_clipboard_media(app); put_clip_names_on_system_clipboard(app, ctx); } r } +/// Snapshot OS files/image present at Copy/Cut. Many platforms keep a previous file list after +/// `copy_text`, so Paste would otherwise import those files instead of the clips (#697). +fn remember_stale_clipboard_media(app: &mut FilmcraftApp) { + app.stale_clipboard_media = app.hooks.clipboard_media.as_mut().and_then(|f| f()).as_ref().map(clipboard_media_key); +} + +fn clipboard_media_key(media: &crate::ClipboardMedia) -> String { + match media { + crate::ClipboardMedia::Files(paths) => format!("files:{}", paths.join("\0")), + crate::ClipboardMedia::Image { width, height, rgba } => { + let mut h: u64 = 0xcbf2_9ce4_8422_2325; + for b in rgba { + h ^= u64::from(*b); + h = h.wrapping_mul(0x0100_0000_01b3); + } + format!("img:{width}x{height}:{h}") + } + } +} + /// Copied clips live in the session, but on Windows and Linux Ctrl+V only reaches the app when the /// system clipboard holds text (an empty clipboard sends no paste at all, #199). Put the copied /// clip names there, like other editors put their own data on the clipboard, so Ctrl+V pastes the @@ -578,6 +600,9 @@ fn put_clip_names_on_system_clipboard(app: &FilmcraftApp, ctx: &egui::Context) { /// imported into the shown bin in the Project panel. None when the clipboard holds neither. fn paste_clipboard_media(app: &mut FilmcraftApp, insert: bool) -> Option> { let media = (app.hooks.clipboard_media.as_mut()?)()?; + if app.stale_clipboard_media.as_ref() == Some(&clipboard_media_key(&media)) { + return None; + } let paths = match media { crate::ClipboardMedia::Files(paths) => paths, crate::ClipboardMedia::Image { width, height, rgba } => match encode_png(width, height, rgba) diff --git a/crates/ui-egui/tests/clipboard_paste_ui.rs b/crates/ui-egui/tests/clipboard_paste_ui.rs index 86beeb73..c01d2e16 100644 --- a/crates/ui-egui/tests/clipboard_paste_ui.rs +++ b/crates/ui-egui/tests/clipboard_paste_ui.rs @@ -185,12 +185,14 @@ fn without_media_ctrl_v_pastes_copied_clips() { let asked = d.clipboard(None); let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap(); d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}})); - d.ok("engine.execute", json!({"command": "edit.copy", "params": {}})); + d.ok("ui.menu.invoke", json!({"id": "edit.copy"})); let copies = |d: &mut Driver| d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(); let n = copies(&mut d); + let asked_after_copy = asked.get(); + assert!(asked_after_copy >= 1, "Copy looks at lingering OS media"); d.ctrl_v_without_text(egui::Modifiers::COMMAND); assert_eq!(copies(&mut d), n + 1, "the copied clip is pasted"); - assert_eq!(asked.get(), 1); + assert_eq!(asked.get(), asked_after_copy + 1); for pressed in [true, false] { d.harness.input_mut().events.push(egui::Event::Key { key: egui::Key::V, @@ -202,5 +204,48 @@ fn without_media_ctrl_v_pastes_copied_clips() { d.frames(1); } d.frames(2); - assert_eq!((copies(&mut d), asked.get()), (n + 1, 1), "V alone pastes nothing"); + assert_eq!((copies(&mut d), asked.get()), (n + 1, asked_after_copy + 1), "V alone pastes nothing"); +} + +/// Files left on the OS clipboard from before Copy/Cut must not steal Paste: Ctrl+V pastes the +/// cut clip, as the Edit menu does (#697). +#[test] +fn ctrl_v_pastes_a_cut_clip_even_when_old_files_linger_on_the_clipboard() { + let mut d = Driver::demo("cut-linger"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let png = d.dir.join("stale.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 0, 200, 255])).save(&png).unwrap(); + let path = png.to_string_lossy().into_owned(); + d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()])))); + let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap(); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}})); + d.ok("ui.menu.invoke", json!({"id": "edit.cut"})); + assert!(d.clips().iter().all(|c| c.2 != first.2), "the clip left the timeline"); + let named = |d: &mut Driver| d.clips().iter().filter(|c| c.3 == first.3).count(); + assert_eq!(named(&mut d), 0); + d.ctrl_v_with_text(); + assert!(named(&mut d) >= 1, "the cut clip is pasted"); + assert!(!d.clips().iter().any(|c| c.3.contains("stale")), "lingering OS files are not pasted"); +} + +/// Copy from the keyboard (Ctrl+C as `Event::Copy`) also snapshots lingering OS media, so Ctrl+V +/// pastes the clips rather than those files. +#[test] +fn ctrl_c_then_ctrl_v_pastes_clips_not_lingering_files() { + let mut d = Driver::demo("copy-linger"); + d.ok("ui.set", json!({"focused": "Timeline"})); + let png = d.dir.join("stale.png"); + image::RgbaImage::from_pixel(8, 8, image::Rgba([0, 200, 0, 255])).save(&png).unwrap(); + let path = png.to_string_lossy().into_owned(); + d.app().hooks.clipboard_media = Some(Box::new(move || Some(ClipboardMedia::Files(vec![path.clone()])))); + let first = d.clips().into_iter().find(|c| c.0 == 'V' && c.1 == 0).unwrap(); + d.ok("engine.execute", json!({"command": "timeline.select", "params": {"clips": [first.2]}})); + d.harness.input_mut().events.extend([egui::Event::ModifiersChanged(egui::Modifiers::COMMAND), egui::Event::Copy]); + d.frames(1); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(2); + assert!(!d.app().session.state.clipboard.is_empty()); + let n = d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(); + d.ctrl_v_with_text(); + assert_eq!(d.clips().iter().filter(|c| c.0 == 'V' && c.3 == first.3).count(), n + 1); } From 42b990b72be035562473d6637bbf0a9e4e1af7ad Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:26:31 +0200 Subject: [PATCH 069/203] #841: import packed 10/12-bit and Y'CbCr DPX stills Problem: DPX import rejected the 10/12-bit packed layout (packing 0) and the Y'CbCr descriptors (100 CbYCrY, 101 CbYACrYA, 102 CbYCr, 103 CbYCrA) with an "unsupported" error. Fix: unpack packed components back to back, low bits first, each scan line on a 32-bit boundary (as ffmpeg reads 12-bit packed files), and convert Y'CbCr to RGB with the matrix the colorimetric field names (BT.709; BT.601 for the 601 and composite codes; by picture size when unspecified), in video range unless the reference codes span the full code range; 4:2:2 chroma is shared by each pixel pair. Unit tests for every new layout and hostile headers; an ffmpeg oracle test builds packed 12-bit RGB and 8-bit 4:2:2 / 4:4:4 / 4:4:4:4 files from ffmpeg's test picture and matches its decode within one 8-bit step. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/media_oracle.rs | 92 ++++++++++++ crates/media/src/dpx.rs | 216 +++++++++++++++++++++++----- docs/file-format-parity.md | 2 +- docs/gaps.md | 6 +- 4 files changed, 280 insertions(+), 36 deletions(-) diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index 4fc6638e..da95c5eb 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -171,6 +171,98 @@ fn dpx_stills_every_layout() { } } +/// A big-endian DPX file holding `data` as its one image element (`w` × `h`, the given +/// descriptor, bit depth, packing and colorimetric code; reference codes undefined). +fn dpx_file(w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, colorimetric: u8, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let put = |f: &mut Vec, at: usize, v: &[u8]| f[at..at + v.len()].copy_from_slice(v); + put(&mut f, 0, b"SDPX"); + put(&mut f, 4, &2048u32.to_be_bytes()); + put(&mut f, 8, b"V2.0"); + put(&mut f, 16, &(2048 + data.len() as u32).to_be_bytes()); + put(&mut f, 770, &1u16.to_be_bytes()); + put(&mut f, 772, &w.to_be_bytes()); + put(&mut f, 776, &h.to_be_bytes()); + let el = 780; + put(&mut f, el + 4, &u32::MAX.to_be_bytes()); + put(&mut f, el + 12, &u32::MAX.to_be_bytes()); + f[el + 20] = descriptor; + f[el + 22] = colorimetric; + f[el + 23] = bits; + put(&mut f, el + 24, &packing.to_be_bytes()); + put(&mut f, el + 28, &2048u32.to_be_bytes()); + f.extend_from_slice(data); + f +} + +/// `pix_fmt` raw frame of ffmpeg's test picture at `w` × `h`. +fn raw_picture(ff: &Path, w: u32, h: u32, pix_fmt: &str) -> Vec { + let alpha = if pix_fmt.contains('a') { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale={w}:{h}{alpha}"); + ffmpeg_out(ff, &["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-f", "rawvideo", "-pix_fmt", pix_fmt, "-"]) +} + +/// A DPX file we built must import exactly like ffmpeg decodes it, within `tol` of its 16-bit +/// decode rounded to 8 bits. +fn dpx_matches(ff: &Path, name: &str, file: Vec, tol: u8) { + let path = dir().join(name); + std::fs::create_dir_all(dir()).unwrap(); + std::fs::write(&path, &file).unwrap(); + let src = filmcraft_codecs::open_bytes(name, file.into()).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + let wide = ffmpeg_out(ff, &["-i", path.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + let want: Vec = wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect(); + assert_eq!(ours.len(), want.len(), "{name}"); + let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(d <= tol, "{name}: differs by {d}"); + eprintln!("{name}: max diff {d}"); +} + +/// Packed 12-bit RGB (packing 0: components back to back, low bits first, lines on 32-bit +/// boundaries) and 8-bit Y'CbCr (4:2:2, 4:4:4, 4:4:4:4 with alpha; BT.709, BT.601 and unspecified +/// colorimetry), layouts ffmpeg reads but does not write, built here from ffmpeg's test picture +/// (#841). +#[test] +fn dpx_packed_and_ycbcr_match_ffmpeg() { + let ff = filmcraft_testkit::require_ffmpeg!(); + let (w, h) = (95u32, 63u32); + let rgb: Vec = raw_picture(&ff, w, h, "rgb48le").as_chunks::<2>().0.iter().map(|c| u32::from(u16::from_le_bytes(*c)) >> 4).collect(); + let mut data = Vec::new(); + for line in rgb.chunks(w as usize * 3) { + let (mut acc, mut n) = (0u64, 0u32); + for &v in line { + acc |= u64::from(v) << n; + n += 12; + while n >= 32 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + data.extend_from_slice(&(acc as u32).to_be_bytes()); + } + } + dpx_matches(&ff, "dpx_packed12.dpx", dpx_file(w, h, 50, 12, 0, 6, &data), 1); + + // Y'CbCr: even width, scan lines a multiple of 4 bytes + let (w, h) = (96u32, 64u32); + let px = (w * h) as usize; + let uyvy = raw_picture(&ff, w, h, "uyvy422"); + let yuv = raw_picture(&ff, w, h, "yuv444p"); + let yuva = raw_picture(&ff, w, h, "yuva444p"); + let interleave = |planes: &[u8], order: &[usize]| -> Vec { (0..px).flat_map(|i| order.iter().map(move |&p| planes[p * px + i])).collect() }; + // planes are Y, U (Cb), V (Cr), A; DPX stores Cb Y Cr A + let cbycr = interleave(&yuv, &[1, 0, 2]); + let cbycra = interleave(&yuva, &[1, 0, 2, 3]); + for colorimetric in [6u8, 7, 0] { + dpx_matches(&ff, &format!("dpx_cbycry_{colorimetric}.dpx"), dpx_file(w, h, 100, 8, 0, colorimetric, &uyvy), 1); + dpx_matches(&ff, &format!("dpx_cbycr_{colorimetric}.dpx"), dpx_file(w, h, 102, 8, 0, colorimetric, &cbycr), 1); + } + dpx_matches(&ff, "dpx_cbycra.dpx", dpx_file(w, h, 103, 8, 0, 6, &cbycra), 1); +} + #[test] fn missing_frames_hold_the_previous_frame() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs index 2d5832fa..474d1236 100644 --- a/crates/media/src/dpx.rs +++ b/crates/media/src/dpx.rs @@ -1,16 +1,23 @@ //! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange). //! -//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per -//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into -//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the -//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit -//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line -//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing -//! density) transfer is shown as stored, without a film-log conversion. +//! The first image element is decoded: luma, RGB, RGBA, ABGR, or Y'CbCr (CbYCrY 4:2:2, CbYACrYA +//! 4:2:2:4, CbYCr 4:4:4, CbYCrA 4:4:4:4), 8, 10, 12 or 16 bits per component, big-endian (`SDPX`) or +//! little-endian (`XPDS`) files. 10-bit components are filled into 32-bit words (packing 1, method +//! A: two padding bits at the bottom; packing 2, method B: at the top), 12-bit components fill +//! 16-bit words the same two ways; packed 10/12-bit components (packing 0) follow each other with no +//! padding, the first in the low bits of a 32-bit word, a component crossing into the next word +//! continuing in its low bits. Scan lines start on a 32-bit boundary and may carry end-of-line +//! padding. Orientations 0–3 (either scan direction, either line order) are honoured. RGB and luma +//! code values map linearly onto 8 bits (full range); a logarithmic (printing density) transfer is +//! shown as stored, without a film-log conversion. Y'CbCr is converted with the matrix the +//! colorimetric field names (BT.709, or BT.601 for the 601 and composite video codes; by picture +//! size when unspecified), in video range unless the reference low / high data codes span the +//! whole code range; 4:2:2 chroma is shared by the two pixels of a pair. //! -//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed" -//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed -//! orientations. +//! Not decoded (an "unsupported" error): run-length encoded elements, 1-bit and floating-point +//! components, transposed orientations. + +use filmcraft_color::{Matrix, Range, normalize_c, normalize_y, ycbcr_to_rgb}; use crate::{MediaError, Result, still::Raster}; @@ -63,6 +70,8 @@ enum Packing { Words { shift: u32 }, /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. Ten { pad: u32 }, + /// `bits`-bit components back to back in 32-bit words, low bits first (packing 0). + Packed { bits: u32 }, } impl Packing { @@ -73,6 +82,7 @@ impl Packing { Packing::Bytes => comps, Packing::Words { .. } => comps.checked_mul(2)?, Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + Packing::Packed { bits } => comps.checked_mul(bits as usize)?.div_ceil(32).checked_mul(4)?, }; raw.checked_next_multiple_of(4) } @@ -93,6 +103,69 @@ impl Packing { } } } + Packing::Packed { bits } => { + let mask = (1u64 << bits) - 1; + for i in 0..comps { + let bit = i * bits as usize; + let at = bit / 32 * 4; + let pair = u64::from(order.u32(line, at)) | (u64::from(order.u32(line, at + 4)) << 32); + out.push(((pair >> (bit % 32)) & mask) as u16); + } + } + } + } +} + +/// What the stored components of a pixel are. +#[derive(Clone, Copy)] +enum Components { + /// `n` components per pixel; the indices of R, G, B and A among them (alpha `None`: opaque). + Rgb { n: usize, map: [Option; 4] }, + /// `n` components per pixel: Cb (4:2:2: Cb on even pixels, Cr on odd ones), Y', then Cr (4:4:4 + /// only), then alpha when `alpha`. + YCbCr { n: usize, sub: bool, alpha: bool, matrix: Matrix, range: Range }, +} + +impl Components { + fn per_pixel(self) -> usize { + match self { + Components::Rgb { n, .. } | Components::YCbCr { n, .. } => n, + } + } + + fn has_alpha(self) -> bool { + match self { + Components::Rgb { map, .. } => map[3].is_some(), + Components::YCbCr { alpha, .. } => alpha, + } + } + + /// RGBA8 of pixel `x` from the unpacked component values of its scan line (`bits` per value). + fn pixel(self, line: &[u16], x: usize, bits: u32) -> [u8; 4] { + let max = (1u32 << bits) - 1; + let to8 = |v: u32| ((v.min(max) * 255 + max / 2) / max) as u8; + let get = |i: usize| line.get(i).map(|&v| u32::from(v)); + match self { + Components::Rgb { n, map } => map.map(|slot| match slot { + Some(i) => get(x * n + i).map_or(0, to8), + None => 255, + }), + Components::YCbCr { n, sub, alpha, matrix, range } => { + let base = x * n; + let (cb, cr) = if sub { + let pair = (x & !1) * n; + (get(pair), get(pair + n)) + } else { + (get(base), get(base + 2)) + }; + // a missing Cr (odd width, 4:2:2) is neutral + let mid = 1u32 << (bits - 1); + let y = normalize_y(get(base + 1).unwrap_or(0), bits, range); + let rgb = ycbcr_to_rgb(y, normalize_c(cb.unwrap_or(mid), bits, range), normalize_c(cr.unwrap_or(mid), bits, range), matrix); + let c = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8; + let a = if alpha { get(base + n - 1).map_or(0, to8) } else { 255 }; + [c(rgb[0]), c(rgb[1]), c(rgb[2]), a] + } } } } @@ -110,7 +183,10 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { let orientation = order.u16(bytes, 768); let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize); let el = ELEMENT; + let low_code = order.u32(bytes, el + 4); + let high_code = order.u32(bytes, el + 12); let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let colorimetric = bytes.get(el + 22).copied().unwrap_or(0); let bits = bytes.get(el + 23).copied().unwrap_or(0); let packing = order.u16(bytes, el + 24); let encoding = order.u16(bytes, el + 26); @@ -120,14 +196,6 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { return Err(err(name, "empty picture")); } let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; - // component order per pixel: indices of R, G, B, A (None: opaque) in the stored components - let (n, map): (usize, [Option; 4]) = match descriptor { - 6 => (1, [Some(0), Some(0), Some(0), None]), - 50 => (3, [Some(0), Some(1), Some(2), None]), - 51 => (4, [Some(0), Some(1), Some(2), Some(3)]), - 52 => (4, [Some(3), Some(2), Some(1), Some(0)]), - d => return Err(unsupported(name, format!("descriptor {d}"))), - }; if encoding != 0 { return Err(unsupported(name, "run-length encoding".into())); } @@ -138,6 +206,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { (10, 2) => Packing::Ten { pad: 0 }, (12, 1) => Packing::Words { shift: 4 }, (12, 2) => Packing::Words { shift: 0 }, + (10 | 12, 0) => Packing::Packed { bits: u32::from(bits) }, (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), }; let (flip_x, flip_y) = match orientation { @@ -147,6 +216,27 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { 3 => (true, true), o => return Err(unsupported(name, format!("orientation {o}"))), }; + let max = (1u32 << bits) - 1; + // video range unless the reference codes span every code value + let range = if low_code == 0 && high_code == max { Range::Full } else { Range::Limited }; + let matrix = match colorimetric { + 5 | 6 => Matrix::Bt709, + 7..=10 => Matrix::Bt601, + _ => filmcraft_frame::default_matrix(w as u32, h as u32), + }; + let ycbcr = |n, sub, alpha| Components::YCbCr { n, sub, alpha, matrix, range }; + let components = match descriptor { + 6 => Components::Rgb { n: 1, map: [Some(0), Some(0), Some(0), None] }, + 50 => Components::Rgb { n: 3, map: [Some(0), Some(1), Some(2), None] }, + 51 => Components::Rgb { n: 4, map: [Some(0), Some(1), Some(2), Some(3)] }, + 52 => Components::Rgb { n: 4, map: [Some(3), Some(2), Some(1), Some(0)] }, + 100 => ycbcr(2, true, false), + 101 => ycbcr(3, true, true), + 102 => ycbcr(3, false, false), + 103 => ycbcr(4, false, true), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + let n = components.per_pixel(); let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?; let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize }; let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?; @@ -157,8 +247,7 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { // the last line needs no end-of-line padding let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?; let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?; - let max = (1u32 << bits) - 1; - let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8; + let bits = u32::from(bits); let mut rgba = vec![0u8; pixels * 4]; let mut line = Vec::with_capacity(comps); for y in 0..h { @@ -168,19 +257,12 @@ pub fn decode(name: &str, bytes: &[u8]) -> Result { for x in 0..w { let dx = if flip_x { w - 1 - x } else { x }; let o = (dy * w + dx) * 4; - let Some(px) = rgba.get_mut(o..o + 4) else { continue }; - for (c, slot) in map.iter().enumerate() { - let v = match slot { - Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)), - None => 255, - }; - if let Some(d) = px.get_mut(c) { - *d = v; - } + if let Some(px) = rgba.get_mut(o..o + 4) { + px.copy_from_slice(&components.pixel(&line, x, bits)); } } } - let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + let has_alpha = components.has_alpha() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) } @@ -250,14 +332,84 @@ mod tests { assert_eq!(r.rgba, [255, 0, 0, 128]); } + /// Packing 0: components back to back, low bits first, each line on a 32-bit boundary. + fn packed(lines: &[&[u32]], bits: u32, big: bool) -> Vec { + let mut out = Vec::new(); + for line in lines { + let (mut acc, mut n) = (0u64, 0u32); + for &v in *line { + acc |= u64::from(v) << n; + n += bits; + while n >= 32 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + acc >>= 32; + n -= 32; + } + } + if n > 0 { + let w = acc as u32; + out.extend_from_slice(&if big { w.to_be_bytes() } else { w.to_le_bytes() }); + } + } + out + } + + #[test] + fn packed_ten_and_twelve_bit_components_cross_words() { + // 2 × 2 RGB 10-bit: 6 components (60 bits) per line, the fourth straddles two words + let lines: [&[u32]; 2] = [&[1023, 0, 512, 4, 1019, 1023], &[0, 1023, 0, 1023, 1023, 1023]]; + for big in [true, false] { + let r = decode("a.dpx", &file(big, 2, 2, 50, 10, 0, 0, &packed(&lines, 10, big))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 255, 255, 0, 255, 0, 255, 255, 255, 255, 255]); + } + // 3 × 1 RGB 12-bit: 9 components, 108 bits in 4 words + let line: [&[u32]; 1] = [&[4095, 0, 2048, 16, 4079, 0, 0, 0, 4095]]; + let r = decode("a.dpx", &file(true, 3, 1, 50, 12, 0, 0, &packed(&line, 12, true))).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255, 0, 0, 255, 255]); + } + + #[test] + fn ycbcr_descriptors_convert_to_rgb() { + // 8-bit CbYCrY, video range, BT.709 (colorimetric 6): black, white, then a red pixel pair + // (the 709 code triple of red is rounded, hence green 1) + let mut f = file(true, 4, 1, 100, 8, 0, 0, &[128, 16, 128, 235, 102, 63, 240, 63]); + f[ELEMENT + 22] = 6; + let r = decode("a.dpx", &f).unwrap(); + assert_eq!(r.rgba, [0, 0, 0, 255, 255, 255, 255, 255, 255, 1, 0, 255, 255, 1, 0, 255]); + assert!(!r.has_alpha); + // red in BT.601 (colorimetric 7): the 601 code triple, rounded + let mut f = file(true, 2, 1, 100, 8, 0, 0, &[90, 81, 240, 81]); + f[ELEMENT + 22] = 7; + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [254, 0, 0, 255, 254, 0, 0, 255]); + // 10-bit CbYCr filled (method A), full range from the reference codes: mid grey + let word = |a: u32, b: u32, c: u32| ((a << 22) | (b << 12) | (c << 2)).to_be_bytes(); + let mut f = file(true, 1, 1, 102, 10, 1, 0, &word(512, 512, 512)); + f[ELEMENT + 4..ELEMENT + 8].copy_from_slice(&0u32.to_be_bytes()); + f[ELEMENT + 12..ELEMENT + 16].copy_from_slice(&1023u32.to_be_bytes()); + assert_eq!(decode("a.dpx", &f).unwrap().rgba, [128, 128, 128, 255]); + // 16-bit CbYCrA: white at half opacity + let v = |x: u16| x.to_be_bytes(); + let data: Vec = [v(32768), v(60160), v(32768), v(32768)].concat(); + let r = decode("a.dpx", &file(true, 1, 1, 103, 16, 0, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 128]); + assert!(r.has_alpha); + // 8-bit CbYACrYA, 3 pixels: the last pixel has no Cr and is neutral + let r = decode("a.dpx", &file(true, 3, 1, 101, 8, 0, 0, &[128, 235, 255, 128, 16, 0, 128, 235, 255, 0, 0, 0])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 0, 0, 0, 0, 255, 255, 255, 255]); + assert!(r.has_alpha); + } + #[test] fn hostile_headers_fail_cleanly() { // huge dimensions with no data, truncated data, unsupported layouts assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err()); assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err()); assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err()); - assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err()); - assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 150, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 3, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 16, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 3, 3, 100, 12, 0, 0, &[0; 20])).is_err()); assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err()); assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err()); assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 2ac9b655..74e17680 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -56,7 +56,7 @@ only). "Tested" names the evidence. | WebP | — | yes | no | ahead | | **PSD** (layers as sequence) | read (layered) | no | — | 0% | | **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% | -| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% | +| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); packed 10/12-bit, Y'CbCr 4:2:2 / 4:4:4 with or without alpha (#841); no RLE | sequences (10-bit RGB, BT.709; #779) | 90% | | Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% | | HEIF / HEIC | read | no | — | 0% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | diff --git a/docs/gaps.md b/docs/gaps.md index f798de68..aa6c4f1c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -166,9 +166,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer - DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import - since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in - `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). + DPX layout, run-length encoding (Targa and DPX stills and sequences import since #821, packed + 10/12-bit and Y'CbCr DPX since #841, deeper than 8 bits kept at 8); transparent video (#402); + Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md). From fc855e2b0e12ae9f343539f84db976ae4f4d1b2f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:26:42 +0200 Subject: [PATCH 070/203] #835: workflow acceptance tests through the MCP server, checked by ffprobe Problem: no test did a real editing job end to end. Codec and container oracles exist, but nothing imported phone- or camera-like media, edited it through the command layer and checked the exported file (G6). Fix: apps/filmcraft-cli/tests/workflows.rs drives the `filmcraft-cli mcp` binary over stdio on media generated with ffmpeg into target/fixtures: - a VFR H.264 + AAC MP4: sequence from clip, extract, -6 dB audio gain, title, H.264 MP4 export; - a long-GOP H.264 MKV with AAC + AC-3 tracks: ripple trims, colour matte, centred cross dissolve, save; a new CLI process exports ProRes. ffprobe / ffmpeg check streams, frame count, duration, which source frame lands where, A/V sync across the cut, title pixels, the dissolve mix and audio levels. The tests skip without ffmpeg (testkit oracles). Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + apps/filmcraft-cli/Cargo.toml | 3 + apps/filmcraft-cli/tests/workflows.rs | 488 ++++++++++++++++++++++++++ docs/gaps.md | 6 + 4 files changed, 498 insertions(+) create mode 100644 apps/filmcraft-cli/tests/workflows.rs diff --git a/Cargo.lock b/Cargo.lock index 5529201a..e2da215a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1797,6 +1797,7 @@ dependencies = [ "filmcraft-platform", "filmcraft-project", "filmcraft-render", + "filmcraft-testkit", "filmcraft-time", "serde_json", "tokio", diff --git a/apps/filmcraft-cli/Cargo.toml b/apps/filmcraft-cli/Cargo.toml index ae3a6f31..218b7f7d 100644 --- a/apps/filmcraft-cli/Cargo.toml +++ b/apps/filmcraft-cli/Cargo.toml @@ -33,3 +33,6 @@ filmcraft-ogg = { workspace = true } filmcraft-automation = { workspace = true } serde_json = { workspace = true } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } + +[dev-dependencies] +filmcraft-testkit = { workspace = true } diff --git a/apps/filmcraft-cli/tests/workflows.rs b/apps/filmcraft-cli/tests/workflows.rs new file mode 100644 index 00000000..b0f7a57a --- /dev/null +++ b/apps/filmcraft-cli/tests/workflows.rs @@ -0,0 +1,488 @@ +//! Workflow acceptance tests (docs/gaps.md G6): real editing jobs done end to end the way an agent +//! does them, through the headless MCP server of the `filmcraft-cli` binary (`filmcraft-cli mcp`, +//! JSON-RPC over stdio) and the CLI itself, on media that looks like what people shoot: +//! +//! - a phone clip: H.264 + AAC in MP4 with a variable frame rate (dropped frames); +//! - a camera clip: long-GOP H.264 (one keyframe) in Matroska with two audio tracks (AAC, AC-3). +//! +//! Each test imports, edits (extract, audio gain, title; ripple trims, colour matte, cross +//! dissolve), exports (H.264 MP4; save the project, reopen it in a new process and export ProRes) +//! and checks the file with the ffprobe / ffmpeg oracles: streams, frame count, duration, which +//! source frame lands where, A/V sync, the title's pixels, the dissolve's mix, audio levels. +//! +//! The media is generated with ffmpeg into `target/fixtures/cli/workflows` at test time (never +//! committed); without ffmpeg the tests skip (`FILMCRAFT_REQUIRE_ORACLES=1` makes that a failure). +//! ffmpeg and ffprobe are external test oracles only. + +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; + +use serde_json::{Value, json}; + +// --------------------------------------------------------------------------------------------- +// Fixtures + +/// A phone-like clip: 4 s of 320×240 `testsrc2` at 30 fps with every frame `n % 7 == 3` dropped +/// (variable frame rate, the timestamps of the kept frames unchanged), H.264 + AAC stereo. The +/// whole picture flashes white on the frames at 1.0 s and 3.0 s, and a 1 kHz beep sounds from 1.0 +/// to 1.1 s and from 3.0 to 3.1 s (silence otherwise): A/V sync markers. +fn phone_vfr_mp4(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("phone_vfr_h264_aac.mp4"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=30:duration=4"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:duration=4"]) + .args([ + "-filter_complex", + "[0:v]drawbox=x=0:y=0:w=iw:h=ih:color=white:t=fill:enable='between(t,1,1.02)+between(t,3,3.02)',\ + select='not(eq(mod(n,7),3))'[v];\ + [1:a]volume='if(between(t,1,1.1)+between(t,3,3.1),1,0)':eval=frame[a]", + ]) + .args(["-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p", "-fps_mode", "passthrough"]) + .args(["-video_track_timescale", "30000", "-c:a", "aac", "-b:a", "128k", "-ac", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A camera-like clip: 6 s of 320×240 `testsrc2` at 25 fps as long-GOP H.264 (a single keyframe, +/// B-frames) in Matroska, with two stereo audio tracks: 440 Hz in AAC and 1320 Hz in AC-3. +fn camera_long_gop_mkv(ffmpeg: &Path) -> Option { + let out = filmcraft_testkit::fixtures_dir("cli/workflows").join("camera_long_gop_2audio.mkv"); + filmcraft_testkit::fixtures::generate(&out, |tmp| { + Command::new(ffmpeg) + .args(["-v", "error", "-y", "-f", "lavfi", "-i", "testsrc2=size=320x240:rate=25:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=440:sample_rate=48000:duration=6"]) + .args(["-f", "lavfi", "-i", "sine=frequency=1320:sample_rate=48000:duration=6"]) + .args(["-map", "0", "-map", "1", "-map", "2", "-c:v", "libx264", "-preset", "veryfast", "-pix_fmt", "yuv420p"]) + .args(["-g", "300", "-keyint_min", "300", "-sc_threshold", "0", "-bf", "3"]) + .args(["-c:a:0", "aac", "-ac:a:0", "2", "-c:a:1", "ac3", "-ac:a:1", "2"]) + .arg(tmp) + .status() + .is_ok_and(|s| s.success()) + }) +} + +/// A scratch directory under the workspace `target/`, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Self { + let d = filmcraft_testkit::workspace_root().join("target").join("workflow-tests").join(format!("{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + Scratch(d) + } + fn path(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +// --------------------------------------------------------------------------------------------- +// A minimal MCP client for `filmcraft-cli mcp` (newline-delimited JSON-RPC over stdio) + +struct Mcp { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_id: u64, +} + +impl Mcp { + /// Start the headless server (an empty project) and initialise the session. `data_dir` keeps + /// user presets away from the real data directory. + fn start(data_dir: &str) -> Self { + let mut child = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")) + .args(["--data-dir", data_dir, "mcp"]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn filmcraft-cli mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let mut m = Mcp { child, stdin, stdout, next_id: 1 }; + let init = + m.request("initialize", json!({"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "workflow-tests", "version": "1"}})); + assert_eq!(init["result"]["serverInfo"]["name"], "filmcraft", "{init}"); + m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"})); + m + } + + fn send(&mut self, msg: &Value) { + writeln!(self.stdin, "{msg}").unwrap(); + self.stdin.flush().unwrap(); + } + + /// Send a request and return its response (notifications in between are skipped). + fn request(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params})); + loop { + let mut line = String::new(); + assert!(self.stdout.read_line(&mut line).unwrap() > 0, "the MCP server closed stdout while `{method}` was pending"); + let v: Value = serde_json::from_str(&line).unwrap_or_else(|e| panic!("not JSON-RPC: {e}: {line}")); + if v["id"] == id { + return v; + } + } + } + + /// Call a tool; its text result parsed as JSON. Panics on a protocol or tool error. + fn tool(&mut self, name: &str, arguments: Value) -> Value { + let r = self.request("tools/call", json!({"name": name, "arguments": arguments})); + let text = r["result"]["content"][0]["text"].as_str().unwrap_or_default().to_string(); + assert!(r["error"].is_null() && r["result"]["isError"] == false, "{name} {arguments}: {r}"); + serde_json::from_str(&text).unwrap_or(Value::String(text)) + } + + /// `command_run`: one engine command by id. + fn run(&mut self, id: &str, params: Value) -> Value { + self.tool("command_run", json!({"id": id, "params": params})) + } +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +// --------------------------------------------------------------------------------------------- +// Oracles + +fn probe(ffprobe: &Path, path: &str) -> Value { + let out = Command::new(ffprobe).args(["-v", "error", "-of", "json", "-count_frames", "-show_format", "-show_streams", path]).output().unwrap(); + assert!(out.status.success(), "ffprobe {path}: {}", String::from_utf8_lossy(&out.stderr)); + serde_json::from_slice(&out.stdout).unwrap() +} + +fn streams<'a>(j: &'a Value, kind: &str) -> Vec<&'a Value> { + j["streams"].as_array().unwrap().iter().filter(|s| s["codec_type"] == kind).collect() +} + +fn num(v: &Value) -> f64 { + v.as_str().and_then(|s| s.parse().ok()).or_else(|| v.as_f64()).unwrap_or(f64::NAN) +} + +/// Run ffmpeg decoding `input` (with `pre` input options) to raw data on stdout. +fn ffmpeg_raw(ffmpeg: &Path, pre: &[&str], input: &str, args: &[&str]) -> Vec { + let out = Command::new(ffmpeg).args(["-v", "error"]).args(pre).args(["-i", input]).args(args).arg("-").output().unwrap(); + assert!(out.status.success(), "ffmpeg {input}: {}", String::from_utf8_lossy(&out.stderr)); + out.stdout +} + +/// Size of the frames [`frames`] returns. +const FW: usize = 80; +const FH: usize = 60; + +/// Every video frame of `input`, scaled to 80×60 in `pix_fmt` (`gray` or `rgb24`). `vf` goes +/// first in the filter chain (e.g. `fps=30` to see a VFR file as an editor shows it). +fn frames(ffmpeg: &Path, input: &str, vf: Option<&str>, pix_fmt: &str) -> Vec> { + let chain = match vf { + Some(f) => format!("{f},scale={FW}:{FH}:flags=area,format={pix_fmt}"), + None => format!("scale={FW}:{FH}:flags=area,format={pix_fmt}"), + }; + let bpp = if pix_fmt == "gray" { 1 } else { 3 }; + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", "0:v:0", "-vf", &chain, "-fps_mode", "passthrough", "-f", "rawvideo"]); + raw.chunks_exact(FW * FH * bpp).map(<[u8]>::to_vec).collect() +} + +/// Mean absolute difference of two equally sized frames. +fn diff(a: &[u8], b: &[u8]) -> f64 { + a.iter().zip(b).map(|(x, y)| f64::from(x.abs_diff(*y))).sum::() / a.len().max(1) as f64 +} + +/// Assert that `got` shows source frame `want`: it is (one of) the closest source frames, and close. +fn assert_shows(got: &[u8], src: &[Vec], want: usize, what: &str) { + let d: Vec = src.iter().map(|s| diff(got, s)).collect(); + let best = d.iter().copied().fold(f64::INFINITY, f64::min); + let at = d.iter().position(|x| *x == best).unwrap_or(0); + assert!(d[want] < 6.0 && d[want] <= best + 0.25, "{what}: expected source frame {want} (diff {:.2}), closest is {at} (diff {best:.2})", d[want]); +} + +/// The first channel of audio stream `stream` of `input`, mono-mixed, as f32 at 48 kHz. +fn pcm(ffmpeg: &Path, input: &str, stream: usize) -> Vec { + let map = format!("0:a:{stream}"); + let raw = ffmpeg_raw(ffmpeg, &[], input, &["-map", &map, "-ac", "1", "-ar", "48000", "-f", "f32le"]); + raw.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() +} + +/// Times (s) where the signal rises above a quarter of its peak, at least 0.2 s apart. +fn onsets(x: &[f32]) -> Vec { + let peak = x.iter().fold(0f32, |m, v| m.max(v.abs())); + let mut out = Vec::new(); + let mut last = f64::NEG_INFINITY; + for (i, block) in x.chunks(48).enumerate() { + let t = i as f64 * 48.0 / 48_000.0; + if block.iter().any(|v| v.abs() > peak / 4.0) && t - last > 0.2 { + out.push(t); + } + if block.iter().any(|v| v.abs() > peak / 4.0) { + last = t; + } + } + out +} + +fn rms(x: &[f32], from: f64, to: f64) -> f64 { + let s = x.get((from * 48_000.0) as usize..((to * 48_000.0) as usize).min(x.len())).unwrap_or_default(); + (s.iter().map(|v| f64::from(*v) * f64::from(*v)).sum::() / s.len().max(1) as f64).sqrt() +} + +/// Amplitude of the `freq` component of 100 ms of `x` from `at` seconds (Goertzel). +fn tone(x: &[f32], at: f64, freq: f64) -> f64 { + let start = (at * 48_000.0) as usize; + let s = x.get(start..(start + 4800).min(x.len())).unwrap_or_default(); + let c = 2.0 * (2.0 * std::f64::consts::PI * freq / 48_000.0).cos(); + let (mut s1, mut s2) = (0.0, 0.0); + for v in s { + let s0 = f64::from(*v) + c * s1 - s2; + s2 = s1; + s1 = s0; + } + (s1 * s1 + s2 * s2 - c * s1 * s2).max(0.0).sqrt() * 2.0 / s.len().max(1) as f64 +} + +fn db(ratio: f64) -> f64 { + 20.0 * ratio.log10() +} + +fn clips_on<'a>(seq: &'a Value, kind: &str, track: usize) -> &'a Vec { + seq[kind][track]["items"].as_array().unwrap() +} + +// --------------------------------------------------------------------------------------------- +// Workflows + +/// Phone footage: import a VFR H.264/AAC clip, make a sequence from it, extract a second from the +/// middle (Mark In / Mark Out / Extract), lower the audio 6 dB, put a title over the start, and +/// export H.264 MP4 through MCP. The VFR clip plays at its 30 fps base rate (a dropped frame +/// holds the one before), the cut lands on the right frames, picture and sound stay in sync +/// across the cut, the title is in the picture only while it is on the timeline. +#[test] +fn phone_vfr_clip_extract_gain_title_to_h264_mp4() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = phone_vfr_mp4(&ffmpeg) else { + return filmcraft_testkit::skip("phone workflow", "ffmpeg could not make the VFR H.264/AAC fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("phone"); + let mut m = Mcp::start(&dir.path("data")); + + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + assert!(made["sequence"].is_u64(), "{made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!((seq["settings"]["width"].as_u64(), seq["settings"]["height"].as_u64()), (Some(320), Some(240)), "{}", seq["settings"]); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 30, "den": 1}), "a VFR phone clip makes a sequence at its base rate"); + assert_eq!(seq["durationFrames"], 120, "4 s at 30 fps"); + + // Extract 1.5 s .. 2.5 s; Mark Out includes its frame, so 31 frames go and 89 remain. + m.run("markers.markIn", json!({"seconds": 1.5})); + m.run("markers.markOut", json!({"seconds": 2.5})); + m.run("sequence.extract", json!({})); + m.run("edit.selectAll", json!({})); + let gain = m.run("clip.audioGain", json!({"mode": "set", "db": -6.0})); + assert_eq!(gain["clips"], 2, "both audio pieces: {gain}"); + m.run("edit.deselectAll", json!({})); + m.run("graphics.newText", json!({"text": "HELLO", "size": 40, "time": 0, "seconds": 0.5})); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["durationFrames"], 89, "{seq}"); + assert_eq!(clips_on(&seq, "video", 0).len(), 2); + assert_eq!(clips_on(&seq, "audio", 0).len(), 2); + + let out = dir.path("phone_edit.mp4"); + let r = m.run("file.exportMedia", json!({"path": out, "format": "h264", "range": "entire", "wait": true})); + assert_eq!(r["result"]["frames"], 89, "{r}"); + + // container and streams + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("h264"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "30/1"); + assert_eq!(v[0]["nb_read_frames"], "89"); + assert_eq!((a[0]["codec_name"].as_str(), a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("aac"), Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - 89.0 / 30.0).abs() < 0.06, "duration {duration}"); + assert!((num(&a[0]["duration"]) - 89.0 / 30.0).abs() < 0.06, "audio as long as the picture: {}", a[0]["duration"]); + + // picture: frame k of the export is frame k (before the cut) or k + 31 (after) of the source + // as an editor plays it (CFR 30, a dropped frame holding the previous one) + let src_frames = frames(&ffmpeg, &src, Some("fps=30"), "gray"); + let got = frames(&ffmpeg, &out, None, "gray"); + assert!(src_frames.len() >= 120, "{} source frames", src_frames.len()); + assert_eq!(got.len(), 89); + for k in [20, 32, 40, 44, 45, 50, 70, 88] { + let want = if k < 45 { k } else { k + 31 }; + assert_shows(&got[k], &src_frames, want, &format!("export frame {k}")); + } + // the flash frames: 30 (with 31 holding it, as the source dropped its frame 31) and 90 - 31 + let mean = |f: &Vec| f.iter().map(|v| f64::from(*v)).sum::() / f.len() as f64; + let flashes: Vec = got.iter().enumerate().filter(|(_, f)| mean(f) > 200.0).map(|(i, _)| i).collect(); + assert_eq!(flashes, [30, 31, 59], "white frames"); + + // the title: over frames 0..15 only (its pixels differ from the source there, nowhere after) + let blocks_changed = |k: usize| { + let mut n = 0; + for by in (0..FH).step_by(10) { + for bx in (0..FW).step_by(10) { + let mut d = 0.0; + for y in by..by + 10 { + for x in bx..bx + 10 { + d += f64::from(got[k][y * FW + x].abs_diff(src_frames[k][y * FW + x])); + } + } + n += usize::from(d / 100.0 > 15.0); + } + } + n + }; + for k in [2, 8, 14] { + assert!(blocks_changed(k) >= 2, "title missing on frame {k}"); + } + for k in [16, 25] { + assert_eq!(blocks_changed(k), 0, "title still on frame {k}"); + } + + // sound: beeps where the flashes are (A/V sync across the extract), 6 dB down + let src_pcm = pcm(&ffmpeg, &src, 0); + let got_pcm = pcm(&ffmpeg, &out, 0); + let beeps = onsets(&got_pcm); + assert_eq!(beeps.len(), 2, "beeps at {beeps:?}"); + for (beep, flash) in beeps.iter().zip([30.0 / 30.0, 59.0 / 30.0]) { + assert!((beep - flash).abs() <= 1.0 / 30.0, "beep at {beep:.3} s, flash at {flash:.3} s"); + } + let change = db(rms(&got_pcm, 1.01, 1.09) / rms(&src_pcm, 1.01, 1.09)); + assert!((change + 6.0).abs() < 1.0, "audio gain -6 dB measured {change:.2} dB"); + let change = db(rms(&got_pcm, 1.98, 2.06) / rms(&src_pcm, 3.01, 3.09)); + assert!((change + 6.0).abs() < 1.0, "second beep {change:.2} dB"); +} + +/// Camera footage: import a long-GOP H.264 Matroska file with two audio tracks through MCP, make +/// a sequence (both tracks land on A1 and A2), ripple-trim 2.6 s off the head (decoding starts at +/// the only keyframe) and 15 frames off the tail, add a red colour matte with a centred cross +/// dissolve, save the project; then a new `filmcraft-cli` process opens it and exports ProRes. +/// The first exported frame is source frame 65, the dissolve mixes the two pictures, both audio +/// tracks are in the mix at their own level and stop where the clip does. +#[test] +fn camera_long_gop_two_audio_tracks_trim_dissolve_to_prores() { + let (ffmpeg, ffprobe) = filmcraft_testkit::require_oracles!(); + let Some(src) = camera_long_gop_mkv(&ffmpeg) else { + return filmcraft_testkit::skip("camera workflow", "ffmpeg could not make the long-GOP Matroska fixture (libx264?)"); + }; + let src = src.to_string_lossy().into_owned(); + let dir = Scratch::new("camera"); + let data = dir.path("data"); + let project = dir.path("camera.fcproj"); + { + let mut m = Mcp::start(&data); + let imported = m.tool("media_import", json!({"paths": [src]})); + assert_eq!(imported["errors"], json!([]), "{imported}"); + let item = imported["items"][0].as_u64().unwrap(); + let made = m.run("file.newSequenceFromClip", json!({"items": [item]})); + let clips: Vec = made["clips"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + assert_eq!(clips.len(), 3, "a video clip and one clip per audio track: {made}"); + let seq = m.tool("sequence_inspect", json!({})); + assert_eq!(seq["settings"]["frame_rate"], json!({"num": 25, "den": 1})); + assert_eq!(seq["audio"].as_array().map(Vec::len), Some(2), "two audio tracks"); + let video = clips_on(&seq, "video", 0)[0]["clip"].as_u64().unwrap(); + + // 65 frames off the head and 15 off the tail, the linked audio following + m.run("timeline.trim", json!({"clip": video, "edge": "in", "mode": "ripple", "deltaFrames": 65})); + m.run("timeline.trim", json!({"clip": video, "edge": "out", "mode": "ripple", "deltaFrames": -15})); + let seq = m.tool("sequence_inspect", json!({})); + let end = clips_on(&seq, "video", 0)[0]["endFrame"].as_u64().unwrap(); + for t in 0..2 { + let a = &clips_on(&seq, "audio", t)[0]; + assert_eq!((a["startFrame"].as_u64(), a["endFrame"].as_u64(), a["sourceIn"].as_i64()), (Some(0), Some(end), Some(660_441_600_000)), "A{}", t + 1); + } + + let matte = m.run("file.newColorMatte", json!({"color": "#ff0000", "seconds": 2.0}))["item"].as_u64().unwrap(); + let placed = m.run("timeline.place", json!({"item": matte, "track": "V1", "frame": end, "insert": false})); + let matte_clip = placed["clips"][0].as_u64().unwrap(); + m.run("sequence.applyVideoTransition", json!({"clip": matte_clip, "edge": "in", "frames": 10, "effect": "cross_dissolve"})); + let seq = m.tool("sequence_inspect", json!({})); + let tr = &seq["video"][0]["transitions"][0]; + assert_eq!((tr["effect"].as_str(), tr["align"].as_str()), (Some("cross_dissolve"), Some("center")), "{tr}"); + m.run("file.saveAs", json!({"path": project})); + } + assert_eq!(end_frame_of(&project), Some(71), "the saved project holds the edit"); + + // a fresh process opens the saved project and renders it + let out = dir.path("camera_edit.mov"); + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--data-dir", &data, "--project", &project, "export", &out]).output().unwrap(); + assert!(o.status.success(), "export: {}", String::from_utf8_lossy(&o.stderr)); + let r: Value = serde_json::from_slice(&o.stdout).unwrap(); + let total = 71 + 50; + assert_eq!(r["result"]["frames"], total, "{r}"); + + let j = probe(&ffprobe, &out); + let (v, a) = (streams(&j, "video"), streams(&j, "audio")); + assert_eq!((v.len(), a.len()), (1, 1), "{j}"); + assert_eq!((v[0]["codec_name"].as_str(), v[0]["width"].as_u64(), v[0]["height"].as_u64()), (Some("prores"), Some(320), Some(240))); + assert_eq!(v[0]["r_frame_rate"], "25/1"); + assert_eq!(v[0]["nb_read_frames"], total.to_string()); + assert_eq!((a[0]["sample_rate"].as_str(), a[0]["channels"].as_u64()), (Some("48000"), Some(2))); + let duration = num(&j["format"]["duration"]); + assert!((duration - f64::from(total) / 25.0).abs() < 0.05, "duration {duration}"); + + // picture: frame k is source frame 65 + k up to the dissolve (frames 66..76), red after it + let src_frames = frames(&ffmpeg, &src, None, "rgb24"); + let got = frames(&ffmpeg, &out, None, "rgb24"); + assert_eq!((src_frames.len(), got.len()), (150, total as usize)); + for k in [0, 1, 2, 30, 64] { + assert_shows(&got[k], &src_frames, 65 + k, &format!("export frame {k}")); + } + let red = &got[100]; + assert!(red.as_chunks::<3>().0.iter().all(|[r, g, b]| *r > 230 && *g < 25 && *b < 25), "the matte is red"); + // the dissolve: frame = (1 - a)·camera + a·red, `a` rising through about 0.5 at the cut + let mix = |k: usize| { + let (cam, e) = (&src_frames[65 + k], &got[k]); + let (mut num, mut den) = (0.0, 0.0); + for ((c, r), e) in cam.iter().zip(red).zip(e) { + let (c, r, e) = (f64::from(*c), f64::from(*r), f64::from(*e)); + num += (e - c) * (r - c); + den += (r - c) * (r - c); + } + num / den + }; + let alphas: Vec = (64..=78).map(mix).collect(); + assert!(alphas[0].abs() < 0.05 && (alphas[14] - 1.0).abs() < 0.05, "outside the dissolve: {alphas:?}"); + assert!(alphas.windows(2).all(|w| w[1] >= w[0] - 0.02), "the dissolve progresses: {alphas:?}"); + assert!((alphas[7] - 0.5).abs() < 0.12, "half way at the cut (frame 71): {alphas:?}"); + + // sound: both tracks in the mix at their own level while the clip plays, silence on the matte + let got_pcm = pcm(&ffmpeg, &out, 0); + let (t1, t2) = (pcm(&ffmpeg, &src, 0), pcm(&ffmpeg, &src, 1)); + for at in [0.2, 1.5, 2.6] { + let (low, high) = (tone(&got_pcm, at, 440.0), tone(&got_pcm, at, 1320.0)); + let (want_low, want_high) = (tone(&t1, at + 2.6, 440.0), tone(&t2, at + 2.6, 1320.0)); + assert!( + db(low / want_low).abs() < 1.0 && db(high / want_high).abs() < 1.0, + "at {at} s: 440 Hz {low:.4} (want {want_low:.4}), 1320 Hz {high:.4} (want {want_high:.4})" + ); + } + assert!(rms(&got_pcm, 3.0, 4.8) < 1e-3, "silence under the matte"); +} + +/// `endFrame` of the first clip on V1 of the saved project's active sequence, read back through +/// `filmcraft-cli --project … inspect sequence`. +fn end_frame_of(project: &str) -> Option { + let o = Command::new(env!("CARGO_BIN_EXE_filmcraft-cli")).args(["--project", project, "inspect", "sequence"]).output().ok()?; + let v: Value = serde_json::from_slice(&o.stdout).ok()?; + v["video"][0]["items"][0]["endFrame"].as_u64() +} diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b3..bf456e94 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -110,6 +110,12 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G6. No real-world media corpus or workflow acceptance tests +- **Done:** first scripted MCP workflows (#835, `apps/filmcraft-cli/tests/workflows.rs`): the + `filmcraft-cli mcp` binary imports ffmpeg-made phone-like VFR H.264 + AAC MP4 and long-GOP + H.264 Matroska with two audio tracks (AAC, AC-3), edits (extract, audio gain, title; ripple + trims, colour matte, cross dissolve), exports H.264 MP4, and saves a project that a new process + exports as ProRes; ffprobe / ffmpeg check streams, frame count, duration, frame placement, A/V + sync, title pixels, dissolve mix and audio levels. Skipped without ffmpeg. - **Missing:** a pinned corpus of phone, camera and screen recordings (variable frame rate, damaged files, long GOP, odd containers, multi-track audio), each with an import → edit → export round trip checked against an ffprobe / ffmpeg oracle; scripted MCP sessions that do real jobs From 2ff3f5e266838700c292a35aaf762e3d4dcef980 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 10 Oct 2026 19:28:41 -0400 Subject: [PATCH 071/203] Release prep: 0.6.0 and a Linux riscv64 release build (#830) - release.yml: new `linux-riscv64` job, cross-compiled on ubuntu-24.04 against ports.ubuntu.com riscv64 libs (ported from GridCraft), tar.gz only, CLI smoke-tested under QEMU. Installs libasound2-dev:riscv64 for cpal/ALSA; the VA-API and NVDEC/NVENC paths are loaded with libloading at run time, so they need no riscv64 libraries. The draft-release job now needs it, so its tarball is uploaded and covered by SHA256SUMS.txt. - packaging/linux/package.sh: CROSS_ARCH / CROSS_TARGET / CROSS_COMPILE / EMULATOR support, as in GridCraft. - README and docs/releasing.md list the riscv64 tarball. - cargo xtask version set 0.6.0 (Cargo.toml, Cargo.lock); refresh contributors. Co-authored-by: Claude Opus 5.5 --- .github/workflows/release.yml | 82 ++++- Cargo.lock | 98 +++--- Cargo.toml | 2 +- README.md | 3 + contributors/contributors.json | 614 +++++++++++++++++++++++++++++---- docs/releasing.md | 1 + packaging/linux/package.sh | 27 +- 7 files changed, 693 insertions(+), 134 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 08eba22b..0ebde2f4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,9 +4,9 @@ # The jobs that sign (macOS, Windows) and the one that publishes the draft run in the `release` # environment (restricted to the `release` branch), which holds the signing secrets. Every secret # is optional: missing ones produce unsigned artifacts and a warning, never a failed build. The -# jobs that sign nothing (version, Linux, Flatpak, FreeBSD, web) get no environment and no -# secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are refused, -# so the draft-release job (which needs them) is skipped and nothing is published. +# jobs that sign nothing (version, Linux incl. riscv64, Flatpak, FreeBSD, web) get no environment +# and no secrets, so a dispatch on any other branch dry-runs them; there the signing jobs are +# refused, so the draft-release job (which needs them) is skipped and nothing is published. name: Release on: @@ -231,6 +231,80 @@ jobs: path: dist/release/* if-no-files-found: error + linux-riscv64: + name: Linux riscv64 + needs: version + # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release + # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's + # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the + # CLI is smoke-tested under QEMU. Audio links ALSA (cpal), hence libasound2-dev:riscv64; the + # hardware video paths (VA-API, NVDEC/NVENC) are loaded at run time with libloading, so they + # need no riscv64 libraries and fall back to the pure-Rust codecs when absent. + runs-on: ubuntu-24.04 + # No `environment: release`: this job signs nothing, so it gets no release secrets. + env: + FILMCRAFT_VERSION: ${{ needs.version.outputs.version }} + FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: storytold/craft-fonts + ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately + path: craft-fonts + - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable + with: + targets: riscv64gc-unknown-linux-gnu + - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 + with: + key: release-linux-riscv64 + - name: Apply version override + if: inputs.version != '' + run: cargo xtask version set "$FILMCRAFT_VERSION" + - name: Install cross-compilation dependencies + run: | + . /etc/os-release + sudo dpkg --add-architecture riscv64 + # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. + if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then + sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources + fi + if [ -f /etc/apt/sources.list ]; then + sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list + sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list + fi + for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do + echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" + done | sudo tee /etc/apt/sources.list.d/riscv64.list + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ + libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ + libxi-dev:riscv64 libgl1-mesa-dev:riscv64 libasound2-dev:riscv64 + - name: Build and package (tar.gz) + env: + CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts + CRAFT_FONTS_REQUIRED: "1" + CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc + PKG_CONFIG_SYSROOT_DIR: / + PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig + PKG_CONFIG_ALLOW_CROSS: "1" + CROSS_ARCH: riscv64 + CROSS_TARGET: riscv64gc-unknown-linux-gnu + CROSS_COMPILE: riscv64-linux-gnu- + EMULATOR: qemu-riscv64 + QEMU_LD_PREFIX: /usr/riscv64-linux-gnu + run: packaging/linux/package.sh --formats tar + - name: Check the tarball + run: | + tar -tzf dist/release/filmcraft-*-linux-riscv64.tar.gz \ + | grep -E '/bin/filmcraft$|/bin/filmcraft-cli$|/share/applications/ai\.storyteller\.filmcraft\.desktop$' + - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: release-linux-riscv64 + path: dist/release/*.tar.gz + if-no-files-found: error + flatpak: name: Flatpak ${{ matrix.arch }} needs: [version, linux] @@ -365,7 +439,7 @@ jobs: release: name: Draft GitHub Release - needs: [version, macos, windows, linux, flatpak, freebsd, web] + needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] runs-on: ubuntu-latest environment: release permissions: diff --git a/Cargo.lock b/Cargo.lock index 5529201a..1db02133 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1665,7 +1665,7 @@ dependencies = [ [[package]] name = "filmcraft" -version = "0.5.0" +version = "0.6.0" dependencies = [ "arboard", "chrono", @@ -1697,7 +1697,7 @@ dependencies = [ [[package]] name = "filmcraft-aac" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1707,7 +1707,7 @@ dependencies = [ [[package]] name = "filmcraft-ac3" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -1715,7 +1715,7 @@ dependencies = [ [[package]] name = "filmcraft-apv" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1725,14 +1725,14 @@ dependencies = [ [[package]] name = "filmcraft-audio-dsp" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-automation" -version = "0.5.0" +version = "0.6.0" dependencies = [ "base64 0.22.1", "filmcraft-engine", @@ -1748,7 +1748,7 @@ dependencies = [ [[package]] name = "filmcraft-av1" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1759,14 +1759,14 @@ dependencies = [ [[package]] name = "filmcraft-bitstream" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", ] [[package]] name = "filmcraft-captions" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-project", @@ -1779,11 +1779,11 @@ dependencies = [ [[package]] name = "filmcraft-cfb" -version = "0.5.0" +version = "0.6.0" [[package]] name = "filmcraft-cli" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-automation", "filmcraft-codecs", @@ -1804,7 +1804,7 @@ dependencies = [ [[package]] name = "filmcraft-codecs" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-aac", "filmcraft-ac3", @@ -1838,7 +1838,7 @@ dependencies = [ [[package]] name = "filmcraft-color" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -1846,7 +1846,7 @@ dependencies = [ [[package]] name = "filmcraft-dnx" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -1856,7 +1856,7 @@ dependencies = [ [[package]] name = "filmcraft-edit" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-geom", "filmcraft-media", @@ -1869,7 +1869,7 @@ dependencies = [ [[package]] name = "filmcraft-engine" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -1901,7 +1901,7 @@ dependencies = [ [[package]] name = "filmcraft-export" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-aac", "filmcraft-apv", @@ -1931,7 +1931,7 @@ dependencies = [ [[package]] name = "filmcraft-format" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-media", "filmcraft-project", @@ -1944,7 +1944,7 @@ dependencies = [ [[package]] name = "filmcraft-frame" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-time", @@ -1954,7 +1954,7 @@ dependencies = [ [[package]] name = "filmcraft-geom" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -1962,7 +1962,7 @@ dependencies = [ [[package]] name = "filmcraft-golden" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -1979,7 +1979,7 @@ dependencies = [ [[package]] name = "filmcraft-gpu" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-export", @@ -1998,7 +1998,7 @@ dependencies = [ [[package]] name = "filmcraft-h264" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2008,7 +2008,7 @@ dependencies = [ [[package]] name = "filmcraft-h264enc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2018,7 +2018,7 @@ dependencies = [ [[package]] name = "filmcraft-hevc" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2028,7 +2028,7 @@ dependencies = [ [[package]] name = "filmcraft-interchange" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-cfb", "filmcraft-color", @@ -2045,7 +2045,7 @@ dependencies = [ [[package]] name = "filmcraft-isobmff" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2056,7 +2056,7 @@ dependencies = [ [[package]] name = "filmcraft-matroska" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "serde_json", @@ -2064,7 +2064,7 @@ dependencies = [ [[package]] name = "filmcraft-media" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-frame", @@ -2077,7 +2077,7 @@ dependencies = [ [[package]] name = "filmcraft-mpeg2v" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "rayon", @@ -2086,7 +2086,7 @@ dependencies = [ [[package]] name = "filmcraft-mpegts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "thiserror 2.0.21", @@ -2094,28 +2094,28 @@ dependencies = [ [[package]] name = "filmcraft-mxf" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-ogg" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-opus" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", ] [[package]] name = "filmcraft-platform" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-codecs", @@ -2141,7 +2141,7 @@ dependencies = [ [[package]] name = "filmcraft-project" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "filmcraft-geom", @@ -2155,7 +2155,7 @@ dependencies = [ [[package]] name = "filmcraft-prores" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "filmcraft-testkit", @@ -2165,7 +2165,7 @@ dependencies = [ [[package]] name = "filmcraft-render" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-audio-dsp", "filmcraft-captions", @@ -2185,7 +2185,7 @@ dependencies = [ [[package]] name = "filmcraft-scopes" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "serde", @@ -2194,7 +2194,7 @@ dependencies = [ [[package]] name = "filmcraft-speech" -version = "0.5.0" +version = "0.6.0" dependencies = [ "candle-core", "candle-nn", @@ -2212,14 +2212,14 @@ dependencies = [ [[package]] name = "filmcraft-testkit" -version = "0.5.0" +version = "0.6.0" dependencies = [ "png", ] [[package]] name = "filmcraft-text" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-color", "harfrust", @@ -2231,7 +2231,7 @@ dependencies = [ [[package]] name = "filmcraft-time" -version = "0.5.0" +version = "0.6.0" dependencies = [ "proptest", "serde", @@ -2239,7 +2239,7 @@ dependencies = [ [[package]] name = "filmcraft-tts" -version = "0.5.0" +version = "0.6.0" dependencies = [ "candle-core", "candle-nn", @@ -2253,7 +2253,7 @@ dependencies = [ [[package]] name = "filmcraft-tts-text" -version = "0.5.0" +version = "0.6.0" dependencies = [ "serde", "thiserror 2.0.21", @@ -2261,7 +2261,7 @@ dependencies = [ [[package]] name = "filmcraft-ui-egui" -version = "0.5.0" +version = "0.6.0" dependencies = [ "cpu-time", "eframe", @@ -2298,7 +2298,7 @@ dependencies = [ [[package]] name = "filmcraft-vp9" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-bitstream", "rayon", @@ -2307,7 +2307,7 @@ dependencies = [ [[package]] name = "filmcraft-web" -version = "0.5.0" +version = "0.6.0" dependencies = [ "eframe", "egui", @@ -7332,7 +7332,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.5.0" +version = "0.6.0" dependencies = [ "filmcraft-testkit", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 1d306cff..a8990aa7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"] default-members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.5.0" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.95" diff --git a/README.md b/README.md index e74820ab..0ce67cb3 100644 --- a/README.md +++ b/README.md @@ -362,6 +362,9 @@ and web backend defaults are unchanged. | Fedora/RHEL/openSUSE | `filmcraft--linux-x86_64.rpm` | `filmcraft--linux-aarch64.rpm` | | | Tarball | `filmcraft--linux-x86_64.tar.gz` | `filmcraft--linux-aarch64.tar.gz` | Unpack and run `./install.sh` | +RISC-V (riscv64): `filmcraft--linux-riscv64.tar.gz` (tarball only; needs glibc 2.39+, e.g. Ubuntu 24.04 +or Debian 13). + The tarball installer places FilmCraft, its command-line tool and desktop integration in `~/.local`, without administrator permissions. For all users, run `sudo ./install.sh --prefix /usr/local` instead. Running the installer again updates the installation. diff --git a/contributors/contributors.json b/contributors/contributors.json index 29b65c4c..52531d0e 100644 --- a/contributors/contributors.json +++ b/contributors/contributors.json @@ -2,23 +2,23 @@ "schema": 1, "repo": "storytold/filmcraft", "branch": "main", - "head": "7b6c134472287db4367fca80bcb498240ba36567", - "head_date": "2026-10-09T15:02:38Z", - "commits": 539, + "head": "9efe8f3112194b58a30cf0449b569306077c67d4", + "head_date": "2026-10-10T22:34:28Z", + "commits": 812, "unattributed_commits": 0, "contributors": [ { "login": "echelon", "people_entry": true, "github_name": null, - "prs": 48, - "commits": 337, - "lines_added": 311167, - "lines_deleted": 13200, + "prs": 49, + "commits": 366, + "lines_added": 313219, + "lines_deleted": 14128, "binary_added": 74, "binary_deleted": 0, "first_commit": "2026-09-30T09:22:00Z", - "last_commit": "2026-10-09T15:02:38Z" + "last_commit": "2026-10-10T20:08:58Z" }, { "login": "storybored379", @@ -154,9 +154,9 @@ "login": "carlitosteso3", "people_entry": false, "github_name": null, - "prs": 15, - "commits": 15, - "lines_added": 1740, + "prs": 16, + "commits": 16, + "lines_added": 2009, "lines_deleted": 207, "binary_added": 0, "binary_deleted": 0, @@ -232,27 +232,53 @@ "login": "DWAA1660", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 209, - "lines_deleted": 0, + "prs": 3, + "commits": 3, + "lines_added": 441, + "lines_deleted": 10, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T22:57:38Z", - "last_commit": "2026-10-08T23:52:30Z" + "last_commit": "2026-10-09T22:01:08Z" }, { "login": "txiki739", "people_entry": false, "github_name": null, - "prs": 5, - "commits": 8, - "lines_added": 7976, - "lines_deleted": 3333, + "prs": 8, + "commits": 11, + "lines_added": 8160, + "lines_deleted": 3347, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-07T23:41:48Z", - "last_commit": "2026-10-09T09:28:24Z" + "last_commit": "2026-10-09T14:06:49Z" + }, + { + "login": "mutombopr", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 19, + "lines_added": 10421, + "lines_deleted": 337, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T03:57:26Z", + "last_commit": "2026-10-10T17:46:20Z" + }, + { + "login": "ahmedhosnypro", + "people_entry": true, + "github_name": null, + "prs": 2, + "commits": 5, + "lines_added": 473, + "lines_deleted": 80, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-08T05:32:08Z", + "last_commit": "2026-10-09T22:00:30Z" }, { "login": "wstock", @@ -284,14 +310,14 @@ "login": "MrCloudy2", "people_entry": false, "github_name": null, - "prs": 1, - "commits": 1, - "lines_added": 132, - "lines_deleted": 4, + "prs": 6, + "commits": 7, + "lines_added": 1293, + "lines_deleted": 46, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T08:53:54Z", - "last_commit": "2026-10-08T08:53:54Z" + "last_commit": "2026-10-10T15:14:34Z" }, { "login": "switch87", @@ -336,27 +362,27 @@ "login": "MarekSpodBiedry", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 74, - "lines_deleted": 6, + "prs": 29, + "commits": 29, + "lines_added": 1048, + "lines_deleted": 137, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T13:44:56Z", - "last_commit": "2026-10-09T08:45:28Z" + "last_commit": "2026-10-10T21:22:52Z" }, { "login": "Tom1tk", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 47, - "lines_deleted": 26, + "prs": 3, + "commits": 3, + "lines_added": 70, + "lines_deleted": 30, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T13:47:40Z", - "last_commit": "2026-10-08T14:56:39Z" + "last_commit": "2026-10-09T14:19:08Z" }, { "login": "Pathos0925", @@ -375,14 +401,14 @@ "login": "mjohnson7cd", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 1, - "lines_added": 100, - "lines_deleted": 2, + "prs": 3, + "commits": 2, + "lines_added": 121, + "lines_deleted": 5, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T20:36:28Z", - "last_commit": "2026-10-08T20:36:28Z" + "last_commit": "2026-10-10T00:47:33Z" }, { "login": "Oldrich333", @@ -401,14 +427,14 @@ "login": "cshapiro", "people_entry": false, "github_name": null, - "prs": 3, - "commits": 5, - "lines_added": 3128, - "lines_deleted": 111, + "prs": 4, + "commits": 6, + "lines_added": 3165, + "lines_deleted": 116, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-08T23:28:32Z", - "last_commit": "2026-10-09T02:57:30Z" + "last_commit": "2026-10-09T15:59:56Z" }, { "login": "rossaai", @@ -440,14 +466,14 @@ "login": "JordyUSA", "people_entry": false, "github_name": null, - "prs": 2, - "commits": 3, - "lines_added": 4158, - "lines_deleted": 283, + "prs": 6, + "commits": 9, + "lines_added": 4948, + "lines_deleted": 405, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T01:35:57Z", - "last_commit": "2026-10-09T02:53:28Z" + "last_commit": "2026-10-10T15:06:45Z" }, { "login": "jerrykimo", @@ -466,14 +492,27 @@ "login": "swillunderscore", "people_entry": true, "github_name": null, - "prs": 1, - "commits": 3, - "lines_added": 1008, - "lines_deleted": 13, + "prs": 2, + "commits": 6, + "lines_added": 2407, + "lines_deleted": 63, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T01:58:37Z", - "last_commit": "2026-10-09T02:19:21Z" + "last_commit": "2026-10-10T00:20:25Z" + }, + { + "login": "jnohclee-rgb", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 19, + "lines_deleted": 1, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T02:14:06Z", + "last_commit": "2026-10-09T02:14:06Z" }, { "login": "IppSec", @@ -492,14 +531,14 @@ "login": "sanyamsingh001", "people_entry": true, "github_name": null, - "prs": 2, - "commits": 2, - "lines_added": 323, - "lines_deleted": 25, + "prs": 5, + "commits": 5, + "lines_added": 1169, + "lines_deleted": 93, "binary_added": 0, "binary_deleted": 0, "first_commit": "2026-10-09T04:06:37Z", - "last_commit": "2026-10-09T05:44:39Z" + "last_commit": "2026-10-09T23:00:34Z" }, { "login": "ItzBloxy", @@ -513,6 +552,435 @@ "binary_deleted": 0, "first_commit": "2026-10-09T09:27:48Z", "last_commit": "2026-10-09T10:17:08Z" + }, + { + "login": "dmatviichuk", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 464, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T12:18:42Z", + "last_commit": "2026-10-09T12:18:42Z" + }, + { + "login": "David-T-Campos", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 7, + "lines_added": 61, + "lines_deleted": 8, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:25:06Z", + "last_commit": "2026-10-09T15:27:55Z" + }, + { + "login": "lloyd-nich", + "people_entry": false, + "github_name": null, + "prs": 0, + "commits": 3, + "lines_added": 1477, + "lines_deleted": 121, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T15:58:49Z", + "last_commit": "2026-10-10T07:02:57Z" + }, + { + "login": "haranishi", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 3700, + "lines_deleted": 92, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:14:19Z", + "last_commit": "2026-10-10T08:34:16Z" + }, + { + "login": "vbasky", + "people_entry": false, + "github_name": null, + "prs": 10, + "commits": 11, + "lines_added": 1314, + "lines_deleted": 215, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T17:37:44Z", + "last_commit": "2026-10-10T16:41:59Z" + }, + { + "login": "ossini", + "people_entry": true, + "github_name": "Oskar Sobania", + "prs": 2, + "commits": 12, + "lines_added": 1381, + "lines_deleted": 149, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T18:43:22Z", + "last_commit": "2026-10-10T17:17:47Z" + }, + { + "login": "liangforstudy", + "people_entry": true, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 575, + "lines_deleted": 20, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:22:05Z", + "last_commit": "2026-10-10T09:40:58Z" + }, + { + "login": "ReReddo", + "people_entry": true, + "github_name": null, + "prs": 12, + "commits": 14, + "lines_added": 1918, + "lines_deleted": 126, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:30:29Z", + "last_commit": "2026-10-10T19:45:20Z" + }, + { + "login": "MasakatsuFunaki", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 261, + "lines_deleted": 13, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T21:49:24Z", + "last_commit": "2026-10-09T21:49:24Z" + }, + { + "login": "maccagnit", + "people_entry": false, + "github_name": null, + "prs": 4, + "commits": 4, + "lines_added": 221, + "lines_deleted": 30, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T23:00:05Z", + "last_commit": "2026-10-10T15:22:56Z" + }, + { + "login": "digitaldecaycreations-lgtm", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 11, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-09T23:20:58Z", + "last_commit": "2026-10-09T23:20:58Z" + }, + { + "login": "dylanmtaylor", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 37, + "lines_deleted": 42, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:04:17Z", + "last_commit": "2026-10-10T00:04:17Z" + }, + { + "login": "sawizzle", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 23, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:06:15Z", + "last_commit": "2026-10-10T04:50:49Z" + }, + { + "login": "luniiya", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 697, + "lines_deleted": 6, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T00:14:55Z", + "last_commit": "2026-10-10T00:14:55Z" + }, + { + "login": "chuygarcia92", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 38, + "lines_deleted": 33, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:20:17Z", + "last_commit": "2026-10-10T02:20:17Z" + }, + { + "login": "Hathor875", + "people_entry": true, + "github_name": "Krzysztof Cieślik", + "prs": 1, + "commits": 1, + "lines_added": 109, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:21:06Z", + "last_commit": "2026-10-10T02:21:06Z" + }, + { + "login": "Guilhermeb-Ferrarezi", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 161, + "lines_deleted": 1, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T02:43:20Z", + "last_commit": "2026-10-10T02:48:16Z" + }, + { + "login": "mitchmckinstry", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 3, + "lines_added": 399, + "lines_deleted": 11, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T03:14:19Z", + "last_commit": "2026-10-10T03:25:58Z" + }, + { + "login": "gyroninja", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 2, + "lines_added": 178, + "lines_deleted": 12, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T04:32:42Z", + "last_commit": "2026-10-10T10:14:18Z" + }, + { + "login": "hadmark", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 4, + "lines_added": 3608, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T06:08:41Z", + "last_commit": "2026-10-10T06:32:49Z" + }, + { + "login": "SilentYeti", + "people_entry": false, + "github_name": null, + "prs": 2, + "commits": 3, + "lines_added": 1562, + "lines_deleted": 72, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T06:17:11Z", + "last_commit": "2026-10-10T09:17:16Z" + }, + { + "login": "adurr21", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 287, + "lines_deleted": 68, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T07:03:03Z", + "last_commit": "2026-10-10T07:03:03Z" + }, + { + "login": "maxm11", + "people_entry": true, + "github_name": null, + "prs": 3, + "commits": 6, + "lines_added": 487, + "lines_deleted": 38, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T07:19:38Z", + "last_commit": "2026-10-10T14:18:27Z" + }, + { + "login": "Ni-zav", + "people_entry": false, + "github_name": null, + "prs": 10, + "commits": 13, + "lines_added": 498, + "lines_deleted": 65, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T08:47:24Z", + "last_commit": "2026-10-10T21:55:59Z" + }, + { + "login": "ivandrozdov1505-coder", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 404, + "lines_deleted": 5, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T10:40:22Z", + "last_commit": "2026-10-10T10:40:22Z" + }, + { + "login": "KattKnight", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 3, + "lines_added": 335, + "lines_deleted": 9, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T10:56:07Z", + "last_commit": "2026-10-10T13:00:21Z" + }, + { + "login": "keller-lukas", + "people_entry": true, + "github_name": null, + "prs": 1, + "commits": 2, + "lines_added": 480, + "lines_deleted": 15, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T11:06:02Z", + "last_commit": "2026-10-10T11:06:02Z" + }, + { + "login": "4papuce", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 39, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T12:43:28Z", + "last_commit": "2026-10-10T12:43:28Z" + }, + { + "login": "vangel76", + "people_entry": true, + "github_name": null, + "prs": 49, + "commits": 53, + "lines_added": 11727, + "lines_deleted": 752, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T13:47:11Z", + "last_commit": "2026-10-10T22:34:28Z" + }, + { + "login": "abdelkabirouadoukou", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 30, + "lines_deleted": 8, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T15:54:00Z", + "last_commit": "2026-10-10T15:54:00Z" + }, + { + "login": "impostazioni", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 3, + "lines_added": 596, + "lines_deleted": 34, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T17:29:09Z", + "last_commit": "2026-10-10T20:52:28Z" + }, + { + "login": "alex-brazh", + "people_entry": false, + "github_name": null, + "prs": 1, + "commits": 1, + "lines_added": 159, + "lines_deleted": 4, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": "2026-10-10T19:55:52Z", + "last_commit": "2026-10-10T19:55:52Z" + }, + { + "login": "LloydNicholson", + "people_entry": false, + "github_name": null, + "prs": 3, + "commits": 0, + "lines_added": 0, + "lines_deleted": 0, + "binary_added": 0, + "binary_deleted": 0, + "first_commit": null, + "last_commit": null } ], "models": [ @@ -521,27 +989,27 @@ "company": "Anthropic", "model": "Claude Opus", "version": "5.5", - "commits": 474, - "lines_added": 346706, - "lines_deleted": 19057 + "commits": 602, + "lines_added": 374868, + "lines_deleted": 20972 }, { "id": "anthropic/claude-sonnet/5.5", "company": "Anthropic", "model": "Claude Sonnet", "version": "5.5", - "commits": 18, - "lines_added": 13935, - "lines_deleted": 854 + "commits": 25, + "lines_added": 18034, + "lines_deleted": 957 }, { "id": "openai/codex", "company": "OpenAI", "model": "Codex", "version": "", - "commits": 11, - "lines_added": 2057, - "lines_deleted": 361 + "commits": 16, + "lines_added": 3654, + "lines_deleted": 503 }, { "id": "anthropic/claude-fable/5.1", @@ -557,9 +1025,9 @@ "company": "Anthropic", "model": "Claude", "version": "", - "commits": 1, - "lines_added": 235, - "lines_deleted": 22 + "commits": 2, + "lines_added": 496, + "lines_deleted": 35 }, { "id": "anthropic/claude-opus/4.8", diff --git a/docs/releasing.md b/docs/releasing.md index 631e2ee9..58c28374 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -50,6 +50,7 @@ signing jobs are refused, and the draft-release job, which needs them, is skippe | Windows on ARM64 | `filmcraft--windows-arm64.msi`, `filmcraft--windows-arm64-portable.zip` | `windows-latest` (cross-compiled) | | Linux x86_64 | `filmcraft--linux-x86_64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04` | | Linux aarch64 | `filmcraft--linux-aarch64.{AppImage,deb,rpm,tar.gz}` | `ubuntu-22.04-arm` | +| Linux riscv64 | `filmcraft--linux-riscv64.tar.gz` | `ubuntu-24.04`, cross-compiled (glibc 2.39+; CLI smoke-tested under QEMU) | | AppImage updates | `filmcraft--linux-{x86_64,aarch64}.AppImage.zsync` | with the AppImage | | Flatpak | `filmcraft--linux-x86_64.flatpak`, `filmcraft--linux-aarch64.flatpak` | `ubuntu-24.04`, `ubuntu-24.04-arm` (repackaged Linux tarball) | | FreeBSD 14 x86_64 | `filmcraft--freebsd-x86_64.tar.gz` | FreeBSD 14.3 VM on `ubuntu-latest` | diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index b2d00bc3..5001bf71 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# Build and package FilmCraft for Linux ( is x86_64 or aarch64): +# Build and package FilmCraft for Linux ( is x86_64 or aarch64, or riscv64 when cross-compiling): # # $DIST/filmcraft--linux-.AppImage any distro with glibc >= the build host's # $DIST/filmcraft--linux-.AppImage.zsync delta updates (needs zsyncmake) @@ -13,6 +13,10 @@ # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, # glibc 2.35) so the binaries run on newer ones. Optional: desktop-file-validate, appstreamcli, # zsyncmake (the zsync package) for the AppImage's .zsync. +# +# Cross-compiling (CI's riscv64 job, tar only): set CROSS_ARCH (riscv64), CROSS_TARGET (the Rust +# target), CROSS_COMPILE (binutils prefix, for strip) and optionally EMULATOR (qemu-riscv64) to +# smoke-test the CLI. set -euo pipefail # shellcheck source=../env.sh . "$(dirname "${BASH_SOURCE[0]}")/../env.sh" @@ -25,13 +29,14 @@ while [ $# -gt 0 ]; do case "$1" in --skip-build) SKIP_BUILD=1; shift ;; --formats) FORMATS="$2"; shift 2 ;; - -h | --help) sed -n '2,16p' "$0"; exit 0 ;; + -h | --help) sed -n '2,21p' "$0"; exit 0 ;; *) echo "unknown argument: $1" >&2; exit 2 ;; esac done -ARCH="$(uname -m)" +ARCH="${CROSS_ARCH:-$(uname -m)}" case "$ARCH" in + riscv64) DEB_ARCH=riscv64 ;; x86_64) DEB_ARCH=amd64 ;; aarch64 | arm64) ARCH=aarch64; DEB_ARCH=arm64 ;; *) echo "unsupported architecture $ARCH" >&2; exit 2 ;; @@ -42,9 +47,13 @@ BASENAME="filmcraft-$VERSION-linux-$ARCH" echo "==> FilmCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then - (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) + if [ -n "${CROSS_TARGET:-}" ]; then + (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli --target "$CROSS_TARGET") + else + (cd "$ROOT" && cargo build --release --locked -p filmcraft -p filmcraft-cli) + fi fi -BIN="$CARGO_TARGET_DIR/release" +BIN="$CARGO_TARGET_DIR/${CROSS_TARGET:+$CROSS_TARGET/}release" WORK="$CARGO_TARGET_DIR/linux-package" STAGE="$WORK/root" rm -rf "$WORK" @@ -52,7 +61,7 @@ rm -rf "$WORK" # ---- stage an FHS tree (shared by every format) ------------------------------------------------- install -Dm755 "$BIN/filmcraft" "$STAGE/usr/bin/filmcraft" install -Dm755 "$BIN/filmcraft-cli" "$STAGE/usr/bin/filmcraft-cli" -strip "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true +"${CROSS_COMPILE:-}strip" "$STAGE/usr/bin/filmcraft" "$STAGE/usr/bin/filmcraft-cli" 2>/dev/null || true install -Dm644 "$HERE/$APP_ID.desktop" "$STAGE/usr/share/applications/$APP_ID.desktop" install -Dm644 "$HERE/$APP_ID.mime.xml" "$STAGE/usr/share/mime/packages/$APP_ID.xml" mkdir -p "$STAGE/usr/share/metainfo" @@ -136,6 +145,10 @@ if has appimage; then fi fi -"$STAGE/usr/bin/filmcraft-cli" --version +if [ -z "${CROSS_TARGET:-}" ]; then + "$STAGE/usr/bin/filmcraft-cli" --version +elif [ -n "${EMULATOR:-}" ]; then + "$EMULATOR" "$STAGE/usr/bin/filmcraft-cli" --version +fi echo "==> done" ls -lh "$DIST" From 4a3b2a20f19e4a066587508fbf8f415fd658a02f Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:39:41 +0200 Subject: [PATCH 072/203] #840: restart an audio stream the device drops instead of playing silent Problem: the cpal error callback marked the stream failed on its first error of any kind, so the UI stopped audio and played the rest of the playback without sound. cpal's ALSA backend reports transient errors (POLLERR after an xrun, spurious poll returns, short writes) that way and keeps the stream alive; after POLLERR it no longer calls back. Reports: a second or two of audio, then silence while meters move (#23). Fix: AudioOut::recover, called every playback frame, lets CpalOut restart the stream: at once when the device is gone, or when an error was reported and no samples were consumed for 300 ms. The restarted stream reuses the shared play-ahead mixer and played-frame clock and must keep the sample rate and channel count; at most 3 restarts in 10 s, then playback falls back to the wall clock as before. Errors while samples keep flowing no longer silence playback. The device callback runs the mixer under catch_unwind: a panic plays silence. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft/src/audio.rs | 347 ++++++++++++++++++++++---- crates/ui-egui/src/lib.rs | 55 ++++ crates/ui-egui/src/source_playback.rs | 5 + docs/gaps.md | 4 + 4 files changed, 359 insertions(+), 52 deletions(-) diff --git a/apps/filmcraft/src/audio.rs b/apps/filmcraft/src/audio.rs index f0e9bc48..cb56e059 100644 --- a/apps/filmcraft/src/audio.rs +++ b/apps/filmcraft/src/audio.rs @@ -1,18 +1,33 @@ //! cpal audio output: the playback master clock. Settings ▸ Audio Hardware picks the host //! ("Device Class"), the output device, the I/O buffer size and the sample rate. +//! +//! A stream the device drops while playing (an ALSA xrun the backend does not recover from, which +//! cpal reports as `poll() returned POLLERR`; an unplugged device) is restarted by +//! [`AudioOut::recover`], keeping the played-frame clock and the play-ahead mixer, so sound +//! resumes where it stopped instead of playback going silent (#840). +use std::panic::{AssertUnwindSafe, catch_unwind}; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::{Duration, Instant}; -use std::sync::{Arc, Mutex, PoisonError}; +use std::sync::{Arc, Mutex, PoisonError, TryLockError}; use cpal::traits::{DeviceTrait, HostTrait, StreamTrait}; use filmcraft_engine::settings::AudioHardwarePrefs; use filmcraft_ui_egui::{AudioDevices, AudioOut}; +/// The playback mixer callback handed to [`AudioOut::start`]. +type Fill = Box; + pub struct CpalOut { stream: Option, + /// The running playback's mixer, shared with the stream so a restarted stream continues it. + fill: Option>>, played: Arc, - failed: Arc, + health: Arc, + watchdog: Watchdog, + /// Time base for the watchdog. + epoch: Instant, rate: u32, channels: u16, hw: AudioHardwarePrefs, @@ -128,8 +143,11 @@ impl CpalOut { }; Self { stream: None, + fill: None, played: Arc::new(AtomicU64::new(0)), - failed: Arc::new(AtomicBool::new(false)), + health: Arc::new(Health::default()), + watchdog: Watchdog::default(), + epoch: Instant::now(), rate: cfg.as_ref().map_or(48_000, |c| c.sample_rate().0), channels: cfg.as_ref().map_or(2, |c| c.channels()), hw: AudioHardwarePrefs::default(), @@ -153,19 +171,14 @@ impl CpalOut { Ok(exact.unwrap_or(default)) } - fn converted_stream( - &self, - dev: &cpal::Device, - config: &cpal::StreamConfig, - mut fill: Box, - ) -> Result + fn converted_stream(&self, dev: &cpal::Device, config: &cpal::StreamConfig, fill: Arc>) -> Result where T: cpal::SizedSample + cpal::FromSample, { let channels = usize::from(config.channels); let played = self.played.clone(); - let failed = self.failed.clone(); - let error_flag = failed.clone(); + let health = self.health.clone(); + let error_health = health.clone(); let mut scratch = Vec::new(); dev.build_output_stream( config, @@ -173,39 +186,23 @@ impl CpalOut { // Reuse the conversion buffer. A pathological driver buffer must not panic the audio thread. if buf.len() > 1_048_576 || scratch.try_reserve(buf.len().saturating_sub(scratch.len())).is_err() { buf.fill(T::EQUILIBRIUM); - if !failed.swap(true, Ordering::SeqCst) { + if !health.failed.swap(true, Ordering::SeqCst) { eprintln!("filmcraft: unable to allocate the audio conversion buffer"); } return; } scratch.resize(buf.len(), 0.0); - fill(&mut scratch, channels); + render(&fill, &mut scratch, channels, &played, &health); convert_samples(&scratch, buf); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); }, - move |e| stream_error(&error_flag, e), + move |e| stream_error(&error_health, e), None, ) } -} -fn stream_error(failed: &AtomicBool, e: cpal::StreamError) { - if !failed.swap(true, Ordering::SeqCst) { - eprintln!("filmcraft: audio stream error: {e}"); - } -} - -fn convert_samples>(input: &[f32], output: &mut [T]) { - // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. - let upper = f32::from_bits(1.0_f32.to_bits() - 1); - for (sample, converted) in input.iter().zip(output) { - *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); - } -} - -impl AudioOut for CpalOut { - fn start(&mut self, mut fill: Box) -> Result { - self.stop(); + /// Open the configured output device and play `fill` on it. `expect` is the (sample rate, + /// channels) a restarted stream must keep: the mixer was set up for them. + fn open(&mut self, fill: Arc>, expect: Option<(u32, u16)>) -> Result<(), String> { let host = host(&self.hw.device_class); let DeviceChoice { device: dev, fallback } = output_device(&host, &self.hw.default_output, &self.fallback).ok_or("no output device")?; self.note = fallback; @@ -214,6 +211,11 @@ impl AudioOut for CpalOut { if channels == 0 || cfg.sample_rate().0 == 0 { return Err("the output device returned an invalid channel count or sample rate".into()); } + if let Some((rate, ch)) = expect + && (rate, ch) != (cfg.sample_rate().0, cfg.channels()) + { + return Err(format!("the output device now runs at {} Hz with {} channels", cfg.sample_rate().0, cfg.channels())); + } let mut config: cpal::StreamConfig = cfg.clone().into(); if let cpal::SupportedBufferSize::Range { min, max } = cfg.buffer_size() && (*min..=*max).contains(&self.hw.buffer_size) @@ -222,20 +224,18 @@ impl AudioOut for CpalOut { } self.rate = cfg.sample_rate().0; self.channels = cfg.channels(); - self.played.store(0, Ordering::SeqCst); - self.failed.store(false, Ordering::SeqCst); - let played = self.played.clone(); - let failed = self.failed.clone(); let stream = match cfg.sample_format() { - cpal::SampleFormat::F32 => dev.build_output_stream( - &config, - move |buf: &mut [f32], _| { - fill(buf, channels); - played.fetch_add((buf.len() / channels) as u64, Ordering::SeqCst); - }, - move |e| stream_error(&failed, e), - None, - ), + cpal::SampleFormat::F32 => { + let played = self.played.clone(); + let health = self.health.clone(); + let error_health = health.clone(); + dev.build_output_stream( + &config, + move |buf: &mut [f32], _| render(&fill, buf, channels, &played, &health), + move |e| stream_error(&error_health, e), + None, + ) + } cpal::SampleFormat::I8 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I16 => self.converted_stream::(&dev, &config, fill), cpal::SampleFormat::I24 => self.converted_stream::(&dev, &config, fill), @@ -251,10 +251,172 @@ impl AudioOut for CpalOut { .map_err(|e| e.to_string())?; stream.play().map_err(|e| e.to_string())?; self.stream = Some(stream); + Ok(()) + } + + /// Replace a dropped stream with a new one on the same mixer, clock and format. + fn restart(&mut self) -> Result<(), String> { + let fill = self.fill.clone().ok_or("nothing is playing")?; + let expect = (self.rate, self.channels); + // The old stream (and its audio thread) goes first: only one stream ever runs the mixer. + self.stream = None; + self.health.clear_errors(); + self.open(fill, Some(expect)) + } +} + +/// What the audio thread reports to the UI thread. +#[derive(Default)] +struct Health { + /// The device went away (unplugged): restart the stream at once. + lost: AtomicBool, + /// A backend error since the last check (cpal keeps the stream alive after these): restart + /// only if the stream also stopped consuming samples. + errored: AtomicBool, + /// Unrecoverable: the mixer panicked, a buffer could not be allocated, or restarts ran out. + /// [`AudioOut::played_frames`] reports `None` and playback goes on without sound. + failed: AtomicBool, + /// The first error of a stream has been printed. + reported: AtomicBool, +} + +impl Health { + fn clear_errors(&self) { + self.lost.store(false, Ordering::SeqCst); + self.errored.store(false, Ordering::SeqCst); + self.reported.store(false, Ordering::SeqCst); + } +} + +/// How long a stream that reported an error may go without consuming samples before it is +/// restarted. Well under the UI's `AUDIO_STALL_S`, which gives up on the device clock. +const STALL_RESTART: Duration = Duration::from_millis(300); +/// At most this many restarts within [`RESTART_WINDOW`]; a device that keeps failing gives up. +const MAX_RESTARTS: usize = 3; +const RESTART_WINDOW: Duration = Duration::from_secs(10); + +/// What [`Watchdog::check`] decides about the running stream. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Verdict { + Healthy, + Restart, + GiveUp, +} + +/// Decides when a stream that reported errors is restarted (UI thread, every frame). +#[derive(Default)] +struct Watchdog { + /// Played-frame count last seen, and when it last changed. + seen: (u64, Duration), + /// An error was reported and the stream has not consumed samples since. + pending_error: bool, + /// When recent restarts happened (at most [`MAX_RESTARTS`] kept). + restarts: Vec, +} + +impl Watchdog { + fn reset(&mut self, now: Duration) { + *self = Self { seen: (0, now), ..Self::default() }; + } + + fn check(&mut self, now: Duration, played: u64, errored: bool, lost: bool) -> Verdict { + if played != self.seen.0 { + self.seen = (played, now); + self.pending_error = false; + } + // An error that arrives with the stream still consuming samples is only a hiccup. + self.pending_error |= errored; + let stalled = now.saturating_sub(self.seen.1) >= STALL_RESTART; + if !lost && !(self.pending_error && stalled) { + return Verdict::Healthy; + } + self.restarts.retain(|t| now.saturating_sub(*t) < RESTART_WINDOW); + if self.restarts.len() >= MAX_RESTARTS { + return Verdict::GiveUp; + } + self.restarts.push(now); + self.seen = (played, now); + self.pending_error = false; + Verdict::Restart + } +} + +/// One device callback: run the shared mixer into `buf` and advance the clock. Never panics: a +/// panicking mixer plays silence and fails the stream, and a mixer held by another stream (never +/// expected: a restart drops the old stream first) plays silence without advancing the clock. +fn render(fill: &Mutex, buf: &mut [f32], channels: usize, played: &AtomicU64, health: &Health) { + let mut guard = match fill.try_lock() { + Ok(guard) => guard, + Err(TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(TryLockError::WouldBlock) => { + buf.fill(0.0); + return; + } + }; + if health.failed.load(Ordering::SeqCst) || catch_unwind(AssertUnwindSafe(|| (*guard)(buf, channels))).is_err() { + buf.fill(0.0); + if !health.failed.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: the audio mixer failed; playing without sound"); + } + return; + } + played.fetch_add((buf.len() / channels.max(1)) as u64, Ordering::SeqCst); +} + +fn stream_error(health: &Health, e: cpal::StreamError) { + match e { + cpal::StreamError::DeviceNotAvailable => health.lost.store(true, Ordering::SeqCst), + cpal::StreamError::BackendSpecific { .. } => health.errored.store(true, Ordering::SeqCst), + } + if !health.reported.swap(true, Ordering::SeqCst) { + eprintln!("filmcraft: audio stream error: {e}"); + } +} + +fn convert_samples>(input: &[f32], output: &mut [T]) { + // Integer conversion represents [-1, 1). In particular I24 cannot represent +1 exactly. + let upper = f32::from_bits(1.0_f32.to_bits() - 1); + for (sample, converted) in input.iter().zip(output) { + *converted = T::from_sample(if sample.is_finite() { sample.clamp(-1.0, upper) } else { 0.0 }); + } +} + +impl AudioOut for CpalOut { + fn start(&mut self, fill: Box) -> Result { + self.stop(); + self.played.store(0, Ordering::SeqCst); + self.health = Arc::new(Health::default()); + self.watchdog.reset(self.epoch.elapsed()); + let fill = Arc::new(Mutex::new(fill)); + self.open(fill.clone(), None)?; + self.fill = Some(fill); Ok(self.rate) } fn stop(&mut self) { self.stream = None; + self.fill = None; + } + fn recover(&mut self) { + if self.stream.is_none() || self.health.failed.load(Ordering::SeqCst) { + return; + } + let lost = self.health.lost.swap(false, Ordering::SeqCst); + let errored = self.health.errored.swap(false, Ordering::SeqCst); + match self.watchdog.check(self.epoch.elapsed(), self.played.load(Ordering::SeqCst), errored, lost) { + Verdict::Healthy => {} + Verdict::Restart => { + log::warn!("the audio output stream {}; restarting it", if lost { "lost its device" } else { "stopped after an error" }); + if let Err(e) = self.restart() { + log::warn!("the audio output stream could not be restarted: {e}"); + self.stream = None; + self.health.failed.store(true, Ordering::SeqCst); + } + } + Verdict::GiveUp => { + log::warn!("the audio output stream keeps failing ({MAX_RESTARTS} restarts within {} s)", RESTART_WINDOW.as_secs()); + self.health.failed.store(true, Ordering::SeqCst); + } + } } fn sample_rate(&self) -> u32 { self.rate @@ -263,7 +425,7 @@ impl AudioOut for CpalOut { self.channels as usize } fn played_frames(&self) -> Option { - self.stream.as_ref().filter(|_| !self.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) + self.stream.as_ref().filter(|_| !self.health.failed.load(Ordering::SeqCst)).map(|_| self.played.load(Ordering::SeqCst)) } fn devices(&self) -> AudioDevices { let h = host(&self.hw.device_class); @@ -327,11 +489,92 @@ mod tests { } #[test] - fn failed_stream_relinquishes_the_playback_clock() { - let failed = AtomicBool::new(false); - stream_error(&failed, cpal::StreamError::DeviceNotAvailable); - assert!(failed.load(Ordering::SeqCst)); + fn stream_errors_ask_for_a_restart_instead_of_failing_playback() { + let health = Health::default(); + stream_error(&health, cpal::StreamError::BackendSpecific { err: cpal::BackendSpecificError { description: "`alsa::poll()` returned POLLERR".into() } }); + assert!(health.errored.load(Ordering::SeqCst) && !health.lost.load(Ordering::SeqCst)); + stream_error(&health, cpal::StreamError::DeviceNotAvailable); + assert!(health.lost.load(Ordering::SeqCst)); + assert!(!health.failed.load(Ordering::SeqCst), "a stream error alone must not silence playback"); let output = CpalOut::new(); - assert_eq!(output.played_frames(), None); + assert_eq!(output.played_frames(), None, "no stream, no clock"); + } + + fn ms(n: u64) -> Duration { + Duration::from_millis(n) + } + + #[test] + fn an_error_while_samples_keep_flowing_is_only_a_hiccup() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, true, false), Verdict::Healthy); + // the stream keeps consuming samples after the error: never restarted + for (i, t) in (20..2_000).step_by(20).enumerate() { + assert_eq!(w.check(ms(t), 480 * (i as u64 + 2), false, false), Verdict::Healthy); + } + } + + #[test] + fn a_stream_that_errors_and_stops_consuming_is_restarted() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(10), 480, false, false), Verdict::Healthy); + // POLLERR: the backend reports errors and no longer calls back + assert_eq!(w.check(ms(20), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(200), 480, true, false), Verdict::Healthy); + assert_eq!(w.check(ms(310), 480, false, false), Verdict::Restart); + // the restarted stream gets a fresh grace period + assert_eq!(w.check(ms(400), 480, false, false), Verdict::Healthy); + // a stall without any reported error is left to the UI's own watchdog + assert_eq!(w.check(ms(2_000), 480, false, false), Verdict::Healthy); + } + + #[test] + fn a_lost_device_restarts_at_once_and_repeated_failures_give_up() { + let mut w = Watchdog::default(); + w.reset(ms(0)); + assert_eq!(w.check(ms(5), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(10), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(15), 0, false, true), Verdict::Restart); + assert_eq!(w.check(ms(20), 0, false, true), Verdict::GiveUp); + // sporadic failures far apart keep being recovered + assert_eq!(w.check(ms(10_006), 0, false, true), Verdict::Restart); + } + + #[test] + fn the_device_callback_never_panics_and_counts_played_frames() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| buf.fill(0.25))); + let mut buf = [1.0_f32; 8]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.25; 8], 4)); + // a mixer still held elsewhere plays silence without moving the clock + { + let _held = fill.lock().unwrap(); + render(&fill, &mut buf, 2, &played, &health); + } + assert_eq!((buf, played.load(Ordering::SeqCst)), ([0.0; 8], 4)); + // zero channels from a broken driver must not divide by zero + render(&fill, &mut buf, 0, &played, &health); + assert_eq!(played.load(Ordering::SeqCst), 12); + } + + #[test] + fn a_panicking_mixer_plays_silence_and_fails_the_stream() { + let (played, health) = (AtomicU64::new(0), Health::default()); + let fill: Mutex = Mutex::new(Box::new(|buf: &mut [f32], _| { + buf.fill(0.5); + panic!("mixer bug"); + })); + let mut buf = [1.0_f32; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); + assert_eq!(played.load(Ordering::SeqCst), 0); + assert!(health.failed.load(Ordering::SeqCst)); + // later callbacks stay silent instead of re-entering the broken mixer + buf = [1.0; 4]; + render(&fill, &mut buf, 2, &played, &health); + assert_eq!(buf, [0.0; 4]); } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9c4c526f..361cc948 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -80,6 +80,10 @@ pub trait AudioOut { } /// Frames played since `start` (the playback master clock), if the device reports it. fn played_frames(&self) -> Option; + /// Called every frame while playing, before `played_frames`: restart a stream the device + /// dropped (a driver error, an unplugged device), keeping the clock and `fill`. A stream that + /// cannot be restarted reports `played_frames() == None`. + fn recover(&mut self) {} /// Hosts and devices that can be chosen in Settings ▸ Audio Hardware. fn devices(&self) -> AudioDevices { AudioDevices::default() @@ -1013,6 +1017,11 @@ impl FilmcraftApp { self.playback.anchor_time = now; } let rate = self.session.sequence_rate(); + if self.playback.audio_clock + && let Some(audio) = self.audio.as_mut() + { + audio.recover(); + } let reading = if self.playback.audio_clock { self.audio.as_ref().and_then(|a| a.played_frames().map(|f| (f, a.sample_rate()))) } else { None }; if self.playback.audio_clock && reading.is_none() { // A device error relinquishes its clock immediately. Resume from the displayed @@ -2263,6 +2272,52 @@ mod audio_recovery_tests { assert!(app.ui.status.contains("Audio output failed")); app.stop(); } + + /// A stream the device dropped, which `recover` restarts on the same clock. + struct Restartable { + reading: Option, + } + + impl AudioOut for Restartable { + fn start(&mut self, _: Box) -> Result { + Ok(48000) + } + fn stop(&mut self) {} + fn sample_rate(&self) -> u32 { + 48000 + } + fn played_frames(&self) -> Option { + self.reading + } + fn recover(&mut self) { + self.reading = self.reading.or(Some(48_000)); + } + } + + #[test] + fn a_restarted_stream_keeps_the_device_clock_and_the_sound() { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).unwrap(); + let ctx = egui::Context::default(); + let mut app = FilmcraftApp::new(session); + app.audio = Some(Box::new(Restartable { reading: None })); + app.playback.playing = true; + app.playback.audio_clock = true; + app.playback.anchor_tick = Tick::ZERO; + app.playback.anchor_time = 0.0; + app.playback.audio_seen = (0, 10.0); + let mut output = ctx.run_ui(egui::RawInput { time: Some(10.0), ..Default::default() }, |ui| app.advance_playback(ui.ctx())); + output.textures_delta.clear(); + assert!(app.playback.audio_clock, "the restarted stream is still the master clock"); + assert!(!app.ui.status.contains("Audio output failed")); + // 48 000 frames played at 48 kHz: one second in, on a frame boundary + let (shown, second) = (app.session.playhead(), Tick::from_seconds_f64(1.0)); + assert!( + shown <= second && shown + app.session.sequence_rate().frame_duration() > second, + "the playhead follows the frames the device played: {shown:?}" + ); + app.stop(); + } } /// A physical-pixel screen position as a point in the window's UI space, given the window's content origin in points. diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index 7cdb7c21..2a783325 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -213,6 +213,11 @@ impl FilmcraftApp { self.source_playback.clock.audio_seen = (0, now); self.start_source_audio(); } + if self.source_playback.clock.audio_clock + && let Some(audio) = self.audio.as_mut() + { + audio.recover(); + } let clock = &mut self.source_playback.clock; if clock.anchor_time < 0.0 { clock.anchor_time = now; diff --git a/docs/gaps.md b/docs/gaps.md index f798de68..e54135ea 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -229,6 +229,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Evidence:** Asus Xonar DX output broken (#605), no audio (#602), audio cuts out with overlapping clips (#336), buggy audio (#410), Linux stutter (#617). No ASIO; cpal only. +- **Have:** a stream the device drops while playing (an ALSA xrun cpal reports as `POLLERR`, an + unplugged device) is restarted with the same rate, channels, clock and mixer position, so sound + resumes instead of playback going silent (at most 3 restarts in 10 s); a panicking mixer plays + silence (#840). - **Estimate:** 20–40 h. ## G18. Effects depth: approximations and missing behaviour From 09f627a73a04edb41ef3eec9390cc07dad89434d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:51:47 +0200 Subject: [PATCH 073/203] #844: on-monitor corner-roundness handle for masks Problem: masks.roundCorners (#813) rounded rectangle/polygon mask corners only from the command line, control channel or MCP; the Program monitor had no Premiere 26-style corner-roundness handle. Fix: a selected mask with sharp corners between straight edges shows a roundness handle just inside its top-left corner (automation id program.mask.roundness). Dragging it into the shape moves it along the corner bisector, previews the rounded outline live with the radius, and on release dispatches one masks.roundCorners (one undo step per drag). The radius is capped where no corner can grow further. Ellipses, pen masks and rounded corners show no handle. Tooltip translated (es/fr/ja/zh-cn). New headless UI test mask_roundness_ui; docs/gaps.md G18 updated. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/es.tsv | 1 + crates/ui-egui/src/i18n/fr.tsv | 1 + crates/ui-egui/src/i18n/ja.tsv | 1 + crates/ui-egui/src/i18n/zh-cn.tsv | 1 + crates/ui-egui/src/panels/masks.rs | 123 ++++++++++++++++- crates/ui-egui/tests/mask_roundness_ui.rs | 158 ++++++++++++++++++++++ docs/gaps.md | 6 +- 7 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/tests/mask_roundness_ui.rs diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 76cc3066..692ac2b9 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -3488,6 +3488,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Haz clic para seleccionar, o haz clic en un espacio vacío y arrastra para seleccionar con un marco. Usa Mayús, Alt y Ctrl para otras opciones. Click to zoom in; Alt-click to zoom out. Haz clic para acercar; Alt+clic para alejar. Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastrar: mover · Alt+clic: suave/esquina · Ctrl+clic: eliminar + Drag inward to round the corners Arrastrar hacia dentro para redondear las esquinas Include Alpha Channel Incluir canal alfa Sequence Settings Ajustes de secuencia Color Management Gestión del color diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 5f17a4f4..170b8011 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -3478,6 +3478,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Cliquez pour sélectionner, ou cliquez dans un espace vide et faites glisser pour sélectionner par rectangle. Utilisez Maj, Alt et Ctrl pour d'autres options. Click to zoom in; Alt-click to zoom out. Cliquez pour un zoom avant ; Alt+clic pour un zoom arrière. Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Glisser : déplacer · Alt+clic : lisse/angle · Ctrl+clic : supprimer + Drag inward to round the corners Glisser vers l'intérieur pour arrondir les angles Include Alpha Channel Inclure la couche alpha Sequence Settings Paramètres de séquence Color Management Gestion des couleurs diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 2c160f4b..41d31f6c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -3487,6 +3487,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. クリックで選択します。何もない場所からドラッグすると範囲選択になります。Shift、Alt、Ctrl キーを併用すると、ほかのオプションも使えます。 Click to zoom in; Alt-click to zoom out. クリックでズームイン、Alt+クリックでズームアウトします。 Drag: move · Alt-click: smooth/corner · Ctrl-click: delete ドラッグ: 移動 · Alt+クリック: スムーズ/コーナー · Ctrl+クリック: 削除 + Drag inward to round the corners 内側にドラッグして角を丸める Include Alpha Channel アルファチャンネルを含める Sequence Settings シーケンス設定 Color Management カラーマネジメント diff --git a/crates/ui-egui/src/i18n/zh-cn.tsv b/crates/ui-egui/src/i18n/zh-cn.tsv index 9e0e7803..d74a619b 100644 --- a/crates/ui-egui/src/i18n/zh-cn.tsv +++ b/crates/ui-egui/src/i18n/zh-cn.tsv @@ -3471,6 +3471,7 @@ Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. 单击以选择,或在空白处单击并拖动以框选。使用 Shift、Alt 和 Ctrl 进行其他操作。 Click to zoom in; Alt-click to zoom out. 单击以放大;Alt-单击以缩小。 Drag: move · Alt-click: smooth/corner · Ctrl-click: delete 拖动:移动 · Alt-单击:平滑/拐角 · Ctrl-单击:删除 + Drag inward to round the corners 向内拖动以使拐角变圆 Include Alpha Channel 包含 Alpha 通道 Sequence Settings 序列设置 Color Management 色彩管理 diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 5062918e..eb479c17 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -1,13 +1,13 @@ //! Effect masks in the UI: the mask rows of Effect Controls (create ellipse / 4-point polygon / //! pen masks, per-mask Path / Feather / Opacity / Expansion / Inverted / mode) and on-monitor -//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather and -//! expansion handles; the pen places a free-draw Bézier mask). +//! editing on the Program monitor (drag vertices, Bézier handles, the whole mask, feather, +//! expansion and corner-roundness handles; the pen places a free-draw Bézier mask). //! //! Automation ids: `effectControls..mask.{ellipse,polygon,pen}`, //! `effectControls..mask` (+ `.mode`, `.inverted`, `.delete`, and the parameter rows //! `effectControls..mask..*`), `program.mask.vertex.`, `program.mask.in.`, //! `program.mask.out.`, `program.mask.body`, `program.mask.feather`, `program.mask.expansion`, -//! `program.maskPen`. +//! `program.mask.roundness`, `program.maskPen`. use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, pos2, vec2}; use filmcraft_geom::{Affine, Vec2}; @@ -344,6 +344,8 @@ pub fn monitor_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, fra } } } + // ---- corner roundness (masks with sharp corners: rectangles, polygons) + actions.extend(roundness_handle(app, ui, &painter, &path, &to_screen, &target)); // ---- tangent handles and vertices let sel_v_id = egui::Id::new(("mask-sel-vertex", sel.clip.0, sel.effect, sel.mask)); let sel_v: Option = ui.data(|d| d.get_temp(sel_v_id)); @@ -436,6 +438,121 @@ fn seg_dist(p: Pos2, a: Pos2, b: Pos2) -> f32 { (p - (a + ab * t)).length() } +/// A sharp corner `masks.roundCorners` can fillet: its point, the unit bisector pointing to the +/// fillet's centre, `sin(θ/2)` of its opening angle θ, and the largest radius it takes (the fillet +/// never uses more than half of either edge). +struct RoundableCorner { + p: Vec2, + bisector: Vec2, + sin_half: f64, + max_radius: f64, +} + +/// The corners of `path` that rounding changes (degenerate ones — straight, reversing, zero-length +/// edges — are left out). +fn roundable_corners(path: &MaskPath) -> Vec { + let n = path.vertices.len(); + path.roundable_corners() + .into_iter() + .filter_map(|i| { + let v = path.vertices.get(i)?.p; + let prev = path.vertices.get((i + n - 1) % n)?.p; + let next = path.vertices.get((i + 1) % n)?.p; + let (a, b) = (prev - v, next - v); + let (la, lb) = (a.length(), b.length()); + if !(la > 1e-6 && lb > 1e-6) { + return None; + } + let (u, w) = (a * (1.0 / la), b * (1.0 / lb)); + let theta = (u.x * w.x + u.y * w.y).clamp(-1.0, 1.0).acos(); + if !(1e-6..std::f64::consts::PI - 1e-6).contains(&theta) { + return None; + } + let s = u + w; + let sl = s.length(); + if !sl.is_finite() || sl <= 1e-9 { + return None; + } + let half = theta / 2.0; + let max_radius = la.min(lb) / 2.0 * half.tan(); + max_radius.is_finite().then_some(RoundableCorner { p: v, bisector: s * (1.0 / sl), sin_half: half.sin(), max_radius }) + }) + .collect() +} + +/// Radius of a roundness drag that has moved `dist` clip pixels along the corner's bisector: the +/// handle follows the fillet's centre, so it sits `r / sin(θ/2)` from the corner. +fn drag_radius(dist: f64, c: &RoundableCorner, max_radius: f64) -> f64 { + let r = dist * c.sin_half; + if r.is_finite() { r.clamp(0.0, max_radius.max(0.0)) } else { 0.0 } +} + +/// The corner-roundness handle of the selected mask, just inside its top-left sharp corner. +/// Dragging it into the shape previews the rounded corners live; releasing rounds every sharp +/// corner with one `masks.roundCorners` (one undo step per drag). Masks without a sharp corner +/// between straight edges (ellipses, pen masks, corners already rounded) show no handle. +fn roundness_handle( + app: &mut FilmcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + path: &MaskPath, + to_screen: &Affine, + target: &Value, +) -> Option<(String, Value)> { + let corners = roundable_corners(path); + let to_clip = to_screen.inverse()?; + let lin_inv = Affine { e: 0.0, f: 0.0, ..to_clip }; + let c = corners.iter().min_by(|a, b| { + let (pa, pb) = (sp(to_screen, a.p), sp(to_screen, b.p)); + (pa.x + pa.y).total_cmp(&(pb.x + pb.y)) + })?; + let max_radius = corners.iter().map(|c| c.max_radius).fold(0.0, f64::max); + let id = egui::Id::new("mask-roundness"); + let dist_id = id.with("dist"); + let dragging = ui.ctx().is_being_dragged(id); + let dist: f64 = if dragging { ui.data(|d| d.get_temp(dist_id)).unwrap_or(0.0) } else { 0.0 }; + let radius = drag_radius(dist, c, max_radius); + let anchor = sp(to_screen, c.p); + let dir = sp(to_screen, c.p + c.bisector) - anchor; + let dir = if dir.length() > 1e-6 { dir.normalized() } else { egui::Vec2::new(1.0, 1.0).normalized() }; + let hp = sp(to_screen, c.p + c.bisector * (radius / c.sin_half.max(1e-9))) + dir * 18.0; + let hr = Rect::from_center_size(hp, vec2(9.0, 9.0)); + let r = ui.interact(hr.expand(3.0), id, Sense::drag()).on_hover_text(tl!("Drag inward to round the corners")); + app.auto.add("program.mask.roundness", hr, "Mask corner roundness"); + let col = Color32::from_rgb(0x5a, 0xb4, 0xff); + if dragging && radius > 0.0 { + let preview = path.with_rounded_corners(radius, &path.roundable_corners()).flatten(0.5); + painter.add(egui::Shape::closed_line(preview.iter().map(|v| sp(to_screen, *v)).collect(), Stroke::new(1.5, Color32::WHITE))); + painter.text(hp + vec2(10.0, -10.0), Align2::LEFT_BOTTOM, format!("{radius:.1}"), Tokens::ui(10.5), app.tokens.hot_text); + } + painter.line_segment([anchor, hp], Stroke::new(1.0, col.gamma_multiply(0.6))); + painter.circle_filled(hp, 4.0, Color32::WHITE); + painter.circle_stroke(hp, 4.0, Stroke::new(1.5, col)); + let primary = egui::PointerButton::Primary; + if r.drag_started_by(primary) { + ui.data_mut(|d| d.insert_temp(dist_id, 0.0_f64)); + } + if r.dragged_by(primary) && r.drag_delta() != egui::Vec2::ZERO { + let d = lin_inv.apply(Vec2::new(r.drag_delta().x as f64, r.drag_delta().y as f64)); + let along = d.x * c.bisector.x + d.y * c.bisector.y; + // accumulate the raw distance: past the limit the handle waits for the pointer to come back + let total = ui.data(|m| m.get_temp::(dist_id)).unwrap_or(0.0) + along; + ui.data_mut(|m| m.insert_temp(dist_id, if total.is_finite() { total } else { 0.0 })); + } + if r.drag_stopped_by(primary) { + let dist: f64 = ui.data_mut(|d| d.remove_temp(dist_id)).unwrap_or(0.0); + let radius = (drag_radius(dist, c, max_radius) * 10.0).round() / 10.0; + if radius > 0.0 { + let mut p = target.clone(); + if let Some(o) = p.as_object_mut() { + o.insert("radius".into(), json!(radius)); + } + return Some(("masks.roundCorners".into(), p)); + } + } + None +} + /// Draft vertices → a mask path. pub fn draft_path(points: &[[f64; 4]]) -> MaskPath { MaskPath { diff --git a/crates/ui-egui/tests/mask_roundness_ui.rs b/crates/ui-egui/tests/mask_roundness_ui.rs new file mode 100644 index 00000000..4311afcf --- /dev/null +++ b/crates/ui-egui/tests/mask_roundness_ui.rs @@ -0,0 +1,158 @@ +//! The corner-roundness handle of rectangle / polygon masks on the Program monitor, driven +//! headless through the control channel: dragging it rounds the corners in one undo step; masks +//! without sharp corners show no handle. +//! +//! With `FILMCRAFT_UI_SHOTS=` the test also renders the UI with wgpu and writes PNGs there. + +use std::sync::mpsc::{Sender, channel}; + +use egui_kittest::Harness; +use filmcraft_engine::Session; +use filmcraft_ui_egui::FilmcraftApp; +use filmcraft_ui_egui::control::ControlRequest; +use serde_json::{Value, json}; + +struct Driver { + harness: Harness<'static, FilmcraftApp>, + tx: Sender, + shots: Option, +} + +impl Driver { + fn demo() -> Self { + let mut session = Session::default(); + session.execute("file.openDemoProject", json!({})).expect("demo project"); + let (tx, rx) = channel(); + let app = FilmcraftApp::new(session).with_control(rx); + let shots = std::env::var_os("FILMCRAFT_UI_SHOTS").map(std::path::PathBuf::from); + let mut b = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_max_steps(10_000); + if shots.is_some() { + b = b.wgpu().with_pixels_per_point(1.0); + } + let harness = b.build_eframe(move |_cc| app); + let mut d = Driver { harness, tx, shots }; + d.frames(4); + d + } + fn frames(&mut self, n: usize) { + for _ in 0..n { + let ctx = self.harness.ctx.clone(); + let mut raw = std::mem::take(self.harness.input_mut()); + eframe::App::raw_input_hook(self.harness.state_mut(), &ctx, &mut raw); + *self.harness.input_mut() = raw; + self.harness.step(); + } + } + fn call(&mut self, method: &str, params: Value) -> Value { + let (req, reply) = ControlRequest::new(method, params.clone()); + self.tx.send(req).unwrap(); + for _ in 0..600 { + self.frames(1); + if let Ok(v) = reply.try_recv() { + return v; + } + } + panic!("no reply to {method} {params}"); + } + fn ok(&mut self, method: &str, params: Value) -> Value { + let r = self.call(method, params.clone()); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + r["result"].clone() + } + fn exec(&mut self, command: &str, params: Value) -> Value { + self.ok("engine.execute", json!({"command": command, "params": params})) + } + fn rect(&mut self, id: &str) -> [f64; 4] { + let v = self.ok("ui.elements", json!({"prefix": id})); + let e = v.as_array().unwrap().iter().find(|e| e["id"] == id).unwrap_or_else(|| panic!("no element {id}: {v}")).clone(); + let r: Vec = e["rect"].as_array().unwrap().iter().map(|x| x.as_f64().unwrap()).collect(); + [r[0], r[1], r[2], r[3]] + } + fn center(&mut self, id: &str) -> (f64, f64) { + let r = self.rect(id); + (r[0] + r[2] / 2.0, r[1] + r[3] / 2.0) + } + fn drag(&mut self, id: &str, dx: f64, dy: f64) { + let (x, y) = self.center(id); + self.ok("ui.drag", json!({"from": {"x": x, "y": y}, "to": {"x": x + dx, "y": y + dy}, "steps": 6})); + self.frames(2); + } + fn has(&mut self, id: &str) -> bool { + let v = self.ok("ui.elements", json!({"prefix": id})); + v.as_array().is_some_and(|a| a.iter().any(|e| e["id"] == id)) + } + fn masks(&mut self) -> Vec { + self.exec("masks.list", json!({}))["masks"].as_array().unwrap().clone() + } + fn shot(&mut self, name: &str) { + let Some(dir) = self.shots.clone() else { return }; + // let the background frame workers finish the (blurred) frame + for _ in 0..40 { + self.frames(1); + std::thread::sleep(std::time::Duration::from_millis(25)); + } + let img = self.harness.render().expect("wgpu render"); + std::fs::create_dir_all(&dir).unwrap(); + img.save(dir.join(format!("{name}.png"))).unwrap(); + } +} + +fn vertex_count(m: &Value) -> usize { + m["path"]["vertices"].as_array().map_or(0, Vec::len) +} + +#[test] +fn roundness_handle_rounds_mask_corners_in_one_undo_step() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = &seq["video"][0]["items"][0]; + let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + + // an ellipse has no sharp corner: no roundness handle + d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "ellipse"})); + d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 0})); + d.frames(3); + assert!(d.has("program.mask.vertex.0"), "the mask overlay is on the monitor"); + assert!(!d.has("program.mask.roundness"), "no roundness handle on an ellipse"); + + // a rectangle has one, just inside its top-left corner + d.exec("masks.add", json!({"clip": id, "effect": "opacity", "shape": "rectangle", "center": [960, 540], "size": [960, 540]})); + d.exec("masks.select", json!({"clip": id, "effect": "opacity", "mask": 1})); + d.frames(3); + assert!(d.has("program.mask.roundness")); + let corner = d.center("program.mask.vertex.0"); + let handle = d.center("program.mask.roundness"); + assert!(handle.0 > corner.0 && handle.1 > corner.1, "handle {handle:?} inside the top-left corner {corner:?}"); + assert_eq!(vertex_count(&d.masks()[1]), 4); + d.shot("mask-roundness-handle"); + + // a click without a drag changes nothing + let undo0 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + d.ok("ui.click", json!({"id": "program.mask.roundness"})); + d.frames(2); + assert_eq!(vertex_count(&d.masks()[1]), 4); + assert_eq!(d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(), undo0); + + // dragging inward rounds all four corners: eight vertices, one undo step + d.drag("program.mask.roundness", 30.0, 30.0); + let m = d.masks(); + assert_eq!(vertex_count(&m[1]), 8, "{}", m[1]["path"]); + let undo1 = d.exec("history.list", json!({}))["undo"].as_array().unwrap().len(); + assert_eq!(undo1, undo0 + 1, "one undo step per drag"); + // the first fillet starts below the old top-left corner (960 − 480, 540 − 270) + let p = &m[1]["path"]["vertices"][0]["p"]; + let (x, y) = (p[0].as_f64().unwrap(), p[1].as_f64().unwrap()); + assert!((x - 480.0).abs() < 1e-6 && y > 270.0 + 10.0, "fillet start ({x}, {y})"); + d.shot("mask-rounded-corners"); + // the rounded corners are no longer sharp: the handle is gone + d.frames(2); + assert!(!d.has("program.mask.roundness")); + + d.exec("edit.undo", json!({})); + d.frames(2); + assert_eq!(vertex_count(&d.masks()[1]), 4); + assert!(d.has("program.mask.roundness")); +} diff --git a/docs/gaps.md b/docs/gaps.md index 56454106..52ef93ed 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -235,13 +235,15 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking (and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles, - redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle). + redesigned 26.0 mask tools (constrained lines; a stored, re-editable corner radius: rounding + bakes fillets into the path, so a rounded corner cannot be re-rounded later). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; - #813). + #813), from an on-monitor corner-roundness handle with a live preview (one undo step per drag; + #844). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From 8e61e610cc71d51f2e81205f0e0c11c02208aa00 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 01:54:49 +0200 Subject: [PATCH 074/203] #850: launch files open whatever the extension's case, with absolute paths Problem: the desktop app picked the project among its launch files with a case-sensitive ends_with(".fcproj"), so CUT.FCPROJ opened through a Windows file association or the Linux MIME glob was imported as media. Relative launch paths were stored as given and went missing from another folder. std::env::args panicked on a non-Unicode argument. filmcraft-cli import used canonicalize, which stores \\?\ verbatim paths on Windows. Fix: args::project_and_media finds the project by a case-insensitive extension and makes every launch path absolute with std::path::absolute (no verbatim prefix, symlinks kept). Both apps read args_os; the desktop app keeps --data-dir exactly. filmcraft-cli import uses std::path::absolute. Co-Authored-By: Claude Opus 5.5 --- apps/filmcraft-cli/src/main.rs | 34 +++++++++++-- apps/filmcraft/src/args.rs | 91 +++++++++++++++++++++++++++++++--- apps/filmcraft/src/main.rs | 5 +- docs/gaps.md | 3 ++ 4 files changed, 120 insertions(+), 13 deletions(-) diff --git a/apps/filmcraft-cli/src/main.rs b/apps/filmcraft-cli/src/main.rs index 10592444..22fe121f 100644 --- a/apps/filmcraft-cli/src/main.rs +++ b/apps/filmcraft-cli/src/main.rs @@ -244,6 +244,13 @@ fn register_hardware_decoders() -> filmcraft_platform::Availability { filmcraft_platform::register() } +/// `path` made absolute against the working directory, as the project stores it. Unlike +/// `canonicalize`, `std::path::absolute` keeps symlinks and adds no Windows verbatim `\\?\` +/// prefix (#850). A path it cannot resolve is kept as given. +fn absolute(path: &str) -> String { + std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string()) +} + #[tokio::main] async fn main() { cli().await; @@ -253,7 +260,8 @@ async fn main() { } async fn cli() { - let a = Args::parse(std::env::args().skip(1)); + // `std::env::args` panics on an argument that is not Unicode (a Linux file name in Latin-1) + let a = Args::parse(std::env::args_os().skip(1).map(|a| a.to_string_lossy().into_owned())); // `--help` anywhere (`filmcraft-cli --help`, `filmcraft-cli export --help`) prints the reference: // the parser takes it as a flag option, so it never reaches the subcommand match. if a.flag("--help") { @@ -373,8 +381,7 @@ async fn cli() { print(&a, &v); } "import" => { - let paths: Vec = - a.positionals[1..].iter().map(|p| std::fs::canonicalize(p).map(|c| c.to_string_lossy().into_owned()).unwrap_or_else(|_| p.clone())).collect(); + let paths: Vec = a.positionals.get(1..).unwrap_or_default().iter().map(|p| absolute(p)).collect(); if paths.is_empty() { usage("import ..."); } @@ -590,6 +597,27 @@ mod format_tests { assert!(p.get("range").is_none()); } + /// `import` used `canonicalize`, which resolves symlinks and on Windows stores `\\?\C:\…` + /// verbatim paths in the project (#850). + #[test] + fn imported_paths_are_absolute_without_resolving_links() { + let cwd = std::env::current_dir().unwrap(); + assert_eq!(super::absolute("clip.mov"), cwd.join("clip.mov").to_string_lossy()); + assert!(!super::absolute("clip.mov").starts_with(r"\\?\")); + assert_eq!(super::absolute(""), ""); + #[cfg(unix)] + { + let dir = std::env::temp_dir().join(format!("filmcraft-cli-abs-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("real.mov"), b"x").unwrap(); + let link = dir.join("link.mov"); + let _ = std::fs::remove_file(&link); + std::os::unix::fs::symlink(dir.join("real.mov"), &link).unwrap(); + assert_eq!(super::absolute(&link.to_string_lossy()), link.to_string_lossy()); + std::fs::remove_dir_all(&dir).unwrap(); + } + } + #[test] fn export_format_from_extension() { assert_eq!(super::format_for("out/clip.MXF"), Some("mxf-op1a")); diff --git a/apps/filmcraft/src/args.rs b/apps/filmcraft/src/args.rs index 6460a968..7e5479d1 100644 --- a/apps/filmcraft/src/args.rs +++ b/apps/filmcraft/src/args.rs @@ -1,5 +1,8 @@ //! The desktop app's command line (`filmcraft --help`). +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + /// `filmcraft --help`. pub const USAGE: &str = "\ Usage: filmcraft [options] [project.fcproj | media files...] @@ -45,29 +48,35 @@ pub struct Launch { /// mistyped flag helps nobody, and starting without the control server that was asked for leaves /// a script waiting for it. That holds for the whole line, so `--version --bogus` is an error /// too, and for a port from the environment exactly as for one from `--control`. -pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { +/// +/// The arguments come from `std::env::args_os`: not every argument is Unicode (a Linux file name +/// in Latin-1), and `std::env::args` panics on one. A file name that is not is passed on lossily +/// (its import then reports it missing); `--data-dir` keeps the folder exactly. +pub fn parse(env_control_port: Option, args: impl IntoIterator) -> Result { let mut l = Launch { control_port: None, files: Vec::new(), demo: true, startup_flag: false, recover: None, data_dir: None }; let (mut help, mut version) = (false, false); let mut args = args.into_iter(); - while let Some(a) = args.next() { - match a.as_str() { + while let Some(raw) = args.next() { + let a = raw.to_string_lossy(); + match a.as_ref() { "-h" | "--help" => help = true, "--version" => version = true, "--control" => { let port = args.next().ok_or("--control needs a port number")?; + let port = port.to_string_lossy(); l.control_port = Some(port.parse().map_err(|_| format!("--control needs a port number, got `{port}`"))?); } "--demo" => (l.demo, l.startup_flag) = (true, true), "--empty" => (l.demo, l.startup_flag) = (false, true), "--recover" => l.recover = Some(true), "--no-recover" => l.recover = Some(false), - "--data-dir" => l.data_dir = Some(args.next().map(std::path::PathBuf::from).ok_or("--data-dir needs a folder")?), + "--data-dir" => l.data_dir = Some(args.next().map(PathBuf::from).ok_or("--data-dir needs a folder")?), // everything after `--` is a file, whatever it looks like - "--" => l.files.extend(args.by_ref()), + "--" => l.files.extend(args.by_ref().map(|f| f.to_string_lossy().into_owned())), // macOS adds a process serial number when it launches an app bundle _ if a.starts_with("-psn_") => {} _ if a.len() > 1 && a.starts_with('-') => return Err(format!("unknown option `{a}`")), - _ => l.files.push(a), + _ => l.files.push(a.into_owned()), } } if help { @@ -84,12 +93,34 @@ pub fn parse(env_control_port: Option, args: impl IntoIterator bool { + Path::new(path).extension().is_some_and(|e| e.eq_ignore_ascii_case("fcproj")) +} + +/// `path` made absolute against the working directory, so a project or media file named relative +/// to a terminal's folder is still found once the app runs from another one (Recent Projects, the +/// media paths saved in the project). Unlike `canonicalize`, `std::path::absolute` keeps symlinks +/// and adds no Windows verbatim `\\?\` prefix. A path it cannot resolve is kept as given. +pub fn absolute(path: &str) -> String { + std::path::absolute(path).ok().and_then(|p| p.into_os_string().into_string().ok()).unwrap_or_else(|| path.to_string()) +} + +/// The launch files as the project to open (the first one, see [`is_project`]) and the media files +/// to import (every file that is not a project), all made [`absolute`]. +pub fn project_and_media(files: &[String]) -> (Option, Vec) { + let project = files.iter().find(|f| is_project(f)).map(|f| absolute(f)); + let media = files.iter().filter(|f| !is_project(f)).map(|f| absolute(f)).collect(); + (project, media) +} + #[cfg(test)] mod tests { use super::*; fn parse_env(env: Option<&str>, args: &[&str]) -> Result { - parse(env.map(str::to_string), args.iter().map(|a| a.to_string())) + parse(env.map(str::to_string), args.iter().map(OsString::from)) } fn parse_args(args: &[&str]) -> Result { @@ -168,4 +199,50 @@ mod tests { assert_eq!(launch(&["--", "--odd name.mov", "-h"]).files, ["--odd name.mov", "-h"]); assert_eq!(launch(&["-", "-psn_0_12345"]).files, ["-"]); } + + /// `std::env::args` panics on an argument that is not Unicode, so such a file name (Latin-1 on + /// Linux) stopped the app before its window opened. + #[cfg(unix)] + #[test] + fn arguments_that_are_not_unicode_still_start_the_app() { + use std::os::unix::ffi::OsStringExt; + let dir = OsString::from_vec(b"/tmp/d\xe9".to_vec()); + let file = OsString::from_vec(b"caf\xe9.mov".to_vec()); + let Ok(Cli::Run(l)) = parse(None, [OsString::from("--data-dir"), dir.clone(), file.clone(), OsString::from("--"), file]) else { + panic!("should start the app"); + }; + assert_eq!(l.data_dir, Some(PathBuf::from(dir)), "the data folder is kept exactly"); + assert_eq!(l.files, ["caf\u{fffd}.mov", "caf\u{fffd}.mov"]); + } + + /// Windows file associations and the Linux MIME glob match `CUT.FCPROJ` too; it used to be + /// imported as a media file instead of opened (#850). + #[test] + fn a_project_is_recognised_whatever_the_case_of_its_extension() { + for p in ["cut.fcproj", "CUT.FCPROJ", "Cut.FcProj", "/edits/a b.fcproj", r"C:\Edits\CUT.FCPROJ"] { + assert!(is_project(p), "{p}"); + } + for p in ["cut.mp4", "fcproj", ".fcproj", "a.fcproj.mov", "a.fcprojx", ""] { + assert!(!is_project(p), "{p}"); + } + } + + /// Relative launch paths were opened and imported as given, so the project's media paths and + /// Recent Projects pointed nowhere once the app ran from another folder (#850). + #[test] + fn launch_files_are_made_absolute_and_split_case_insensitively() { + let cwd = std::env::current_dir().unwrap(); + let nested = Path::new("clips").join("a.mp4"); + let already = cwd.join("b.wav").to_string_lossy().into_owned(); + let files = ["Cut.FCPROJ".to_string(), nested.to_string_lossy().into_owned(), "other.fcproj".to_string(), already.clone()]; + let (project, media) = project_and_media(&files); + assert_eq!(project, Some(cwd.join("Cut.FCPROJ").to_string_lossy().into_owned())); + assert_eq!(media, [cwd.join(&nested).to_string_lossy().into_owned(), already]); + for p in media.iter().chain(&project) { + assert!(Path::new(p).is_absolute() && !p.starts_with(r"\\?\"), "{p}"); + } + assert_eq!(project_and_media(&[]), (None, vec![])); + // nothing to resolve against: kept as given rather than dropped + assert_eq!(absolute(""), ""); + } } diff --git a/apps/filmcraft/src/main.rs b/apps/filmcraft/src/main.rs index 591bc00f..38e2a6b4 100644 --- a/apps/filmcraft/src/main.rs +++ b/apps/filmcraft/src/main.rs @@ -80,7 +80,7 @@ fn main() -> eframe::Result { let logger = logging::install(); // lossy, so a value that is not Unicode is reported like any other bad port instead of ignored let env_port = std::env::var_os("FILMCRAFT_CONTROL_PORT").map(|p| p.to_string_lossy().into_owned()); - let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args().skip(1)) { + let Launch { control_port, files, demo, startup_flag, recover, data_dir } = match args::parse(env_port, std::env::args_os().skip(1)) { Ok(Cli::Run(l)) => l, Ok(Cli::Help) => { tell(args::USAGE, false); @@ -157,7 +157,7 @@ fn main() -> eframe::Result { } // voice-over recording reads the microphone through cpal session.voiceover.input = Some(Box::new(audio_in::CpalIn::new(&session.prefs.audio_hardware.device_class))); - let project = files.iter().find(|f| f.ends_with(".fcproj")).cloned(); + let (project, media) = args::project_and_media(&files); if let Some(p) = project { if let Err(e) = session.execute("file.open", json!({"path": p})) { log::error!("{e}"); @@ -179,7 +179,6 @@ fn main() -> eframe::Result { } else if demo { let _ = session.execute("file.openDemoProject", json!({})); } - let media: Vec = files.iter().filter(|f| !f.ends_with(".fcproj")).cloned().collect(); if !media.is_empty() { let _ = session.execute("file.import", json!({"paths": media})); } diff --git a/docs/gaps.md b/docs/gaps.md index 56454106..de518a7f 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -148,6 +148,9 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G9. Windows and Linux at run time +- **Have:** files given at launch (terminal, Open With, file associations) open whatever the case + of `.fcproj` and are made absolute, and arguments that are not Unicode no longer stop the start; + `filmcraft-cli import` stores no Windows `\\?\` paths (#850). - **Missing:** routine runtime testing off macOS. Linux playback stutter (#617), Wayland title bar (#433), case-sensitive extensions (#593), "Report an issue" link (#642), Windows bins cannot be left (#587), Sequence Settings won't open on Windows (#586, #351), old hardware (#491). From 1edcaa4e78f873eadd74df80018a40d271803057 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:09:49 +0200 Subject: [PATCH 075/203] =?UTF-8?q?#853:=20Shift=20constrains=20pen=20mask?= =?UTF-8?q?=20segments=20to=2045=C2=B0=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the pen mask tool ignored Shift, so straight horizontal, vertical or diagonal mask edges could not be drawn precisely (Premiere 26.0 constrains mask lines with Shift). Separately, path_from_json accepted the `{"p","in","out"}` vertex form through serde first, which silently dropped the `in`/`out` tangents, so a pen mask's dragged-out curves were committed as corners. Fix: with Shift held, a new pen point snaps onto the nearest 45° ray from the previous point (in screen space), the rubber-band preview follows, and a Shift-drag constrains the Bézier handle it pulls out. path_from_json now parses the vertex forms before falling back to the serialized MaskPath. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/masks.rs | 9 ++++-- crates/engine/src/masks_tests.rs | 19 ++++++++++++ crates/ui-egui/src/panels/masks.rs | 46 ++++++++++++++++++++++++++++-- crates/ui-egui/tests/masks_ui.rs | 44 ++++++++++++++++++++++++++++ docs/gaps.md | 5 ++-- 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/engine/src/masks.rs b/crates/engine/src/masks.rs index ffa5a5a1..37da8ac0 100644 --- a/crates/engine/src/masks.rs +++ b/crates/engine/src/masks.rs @@ -91,9 +91,12 @@ pub(crate) fn commands() -> Vec { /// A mask path from JSON: the serialized [`MaskPath`] object, an array of vertices /// (`{"p":[x,y],"in":[x,y]?,"out":[x,y]?}`), or an array of `[x, y]` / `{"x","y"}` points. pub fn path_from_json(v: &Value) -> Option { - if let Ok(p) = serde_json::from_value::(v.clone()) { - return Some(p); - } + // The vertex forms first: serde also accepts `{"p","in","out"}` vertices (the `masks.list` / + // `masks.add` form) but silently drops the unknown `in` / `out` tangents. + vertex_path(v).or_else(|| serde_json::from_value::(v.clone()).ok()) +} + +fn vertex_path(v: &Value) -> Option { let arr = v.as_array().or_else(|| v.get("vertices").and_then(Value::as_array))?; let closed = v.get("closed").and_then(Value::as_bool).unwrap_or(true); let mut vertices = Vec::new(); diff --git a/crates/engine/src/masks_tests.rs b/crates/engine/src/masks_tests.rs index 1fc069a8..c25cb468 100644 --- a/crates/engine/src/masks_tests.rs +++ b/crates/engine/src/masks_tests.rs @@ -380,3 +380,22 @@ fn rounding_an_animated_mask_rounds_every_keyframe_and_only_the_chosen_corners() assert_eq!(v.len(), 6); assert!((v[0]["p"][0].as_f64().unwrap() - 450.0).abs() < 1e-6, "{}", v[0]); } + +/// `path_to_json` → `path_from_json` keeps the Bézier tangents (they used to come back as zero, so +/// a pen mask's dragged-out curves were committed as corners; #853). +#[test] +fn path_json_round_trip_keeps_tangents() { + use filmcraft_geom::Vec2; + use filmcraft_project::mask::{MaskPath, MaskVertex}; + let path = MaskPath { + vertices: vec![ + MaskVertex { p: Vec2::new(10.0, 20.0), t_in: Vec2::new(-5.0, 0.0), t_out: Vec2::new(5.0, 0.0) }, + MaskVertex::corner(Vec2::new(80.0, 20.0)), + MaskVertex { p: Vec2::new(40.0, 90.0), t_in: Vec2::new(3.0, -4.0), t_out: Vec2::new(-3.0, 4.0) }, + ], + closed: true, + }; + assert_eq!(crate::masks::path_from_json(&crate::masks::path_to_json(&path)), Some(path.clone())); + // the serialized project form still parses + assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path)); +} diff --git a/crates/ui-egui/src/panels/masks.rs b/crates/ui-egui/src/panels/masks.rs index 5062918e..c3cd161b 100644 --- a/crates/ui-egui/src/panels/masks.rs +++ b/crates/ui-egui/src/panels/masks.rs @@ -464,6 +464,12 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 let painter = ui.painter().with_clip_rect(pic); let to_c = |p: Pos2| to_clip.apply(Vec2::new(p.x as f64, p.y as f64)); let mut d = draft.clone(); + // Shift constrains the segment from the previous point (and a dragged-out handle) to 45° steps + let shift = ui.input(|i| i.modifiers.shift); + let snap = |from: Option<&[f64; 4]>, p: Pos2| match from { + Some(q) if shift => constrain_45(sp(&to_screen, Vec2::new(q[0], q[1])), p), + _ => p, + }; let mut commit = false; let pressed = resp.drag_started() || (resp.clicked() && !ui.data(|m| m.get_temp::(egui::Id::new("mask-pen-placed")).unwrap_or(false))); if pressed && let Some(p) = resp.interact_pointer_pos() { @@ -471,7 +477,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 if d.points.len() >= 3 && near_first { commit = true; } else { - let c = to_c(p); + let c = to_c(snap(d.points.last(), p)); d.points.push([c.x, c.y, 0.0, 0.0]); ui.data_mut(|m| m.insert_temp(egui::Id::new("mask-pen-placed"), resp.drag_started())); } @@ -483,7 +489,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 && !commit && let (Some(p), Some(last)) = (resp.interact_pointer_pos(), d.points.last_mut()) { - let c = to_c(p); + let c = to_c(snap(Some(last), p)); let tan = Vec2::new(c.x - last[0], c.y - last[1]); if tan.length() > 2.0 { last[2] = tan.x; @@ -504,7 +510,7 @@ fn pen_overlay(app: &mut FilmcraftApp, ui: &mut egui::Ui, pic: Rect, frame: (u32 painter.add(egui::Shape::line(pts, Stroke::new(1.5, col))); } if let (Some(h), Some(l)) = (resp.hover_pos(), d.points.last()) { - painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), h], Stroke::new(1.0, col.gamma_multiply(0.5))); + painter.line_segment([sp(&to_screen, Vec2::new(l[0], l[1])), snap(Some(l), h)], Stroke::new(1.0, col.gamma_multiply(0.5))); } for (i, q) in d.points.iter().enumerate() { let p = sp(&to_screen, Vec2::new(q[0], q[1])); @@ -549,6 +555,19 @@ fn open_polyline(path: &MaskPath, to_screen: &Affine) -> Vec { out } +/// `to` moved onto the nearest 45° ray from `from` (horizontal, vertical or diagonal on screen), +/// keeping its distance along that ray; `to` itself when it coincides with `from` or is not finite. +fn constrain_45(from: Pos2, to: Pos2) -> Pos2 { + let v = to - from; + if !(v.x.is_finite() && v.y.is_finite()) || v.length_sq() == 0.0 { + return to; + } + let step = std::f32::consts::FRAC_PI_4; + let a = (v.y.atan2(v.x) / step).round() * step; + let dir = egui::Vec2::new(a.cos(), a.sin()); + from + dir * v.dot(dir) +} + /// The Mask Path row's value area: mask tracking (backward continuously / one frame, forward one /// frame / continuously; clicking while a track runs stops it) and the tracking-method menu. pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effect: usize, mask: Option, actions: &mut Vec<(String, Value)>) { @@ -607,3 +626,24 @@ pub fn path_value(app: &mut FilmcraftApp, ui: &mut egui::Ui, clip: ClipId, effec ui.ctx().request_repaint(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn constrain_45_snaps_to_the_nearest_eighth_turn() { + let o = pos2(10.0, 20.0); + let near = |a: Pos2, b: Pos2| (a - b).length() < 1e-3; + // nearly horizontal / vertical: the off-axis component goes + assert!(near(constrain_45(o, pos2(110.0, 30.0)), pos2(110.0, 20.0))); + assert!(near(constrain_45(o, pos2(5.0, -80.0)), pos2(10.0, -80.0))); + // nearly diagonal: projected onto the 45° ray + let d = constrain_45(o, pos2(60.0, 66.0)); + assert!(((d.x - o.x) - (d.y - o.y)).abs() < 1e-3, "{d:?}"); + // degenerate input comes back unchanged + assert_eq!(constrain_45(o, o), o); + let nan = pos2(f32::NAN, 1.0); + assert!(constrain_45(o, nan).x.is_nan()); + } +} diff --git a/crates/ui-egui/tests/masks_ui.rs b/crates/ui-egui/tests/masks_ui.rs index b958f940..08934963 100644 --- a/crates/ui-egui/tests/masks_ui.rs +++ b/crates/ui-egui/tests/masks_ui.rs @@ -216,6 +216,50 @@ fn create_and_edit_masks_on_the_monitor() { d.shot("mask-tracked"); } +/// Shift held while placing pen points snaps each new segment to 45° steps from the previous +/// point; a Shift-drag also constrains the Bézier handle it pulls out (#853). +#[test] +fn shift_constrains_pen_mask_segments_to_45_degrees() { + let mut d = Driver::demo(); + d.ok("ui.set", json!({"workspace": "Effects"})); + let seq = d.exec("sequence.inspect", json!({})); + let clip = &seq["video"][0]["items"][0]; + let (id, start, dur) = (clip["clip"].as_u64().unwrap(), clip["start"].as_i64().unwrap(), clip["duration"].as_i64().unwrap()); + d.exec("playhead.set", json!({"time": start + dur / 2})); + d.exec("timeline.select", json!({"clips": [id]})); + d.frames(3); + d.ok("ui.click", json!({"id": "effectControls.opacity.mask.pen"})); + d.frames(2); + let pic = d.rect("program.picture"); + let at = |fx: f64, fy: f64| json!({"x": pic[0] + pic[2] * fx, "y": pic[1] + pic[3] * fy}); + // hold Shift (as the OS reports a held key) across the clicks + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT)); + d.frames(1); + for (fx, fy) in [(0.3, 0.3), (0.6, 0.34), (0.63, 0.6)] { + d.ok("ui.click", at(fx, fy)); + d.frames(2); + } + // a Shift-drag places the fourth point and pulls out its handle + d.ok("ui.drag", json!({"from": at(0.32, 0.62), "to": at(0.42, 0.64), "steps": 6, "modifiers": {"shift": true}})); + d.frames(2); + d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); + d.frames(1); + d.ok("ui.click", at(0.3, 0.3)); + d.frames(3); + let m = d.masks(); + let pen = m.iter().find(|x| x["effectId"] == "opacity").unwrap_or_else(|| panic!("opacity mask: {m:?}")); + assert_eq!(pen["path"]["vertices"].as_array().unwrap().len(), 4, "{pen}"); + let (p0, p1, p2, p3) = (v0(pen, 0), v0(pen, 1), v0(pen, 2), v0(pen, 3)); + assert!((p1.1 - p0.1).abs() < 1e-6, "segment 0→1 horizontal: {p0:?} {p1:?}"); + assert!(p1.0 > p0.0 + 50.0, "{p0:?} {p1:?}"); + assert!((p2.0 - p1.0).abs() < 1e-6, "segment 1→2 vertical: {p1:?} {p2:?}"); + assert!(p2.1 > p1.1 + 50.0, "{p1:?} {p2:?}"); + assert!((p3.1 - p2.1).abs() < 1e-6, "segment 2→3 horizontal: {p2:?} {p3:?}"); + let out = &pen["path"]["vertices"][3]["out"]; + let (ox, oy) = (out[0].as_f64().unwrap(), out[1].as_f64().unwrap()); + assert!(ox > 10.0 && oy.abs() < 1e-6, "handle constrained horizontal: {pen}"); +} + #[test] fn presets_bin_drag_and_save_dialog() { let mut d = Driver::demo(); diff --git a/docs/gaps.md b/docs/gaps.md index 56454106..866ba40e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -235,13 +235,14 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** - **Missing:** Warp Stabilizer is 2-D (Premiere's is 3-D subspace), Morph Cut has no face tracking (and occlusion edges are soft), Lumetri HDR-aware maths, multicam paging beyond 16 angles, - redesigned 26.0 mask tools (constrained lines; an on-monitor corner-roundness handle). + redesigned 26.0 mask tools (Shift-constrained vertex and whole-mask drags; an on-monitor + corner-roundness handle). - **Have:** Optical Flow time interpolation synthesises motion-compensated in-between frames (dense coarse-to-fine Lucas–Kanade, CPU; #780); Morph Cut morphs the subject along that optical flow instead of cross-dissolving (CPU; #805); Transform shutter angle motion blur (#415); Ultra Key Setting applied as a parameter preset (#458); rectangle and polygon masks round their corners with circular fillets (`masks.add` `cornerRadius`, `masks.roundCorners`, every path keyframe; - #813). + #813); Shift snaps pen mask segments and dragged-out Bézier handles to 45° steps (#853). - **Estimate:** 60–110 h. ## G19. Speech to text off in release builds From b964b9970a8286b141ed081f4db904fbcc77c05e Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 11:16:50 +1100 Subject: [PATCH 076/203] #856: Reject negative times on timeline.place and markers.add Clips and markers before sequence start were accepted, then unreachable in the GUI and cut off on export. Time, frame and seconds before 0 now return a parameter error; zero remains valid. --- crates/engine/src/commands.rs | 13 ++++++++++-- crates/engine/src/sequence_extras.rs | 4 ++-- crates/engine/src/sequence_tools.rs | 6 +++--- crates/engine/src/tests.rs | 31 ++++++++++++++++++++++++++++ 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afdd..6f7d2522 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -239,6 +239,15 @@ pub(crate) fn time_p(s: &Session, p: &Value, prefix: &str) -> Option { None } +/// Sequence time from params. Times before 0 are unreachable in the GUI and cut off on export. +pub(crate) fn sequence_time_p(s: &Session, p: &Value, prefix: &str, cmd: &str) -> Result> { + let Some(t) = time_p(s, p, prefix) else { return Ok(None) }; + if t < Tick::ZERO { + return Err(bad(cmd, "time must not be negative")); + } + Ok(Some(t)) +} + pub(crate) fn clip_p(p: &Value, k: &str) -> Option { u64_p(p, k).map(ClipId) } @@ -1999,7 +2008,7 @@ fn build() -> Vec { return Ok(v); } let (_seq, ph) = marker_list_mut(s)?; - let t = time_p(s, p, "").unwrap_or(ph); + let t = sequence_time_p(s, p, "", "markers.add")?.unwrap_or(ph); let name = str_p(p, "name").unwrap_or("").to_string(); let comment = str_p(p, "comment").unwrap_or("").to_string(); let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); @@ -2200,7 +2209,7 @@ fn build() -> Vec { return Err(bad("timeline.place", "enable video, audio, or both")); } let item = item_p(p, "item").ok_or_else(|| bad("timeline.place", "need `item`"))?; - let at = time_p(s, p, "").unwrap_or(s.playhead()); + let at = sequence_time_p(s, p, "", "timeline.place")?.unwrap_or(s.playhead()); let tg = s.targeting(); let pi = s.project.item(item).ok_or_else(|| bad("timeline.place", "no such item"))?; let is_audio = |t: TrackId| s.active_sequence().is_some_and(|q| q.audio_tracks.iter().any(|x| x.id == t)); diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 389590c8..8b5d8e6d 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -20,7 +20,7 @@ use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, S use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, sequence_time_p, str_p, u64_p}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -483,7 +483,7 @@ fn show_active_only(s: &mut Session, p: &Value) -> Result { // --------------------------------------------------------------------------------------------- fn add_flash_cue(s: &mut Session, p: &Value) -> Result { - let t = s.sequence_rate().snap(time_p(s, p, "").unwrap_or(s.playhead())); + let t = s.sequence_rate().snap(sequence_time_p(s, p, "", "markers.addFlashCue")?.unwrap_or(s.playhead())); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}")); let comment = str_p(p, "comment").unwrap_or("").to_string(); diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c5795..897b9112 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -31,7 +31,7 @@ use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links}; +use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, sequence_time_p, str_p, time_p, track_p, with_links}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -206,7 +206,7 @@ fn groups() -> Vec<(&'static str, Vec)> { r#"{"time":ticks?,"name":str?,"comment":str?,"color":label?}"#, has_seq, |s, p| { - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addChapter")?.unwrap_or(s.playhead()); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}")); let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; @@ -977,7 +977,7 @@ fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind /// `duration` say otherwise) at the playhead. fn add_range_marker(s: &mut Session, p: &Value) -> Result { let rate = s.sequence_rate(); - let t = time_p(s, p, "").unwrap_or(s.playhead()); + let t = sequence_time_p(s, p, "", "markers.addRange")?.unwrap_or(s.playhead()); let dur = p .get("duration") .and_then(Value::as_i64) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9852734d..3b01ca7d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -958,3 +958,34 @@ fn enable_flips_each_selected_clip() { s.execute("clip.enable", json!({"clips": [a, b]})).unwrap(); assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true)); } + +/// Times before sequence start are unreachable in the GUI and cut off on export. +#[test] +fn negative_place_and_marker_times_are_refused() { + let mut s = demo(); + let item = s.active_sequence().unwrap().video_tracks[0].items[0].item; + let before = s.project.clone(); + let n_markers = s.active_sequence().unwrap().markers.len(); + let n_clips: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + for (cmd, params) in [ + ("timeline.place", json!({"item": item.0, "frame": -12})), + ("timeline.place", json!({"item": item.0, "seconds": -1.0})), + ("timeline.place", json!({"item": item.0, "time": -1})), + ("markers.add", json!({"frame": -10})), + ("markers.add", json!({"seconds": -0.5})), + ("markers.add", json!({"time": i64::MIN})), + ("markers.addChapter", json!({"frame": -1})), + ("markers.addRange", json!({"time": -1, "durationFrames": 10})), + ("markers.addFlashCue", json!({"frame": -3})), + ] { + let e = s.execute(cmd, params.clone()).expect_err(&format!("{cmd} {params}")); + assert!(matches!(&e, EngineError::BadParams { cmd: c, msg } if c == cmd && msg.contains("negative")), "{cmd} {params}: {e:?}"); + assert_eq!(*s.project, *before); + } + assert_eq!(s.active_sequence().unwrap().markers.len(), n_markers); + let n_clips_after: usize = s.active_sequence().unwrap().video_tracks.iter().map(|t| t.items.len()).sum(); + assert_eq!(n_clips_after, n_clips); + s.execute("timeline.place", json!({"item": item.0, "frame": 0})).unwrap(); + s.execute("markers.add", json!({"frame": 0, "name": "start"})).unwrap(); + assert!(s.active_sequence().unwrap().markers.iter().any(|m| m.start == Tick::ZERO && m.name == "start")); +} From c0f1383d6695ded3235688627a6a27fdfcc6e4ab Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:17:05 +0200 Subject: [PATCH 077/203] #857: reject unknown marker colour names instead of storing Green Problem: program-monitor markers.add (and markers.addRange, addRangeInOut, addChapter, addFlashCue) mapped `color` with Label::from_name and fell back to Green on any unknown name, so `{color:"Red"}` succeeded and stored Green; markers.edit silently ignored an unknown colour. Fix: one marker_color_p helper turns a non-label `color` into a bad-params error that lists the accepted label names; every marker command (and the Source monitor path, which already rejected it) uses it. Absent or null still means the default. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands.rs | 17 +++++++++++++++-- crates/engine/src/sequence_extras.rs | 4 ++-- crates/engine/src/sequence_tools.rs | 12 ++++++------ crates/engine/src/sequence_tools_tests.rs | 21 +++++++++++++++++++++ crates/engine/src/source_monitor.rs | 2 +- 5 files changed, 45 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afdd..6d99827d 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -185,6 +185,18 @@ pub(crate) fn named_items(s: &Session, spec: &CommandSpec, p: &Value) -> Option< pub(crate) fn bad(cmd: &str, msg: impl Into) -> EngineError { EngineError::BadParams { cmd: cmd.into(), msg: msg.into() } } +/// A marker's `color` param: absent (or null) is `None`; anything but a label name is an error +/// that lists the accepted names, instead of silently becoming Green (#857). +pub(crate) fn marker_color_p(p: &Value, cmd: &str) -> Result> { + let v = match p.get("color") { + None | Some(Value::Null) => return Ok(None), + Some(v) => v, + }; + v.as_str().and_then(Label::from_name).map(Some).ok_or_else(|| { + let names: Vec<&str> = Label::ALL.iter().map(|l| l.name()).collect(); + bad(cmd, format!("unknown marker color {v}; use one of: {}", names.join(", "))) + }) +} pub(crate) fn str_p<'a>(p: &'a Value, k: &str) -> Option<&'a str> { p.get(k).and_then(Value::as_str) } @@ -2002,7 +2014,7 @@ fn build() -> Vec { let t = time_p(s, p, "").unwrap_or(ph); let name = str_p(p, "name").unwrap_or("").to_string(); let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, "markers.add")?.unwrap_or(Label::Green); let dur = p.get("durationFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)).unwrap_or_default(); let id = s.edit_sequence("Add Marker", |q, ctx, _| { let id = MarkerId(ctx.alloc()); @@ -2078,6 +2090,7 @@ fn build() -> Vec { } let id = u64_p(p, "marker").map(MarkerId).ok_or_else(|| bad("markers.edit", "need `marker`"))?; let rate = s.sequence_rate(); + let color = marker_color_p(p, "markers.edit")?; let p = p.clone(); s.edit_sequence("Edit Marker", |q, _, _| { let m = q.markers.iter_mut().find(|m| m.id == id).ok_or_else(|| bad("markers.edit", "no such marker"))?; @@ -2087,7 +2100,7 @@ fn build() -> Vec { if let Some(c) = str_p(&p, "comment") { m.comment = c.into(); } - if let Some(c) = str_p(&p, "color").and_then(Label::from_name) { + if let Some(c) = color { m.color = c; } if let Some(f) = p.get("durationFrames").and_then(Value::as_i64) { diff --git a/crates/engine/src/sequence_extras.rs b/crates/engine/src/sequence_extras.rs index 389590c8..c6787010 100644 --- a/crates/engine/src/sequence_extras.rs +++ b/crates/engine/src/sequence_extras.rs @@ -20,7 +20,7 @@ use filmcraft_project::{ClipId, ItemKind, Label, Marker, MarkerId, MarkerKind, S use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, str_p, time_p, u64_p}; +use crate::commands::{CommandSpec, bad, bool_p, f64_p, has_seq, marker_color_p, str_p, time_p, u64_p}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -487,7 +487,7 @@ fn add_flash_cue(s: &mut Session, p: &Value) -> Result { let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::FlashCue).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Cue Point {n}")); let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, "markers.addFlashCue")?.unwrap_or(Label::Green); let dur = u64_p(p, "durationFrames").map(|f| s.sequence_rate().tick_of(f as i64)).unwrap_or(Tick::ZERO); let id = s.edit_sequence("Add Flash Cue Marker", |q, ctx, _| { let id = MarkerId(ctx.alloc()); diff --git a/crates/engine/src/sequence_tools.rs b/crates/engine/src/sequence_tools.rs index dc6c5795..c3dd3db0 100644 --- a/crates/engine/src/sequence_tools.rs +++ b/crates/engine/src/sequence_tools.rs @@ -31,7 +31,7 @@ use filmcraft_project::{AudioChannels, ClipId, ItemId, Label, Marker, MarkerId, use filmcraft_time::{Tick, TimeRange}; use serde_json::{Value, json}; -use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, str_p, time_p, track_p, with_links}; +use crate::commands::{CommandSpec, always, bad, bool_p, clips_p, has_seq, marker_color_p, str_p, time_p, track_p, with_links}; use crate::{EngineError, Result, Session}; type Run = fn(&mut Session, &Value) -> Result; @@ -209,7 +209,7 @@ fn groups() -> Vec<(&'static str, Vec)> { let t = time_p(s, p, "").unwrap_or(s.playhead()); let n = s.active_sequence().map(|q| q.markers.iter().filter(|m| m.kind == MarkerKind::Chapter).count()).unwrap_or(0) + 1; let name = str_p(p, "name").map(str::to_string).unwrap_or_else(|| format!("Chapter {n}")); - let id = add_marker(s, "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; + let id = add_marker(s, "markers.addChapter", "Add Chapter Marker", t, Tick::ZERO, MarkerKind::Chapter, name, p)?; Ok(json!({"marker": id.0})) }, ), @@ -962,9 +962,9 @@ pub fn ripple_markers(markers: &mut Vec, from: Tick, shift: Tick) { markers.sort_by_key(|m| m.start); } -fn add_marker(s: &mut Session, label: &str, t: Tick, dur: Tick, kind: MarkerKind, name: String, p: &Value) -> Result { +fn add_marker(s: &mut Session, cmd: &str, label: &str, t: Tick, dur: Tick, kind: MarkerKind, name: String, p: &Value) -> Result { let comment = str_p(p, "comment").unwrap_or("").to_string(); - let color = str_p(p, "color").and_then(Label::from_name).unwrap_or(Label::Green); + let color = marker_color_p(p, cmd)?.unwrap_or(Label::Green); s.edit_sequence(label, |q, ctx, _| { let id = MarkerId(ctx.alloc()); q.markers.push(Marker { id, start: t, duration: dur, name, comment, kind, color }); @@ -988,7 +988,7 @@ fn add_range_marker(s: &mut Session, p: &Value) -> Result { return Err(bad("markers.addRange", "the duration must be positive")); } let name = str_p(p, "name").unwrap_or("").to_string(); - let id = add_marker(s, "Add Range Marker", t, dur, MarkerKind::Comment, name, p)?; + let id = add_marker(s, "markers.addRange", "Add Range Marker", t, dur, MarkerKind::Comment, name, p)?; Ok(json!({"marker": id.0})) } @@ -998,7 +998,7 @@ fn add_range_marker_in_out(s: &mut Session, p: &Value) -> Result { let (Some(i), Some(o)) = (q.mark_in, q.mark_out) else { return Err(EngineError::Other("mark an In and an Out point first".into())) }; let dur = o + q.settings.frame_rate.frame_duration() - i; let name = str_p(p, "name").unwrap_or("").to_string(); - let id = add_marker(s, "Add Range Marker", i, dur, MarkerKind::Comment, name, p)?; + let id = add_marker(s, "markers.addRangeInOut", "Add Range Marker", i, dur, MarkerKind::Comment, name, p)?; Ok(json!({"marker": id.0})) } diff --git a/crates/engine/src/sequence_tools_tests.rs b/crates/engine/src/sequence_tools_tests.rs index d275f9b3..81762344 100644 --- a/crates/engine/src/sequence_tools_tests.rs +++ b/crates/engine/src/sequence_tools_tests.rs @@ -476,6 +476,27 @@ fn range_and_chapter_markers() { assert!(s.active_sequence().unwrap().markers.is_empty()); } +#[test] +fn unknown_marker_colours_are_rejected_not_stored_as_green() { + // #857: `markers.add {color:"Red"}` used to succeed and store Green + let mut s = demo(); + let before = s.project.to_json(); + for cmd in ["markers.add", "markers.addRange", "markers.addChapter", "markers.addFlashCue"] { + for color in [json!("Red"), json!("NotAColor"), json!(3)] { + let err = s.execute(cmd, json!({"frame": 10, "color": color})).unwrap_err().to_string(); + assert!(err.contains("Rose"), "{cmd}: the error lists the accepted names: {err}"); + assert_eq!(s.project.to_json(), before, "{cmd} {color} must not add a marker"); + } + } + let id = s.execute("markers.add", json!({"frame": 10, "color": "rose"})).unwrap()["marker"].as_u64().unwrap(); + let colour = |s: &Session| s.active_sequence().unwrap().markers.iter().find(|m| m.id.0 == id).unwrap().color; + assert_eq!(colour(&s), Label::Rose); + assert!(s.execute("markers.edit", json!({"marker": id, "color": "Red"})).is_err()); + assert_eq!(colour(&s), Label::Rose, "a bad colour leaves the marker unchanged"); + s.execute("markers.edit", json!({"marker": id, "color": "Teal"})).unwrap(); + assert_eq!(colour(&s), Label::Teal); +} + #[test] fn ripple_sequence_markers_follow_ripple_edits() { let mut s = demo(); diff --git a/crates/engine/src/source_monitor.rs b/crates/engine/src/source_monitor.rs index 2e7ec745..af97205c 100644 --- a/crates/engine/src/source_monitor.rs +++ b/crates/engine/src/source_monitor.rs @@ -274,7 +274,7 @@ fn text<'a>(p: &'a Value, key: &str, id: &str) -> Result> { p.get(key).map(|v| v.as_str().ok_or_else(|| bad(id, format!("{key} must be text")))).transpose() } fn color(p: &Value, id: &str) -> Result> { - text(p, "color", id)?.map(|t| Label::from_name(t).ok_or_else(|| bad(id, "unknown marker color"))).transpose() + crate::commands::marker_color_p(p, id) } fn duration(p: &Value, view: &crate::clip_ops::SourceView, id: &str) -> Result { let Some(v) = p.get("durationFrames") else { return Ok(Tick::ZERO) }; From 111991591b9f6a185c7cd759745243ad6170e9b4 Mon Sep 17 00:00:00 2001 From: Vikram Bhaskaran Date: Sun, 11 Oct 2026 11:17:47 +1100 Subject: [PATCH 078/203] Roll linked audio with the picture cut (#855). timeline.roll follows linked partners at the same through-edit when linked selection is on, so a +12 frame roll on V1 also moves A1. The command reports linked:false when the cut is not followed as a pair. Reversed clips keep the unmoved-edge media mapping (#526/#558). --- crates/engine/src/commands.rs | 63 ++++++++++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/roll_linked_tests.rs | 86 ++++++++++++++++++++++++++ crates/ui-egui/src/panels/timeline.rs | 13 ++-- 4 files changed, 157 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/roll_linked_tests.rs diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs index 6609afdd..2e76582f 100644 --- a/crates/engine/src/commands.rs +++ b/crates/engine/src/commands.rs @@ -411,6 +411,47 @@ pub(crate) fn slip_together(q: &mut filmcraft_project::Sequence, ctx: &mut edit: Ok(()) } +/// Adjacent roll pairs that follow `left`/`right` when linked selection is on: the given cut, plus +/// through-edits between the same pieces' linked partners at that same time (Premiere linked +/// selection). Split edits (audio cut at another time) and unlinked clips are not added. +pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right: ClipId, follow_links: bool) -> Vec<(ClipId, ClipId)> { + let mut pairs = vec![(left, right)]; + if !follow_links { + return pairs; + } + let Some((_, l)) = seq.find_item(left) else { + return pairs; + }; + let Some((_, r)) = seq.find_item(right) else { + return pairs; + }; + let cut = l.end(); + let left_link = l.link; + let right_link = r.link; + if left_link.is_none() && right_link.is_none() { + return pairs; + } + for tr in seq.all_tracks() { + if tr.locked { + continue; + } + for w in tr.items.windows(2) { + if w[0].id == left && w[1].id == right { + continue; + } + if w[0].end() != w[1].start || w[0].end() != cut { + continue; + } + let left_ok = left_link.is_some_and(|g| w[0].link == Some(g)); + let right_ok = right_link.is_some_and(|g| w[1].link == Some(g)); + if left_ok && right_ok { + pairs.push((w[0].id, w[1].id)); + } + } + } + pairs +} + /// Expand a clip selection with linked partners (when linked selection is on). pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec { let Some(seq) = s.active_sequence() else { return clips.to_vec() }; @@ -2412,8 +2453,26 @@ fn build() -> Vec { .map(Tick) .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f))) .unwrap_or_default(); - let x = s.edit_sequence("Rolling Edit", |q, ctx, _| Ok(edit::roll(q, l, r, d, ctx)?))?; - Ok(json!({"delta": x.0})) + let follow = s.state.linked_selection; + let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| { + let pairs = linked_roll_pairs(q, l, r, follow); + let linked = pairs.len() > 1; + let mut d = d; + for &(pl, pr) in &pairs { + let mut probe = q.clone(); + let x = edit::roll(&mut probe, pl, pr, d, ctx)?; + if x.abs() < d.abs() { + d = x; + } + } + if d != Tick::ZERO { + for &(pl, pr) in &pairs { + edit::roll(q, pl, pr, d, ctx)?; + } + } + Ok((d, pairs.len(), linked)) + })?; + Ok(json!({"delta": x.0, "pairs": n, "linked": linked})) }), cmd!("timeline.slip", "Slip", [], None, r#"{"clip":id,"delta":ticks|"deltaFrames":i64}"#, has_seq, |s, p| { let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.slip", "need `clip`"))?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index a2ed9e6a..2d9a8426 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1156,6 +1156,8 @@ mod remix_tests; #[cfg(test)] mod ripple_delete_tests; #[cfg(test)] +mod roll_linked_tests; +#[cfg(test)] mod scopes_tests; #[cfg(test)] mod sequence_inspect_tests; diff --git a/crates/engine/src/roll_linked_tests.rs b/crates/engine/src/roll_linked_tests.rs new file mode 100644 index 00000000..dd3f2e4b --- /dev/null +++ b/crates/engine/src/roll_linked_tests.rs @@ -0,0 +1,86 @@ +//! #855: a rolling edit on a linked A/V cut must move the sound cut with the picture. + +use filmcraft_project::ClipId; +use filmcraft_time::Tick; +use serde_json::json; + +use crate::Session; + +fn range(s: &Session, c: ClipId) -> (Tick, Tick) { + s.active_sequence().unwrap().find_item(c).map(|(_, i)| (i.start, i.end())).unwrap() +} + +fn source_in(s: &Session, c: ClipId) -> Tick { + s.active_sequence().unwrap().find_item(c).unwrap().1.source_in +} + +/// Two consecutive 48-frame A/V clips (picture on V1, linked sound on A1). +fn two_clips(s: &mut Session) -> [(ClipId, ClipId); 2] { + s.execute("file.openDemoProject", json!({})).unwrap(); + s.execute("file.newSequence", json!({"name": "Roll", "video": 1, "audio": 1})).unwrap(); + let rate = s.sequence_rate(); + let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).map(|i| i.id).unwrap(); + for n in 0..2 { + s.execute("timeline.place", json!({"item": item.0, "frame": 48 * n, "sourceIn": rate.tick_of(48).0, "duration": rate.tick_of(48).0})).unwrap(); + } + let q = s.active_sequence().unwrap(); + [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)) +} + +/// #855: rolling the V1 cut +12 frames used to leave A1, so export played the second clip's audio +/// under the first clip's picture. +#[test] +fn roll_moves_linked_audio_cut() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + assert!(s.state.linked_selection); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(true), "{r}"); + assert_eq!(r["pairs"], json!(2), "{r}"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(60)), "linked sound must roll with the picture"); + assert_eq!(range(&s, a1), (f(60), f(96))); +} + +/// Linked selection off (or unlinked clips) rolls only the given pair and says so. +#[test] +fn roll_without_linked_selection_leaves_audio() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("sequence.linkedSelection", json!({"on": false})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let r = s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(r["linked"], json!(false), "{r}: clips were not followed as a linked pair"); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a0), (f(0), f(48)), "unlinked A1 cut stays"); + assert_eq!(range(&s, a1), (f(48), f(96))); +} + +/// #526/#558: reversed linked clips still keep the media on the unmoved timeline edges. +#[test] +fn roll_linked_reversed_clips_keeps_media_mapping() { + let mut s = Session::default(); + let [(v0, a0), (v1, a1)] = two_clips(&mut s); + s.execute("timeline.select", json!({"clips": [v0.0, a0.0, v1.0, a1.0]})).unwrap(); + s.execute("clip.speedDuration", json!({"speed": 100, "reverse": true})).unwrap(); + let rate = s.sequence_rate(); + let f = |n: i64| rate.tick_of(n); + let left_in = source_in(&s, v0); + let right_in = source_in(&s, v1); + s.execute("timeline.roll", json!({"left": v0.0, "right": v1.0, "deltaFrames": 12})).unwrap(); + assert_eq!(range(&s, v0), (f(0), f(60))); + assert_eq!(range(&s, a0), (f(0), f(60))); + assert_eq!(range(&s, v1), (f(60), f(96))); + assert_eq!(range(&s, a1), (f(60), f(96))); + // reversed left: source_in moves so the clip's In still shows the same media + assert_eq!(source_in(&s, v0), left_in - f(12)); + assert_eq!(source_in(&s, a0), left_in - f(12)); + // reversed right: source_in stays so the clip's Out still shows the same media + assert_eq!(source_in(&s, v1), right_in); + assert_eq!(source_in(&s, a1), right_in); +} diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 70cde2b6..a2e3dc87 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1680,11 +1680,14 @@ fn preview_drag(app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap { - if let Some((_, l)) = seq.find_item(*left) { - moved(l, filmcraft_edit::Edge::Out, *delta); - } - if let Some((_, r)) = seq.find_item(*right) { - moved(r, filmcraft_edit::Edge::In, *delta); + let pairs = filmcraft_engine::commands::linked_roll_pairs(seq, *left, *right, app.session.state.linked_selection); + for (pl, pr) in pairs { + if let Some((_, l)) = seq.find_item(pl) { + moved(l, filmcraft_edit::Edge::Out, *delta); + } + if let Some((_, r)) = seq.find_item(pr) { + moved(r, filmcraft_edit::Edge::In, *delta); + } } } Drag::Slide { clip, delta } => { From c6d47e20f0ad5ec0737b4ad8cc5492f1d618df7a Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:17:58 +0200 Subject: [PATCH 079/203] #859: complete the Brazilian Portuguese (pt-br) interface translation Problem: the Portuguese (Brazil) catalog, one of Premiere's interface languages and one of the 12 key languages in docs/gaps.md G14, covered only the menus (348 of ~3,540 strings); panels, dialogs, Settings pages, effect names, the Keyboard Shortcuts dialog and status messages showed in English. Fix: pt-br.tsv now has every entry Spanish has plus the native macOS Settings item, written from the plain meaning of the English text with ordinary Brazilian video-editing vocabulary (infinitive commands, polite imperative instructions, sentence case); the header records the conventions and a term glossary. Like Japanese and French, tests require only the menus (now every menu label, not just the core set), so later strings show in English until translated; a Portuguese entry without a Spanish one fails, and a new test checks the bundled fonts cover every Portuguese letter. The core-menu set that Ukrainian, German, Russian and French must cover moves from pt-br.tsv (no longer menus-only) to the menus-only Ukrainian catalog, unchanged in content for those tests. Attribution sidecar and ATTRIBUTION.md entry added. Docs: G14, localization-parity, ROADMAP language row, ui-egui README. Co-Authored-By: Claude Opus 5.5 --- ATTRIBUTION.md | 1 + ROADMAP.md | 2 +- crates/ui-egui/README.md | 6 +- crates/ui-egui/src/i18n/mod.rs | 91 +- crates/ui-egui/src/i18n/pt-br.tsv | 3219 ++++++++++++++++- crates/ui-egui/src/i18n/pt-br.tsv.attribution | 8 + docs/gaps.md | 6 +- docs/localization-parity.md | 13 +- 8 files changed, 3304 insertions(+), 42 deletions(-) create mode 100644 crates/ui-egui/src/i18n/pt-br.tsv.attribution diff --git a/ATTRIBUTION.md b/ATTRIBUTION.md index 0c1793ed..9285a6b9 100644 --- a/ATTRIBUTION.md +++ b/ATTRIBUTION.md @@ -10,6 +10,7 @@ iconography, images or other Adobe assets; open licences only; every asset attri | Asset | Author | Source | Licence | |---|---|---|---| | `crates/ui-egui/src/i18n/fr.tsv` | FilmCraft contributors | Original work: clean-room French interface translations | MIT OR Apache-2.0 | +| `crates/ui-egui/src/i18n/pt-br.tsv` | FilmCraft contributors | Original work: clean-room Brazilian Portuguese interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/ru.tsv` | FilmCraft contributors | Original work: clean-room Russian interface translations | MIT OR Apache-2.0 | | `crates/ui-egui/src/i18n/uk.tsv` | FilmCraft contributors | Original work: clean-room Ukrainian interface translations | MIT OR Apache-2.0 | | `assets/fonts/Inter-Regular.ttf` | The Inter Project Authors | https://github.com/rsms/inter | OFL-1.1 (`assets/fonts/OFL-Inter.txt`) | diff --git a/ROADMAP.md b/ROADMAP.md index 9566bc59..4d9726b7 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -79,7 +79,7 @@ Premiere ships 10 interface languages. Measured from our catalogs (3,498 strings | Hindi | none (no Devanagari shaping in the interface) | 0% | | Arabic | none (no RTL interface) | 0% | | French | partial (whole interface; native review welcome) | ~100% | -| Portuguese (Brazil) | menus only | 9.9% | +| Portuguese (Brazil) | partial (whole interface; native review welcome) | ~100% | | Indonesian | none | 0% | | Japanese | partial (whole interface) | 99.7% | | German | none | 0% | diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md index 69ccdc1e..a95914ea 100644 --- a/crates/ui-egui/README.md +++ b/crates/ui-egui/README.md @@ -32,11 +32,11 @@ fills translated templates, and `i18n::t` translates names from registries. Plac (including user filenames containing braces) are inserted literally. Catalog translations are original work using ordinary language, without proprietary localisation resources. -Spanish, Japanese, Simplified Chinese and French cover menus, panels, dialogs, settings and registry labels. Searches accept both the +Spanish, Japanese, Simplified Chinese, French and Brazilian Portuguese cover menus, panels, dialogs, settings and registry labels. Searches accept both the translated label and its English source, including Unicode capitals. Project content, command ids and preference values retain their original values. Engine errors, CLI and MCP messages remain -English. Brazilian Portuguese and Ukrainian currently cover the core menus, German every menu; they -fall back to English elsewhere. +English. Ukrainian and Russian currently cover the core menus, German every menu; they fall back +to English elsewhere. Verification: `cargo test -p filmcraft-ui-egui` checks catalog syntax, duplicate keys, placeholders, literal/menu/registry coverage and UI behaviour; `cargo xtask ci` runs the workspace gates. Visual diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs index 7225b5c9..8dae92f1 100644 --- a/crates/ui-egui/src/i18n/mod.rs +++ b/crates/ui-egui/src/i18n/mod.rs @@ -425,19 +425,62 @@ mod tests { assert!(missing.is_empty(), "untranslated menu labels: {missing:#?}"); } - /// The Brazilian Portuguese catalog covers the menus; every entry must still be a menu label - /// (a renamed command would otherwise leave a dead entry and an untranslated menu item). + /// The core menus every other catalog must cover: the entries of the menus-only Ukrainian + /// catalog, which `ukrainian_entries_cover_the_original_menu_catalog` keeps to menu labels (and + /// the native macOS Settings menu). Brazilian Portuguese played this role while it was + /// menus-only. + fn core_menu_labels() -> Vec { + let (entries, _) = catalog::parse_entries(include_str!("uk.tsv")); + entries.into_iter().map(|(_, en, _)| en).collect() + } + + /// Brazilian Portuguese covers the whole interface (the Spanish catalog's entries and the native + /// macOS Settings menu), but, like Japanese and French, the tests require only the menus, so + /// strings added later show in English until they are translated. Every entry must still have a + /// Spanish counterpart: a renamed or removed source would otherwise leave a dead entry. #[test] - fn portuguese_entries_are_menu_labels() { - let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); - let items = crate::menus::menu_items(&app); - let known = |text: &str| crate::menus::MENUS.contains(&text) || items.iter().any(|it| it.label == text || it.path.iter().any(|p| p == text)); - let (entries, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - assert!(!entries.is_empty()); - for (_, en, _) in entries { - assert!(known(&en), "not a menu label: {en}"); + fn portuguese_covers_the_menus_and_has_no_dead_entries() { + let (entries, errors) = catalog::parse_entries(include_str!("pt-br.tsv")); + assert!(errors.is_empty(), "{errors:?}"); + for (ctx, en, _) in &entries { + assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "Portuguese entry without a Spanish one: {ctx:?} {en:?}"); } - assert_eq!(Language::PtBr.tr("File"), "Arquivo"); + let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); + let mut missing: Vec = Vec::new(); + let mut need = |text: &str| { + if translatable(text) && Language::Es.has(text) && !Language::PtBr.has(text) && !missing.iter().any(|m| m == text) { + missing.push(text.to_string()); + } + }; + for top in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { + need(top); + } + for it in crate::menus::menu_items(&app) { + need(&it.label); + for p in &it.path { + need(p); + } + } + for en in core_menu_labels() { + need(&en); + } + missing.sort(); + assert!(missing.is_empty(), "untranslated Portuguese menu labels: {missing:#?}"); + // beyond the menus: panels, effects and their parameters, settings, status messages + for (en, pt) in [ + ("File", "Arquivo"), + ("Settings", "Preferências"), + ("Gaussian Blur", "Desfoque gaussiano"), + ("Audio Track Mixer", "Misturador de faixas de áudio"), + ("Keyboard Shortcuts", "Atalhos de teclado"), + ("Playhead", "Cursor de reprodução"), + ] { + assert_eq!(Language::PtBr.tr(en), pt); + } + set_current(Language::PtBr); + assert!(matches_query("Gaussian Blur", "desfoque")); + assert!(matches_query("Gaussian Blur", "gaussian")); + set_current(Language::En); } /// Every `tl!`/`tlf!` literal outside test modules has a Spanish translation. @@ -921,17 +964,13 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { - assert!(Language::Uk.has(&en), "missing Ukrainian menu label: {en}"); - } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { assert_ne!(Language::Uk.tr(en), en, "untranslated Ukrainian menu: {en}"); } } - /// German covers at least the core menus (the Brazilian Portuguese entries, the top-level menus - /// and the native macOS Settings menu); every entry must still be a menu label. + /// German covers at least the core menus (`core_menu_labels`, the top-level menus and the native + /// macOS Settings menu); every entry must still be a menu label. #[test] fn german_entries_cover_the_original_menu_catalog() { let app = crate::FilmcraftApp::new(filmcraft_engine::Session::default()); @@ -941,8 +980,7 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::De.has(&en), "missing German menu label: {en}"); } // "Clip" and "Audio" are the same word in German, so check for an entry, not a change @@ -963,8 +1001,7 @@ mod tests { for (ctx, en, _) in &entries { assert!(ctx.is_empty() && (Language::Es.has(en) || en == "Settings"), "French entry without a Spanish one: {ctx:?} {en:?}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::Fr.has(&en), "missing French menu label: {en}"); } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { @@ -991,8 +1028,7 @@ mod tests { for (_, en, _) in entries { assert!(known(&en) || en == "Settings", "not a menu label: {en}"); } - let (portuguese, _) = catalog::parse_entries(include_str!("pt-br.tsv")); - for (_, en, _) in portuguese { + for en in core_menu_labels() { assert!(Language::Ru.has(&en), "missing Russian menu label: {en}"); } for en in crate::menus::MENUS.into_iter().chain(["Audio", "Settings"]) { @@ -1033,6 +1069,15 @@ mod tests { assert_bundled_fonts_cover(alphabet.chars().chain(Language::Uk.name().chars()).chain(entries.iter().flat_map(|(_, _, uk)| uk.chars()))); } + #[test] + fn portuguese_renders_with_bundled_fonts() { + let (entries, _) = catalog::parse_entries(include_str!("pt-br.tsv")); + // every Portuguese letter, even ones no entry uses yet, and every letter the entries use + let alphabet = "ÁÂÃÀÇÉÊÍÓÔÕÚÜáâãàçéêíóôõúü"; + let letters = entries.iter().flat_map(|(_, _, pt)| pt.chars()).filter(|ch| ch.is_alphabetic()); + assert_bundled_fonts_cover(alphabet.chars().chain(Language::PtBr.name().chars()).chain(letters)); + } + #[test] fn french_renders_with_bundled_fonts() { let (entries, _) = catalog::parse_entries(include_str!("fr.tsv")); diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index fc7b9fd5..283ce9c5 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -1,9 +1,22 @@ # Brazilian Portuguese (pt-br) catalog. Same format as `es.tsv`: context source translation. # -# Clean-room: every entry was written from the plain meaning of the English label using ordinary -# Brazilian Portuguese computing vocabulary. No proprietary localisation resource, string table or -# product UI was consulted or copied. This catalog covers the menus; anything else is shown in -# English. +# Clean-room: every entry was written from the plain meaning of the English text using ordinary +# Brazilian Portuguese video-editing vocabulary. No proprietary localisation resource, string table +# or product UI was consulted or copied; add entries the same way. Commands use the infinitive +# ("Salvar como…"), instructions use the polite imperative ("Selecione um clipe…"), and labels use +# sentence case. Product, format and technology names (FilmCraft, ArtCraft, Lumetri, ProRes, LUFS, +# EDL, AAF…) keep their spelling; decimals in prose use a comma ("29,97 fps"). This catalog has the +# same entries as `es.tsv` plus the native macOS Settings menu; strings added later show in English +# until they are translated. A native-speaker review is welcome. +# +# Terms: clip = clipe, track = faixa, sequence = sequência, timeline = linha do tempo, bin = pasta, +# playhead = cursor de reprodução, In/Out = entrada / saída, marker = marcador, keyframe = +# quadro-chave, frame = quadro, frame hold = quadro congelado, edit point = ponto de edição, panel = +# painel, workspace = área de trabalho, render = renderizar, preset = predefinição, captions = +# legendas, voice-over = narração, trim = aparar (aparagem), ripple = em cascata, roll = rolante, +# slip / slide = deslizar conteúdo / deslizar clipe, overwrite = sobrescrever, wipe = cortina, +# dissolve = dissolução, scopes = escopos, highlights = realces, offline = offline, legacy = legado, +# preferences (also the native macOS Settings menu) = Preferências, settings = configurações. About FilmCraft Sobre o FilmCraft Add Audio Submix Track Adicionar faixa de submixagem de áudio @@ -86,7 +99,6 @@ Create Multi-Camera Source Sequence… Criar sequência de origem multicâmera… Create Proxies… Criar proxies… Cut Recortar - Dark Escuro Darkest Mais escuro Delete Render Files Excluir arquivos de renderização Delete Render Files In to Out Excluir arquivos de renderização da entrada à saída @@ -184,6 +196,7 @@ Light Claro Link Vincular Link Media… Vincular mídia… + Refresh Changed Media Atualizar mídia alterada Linked Selection Seleção vinculada Lock Guides Bloquear guias Lumetri Color Cor Lumetri @@ -204,6 +217,7 @@ Media Browser Navegador de mídia Media Cache… Cache de mídia… Media… Mídia… + Medium Médio Memory… Memória… Merge Clips… Mesclar clipes… Metadata Metadados @@ -352,7 +366,3200 @@ Zoom In Ampliar Zoom Out Reduzir Zoom to Sequence Zoom para a sequência + Home Início + Import Importar + {name} - Edited {name} - Editado + Full screen Tela cheia + Volume Volume + Master volume: use the Audio Track Mixer Volume principal: use o misturador de faixas de áudio + Search Pesquisar + Quick Export Exportação rápida + Join the ArtCraft Discord (discord.gg/artcraft) Entre no Discord do ArtCraft (discord.gg/artcraft) + Cancel Cancelar + OK OK + Interpret Footage Interpretar clipes + Color Management · {n} item Gerenciamento de cores · {n} item + Color Management · {n} items Gerenciamento de cores · {n} itens + Media colour space (from file metadata): {detected} Espaço de cores da mídia (dos metadados do arquivo): {detected} + Override media colour space: Substituir o espaço de cores da mídia: + Use file metadata Usar os metadados do arquivo + Sequence Color Management Gerenciamento de cores da sequência + Working Color Space Espaço de cores de trabalho + Wide gamut color (composite in BT.2020) Cor de gama ampla (compor em BT.2020) + Auto tone map media (HDR and log into SDR) Mapeamento de tons automático da mídia (HDR e log para SDR) + Audio Gain Ganho de áudio + Set Gain to: Definir ganho como: + Adjust Gain by: Ajustar ganho em: + Normalize Max Peak to: Normalizar pico máximo para: + Normalize All Peaks to: Normalizar todos os picos para: + Peak Amplitude: {peak} Amplitude de pico: {peak} + Standard Padrão + Adaptive Adaptável + Mono Mono + Stereo Estéreo + Add Tracks Adicionar faixas + Amount Quantidade + Placement Posicionamento + Track type Tipo de faixa + Add video tracks Adicionar faixas de vídeo + Add audio tracks Adicionar faixas de áudio + Add audio submix tracks Adicionar faixas de submixagem de áudio + After {name} Depois de {name} + Before First Track Antes da primeira faixa + Delete Tracks Excluir faixas + Video Tracks Faixas de vídeo + Audio Tracks Faixas de áudio + Delete Video Tracks Excluir faixas de vídeo + Delete Audio Tracks Excluir faixas de áudio + All Empty Tracks Todas as faixas vazias + Caption Tracks Faixas de legendas + Delete Caption Tracks Excluir faixas de legendas + Delete Caption Track Excluir faixa de legendas + Delete Empty Caption Tracks Excluir faixas de legendas vazias + Version {version} Versão {version} + A clean-room, pure-Rust non-linear video editor. Part of the ArtCraft family. Um editor de vídeo não linear em Rust puro, desenvolvido em sala limpa. Parte da família ArtCraft. + Join the ArtCraft Discord Entre no Discord do ArtCraft + Source code on GitHub Código-fonte no GitHub + Report an issue Relatar um problema + MIT OR Apache-2.0. Fonts: Inter, Noto Serif and JetBrains Mono (SIL OFL 1.1). MIT OR Apache-2.0. Fontes: Inter, Noto Serif e JetBrains Mono (SIL OFL 1.1). + just now agora mesmo + {n} s ago há {n} s + {n} min ago há {n} min + {n} h ago há {n} h + All Events Todos os eventos + Warnings and Errors Avisos e erros + Errors Only Somente erros + Clear All Limpar tudo + No events. Nenhum evento. + Show finished jobs Mostrar tarefas concluídas + Cancelled Cancelada + Failed: {e} Falha: {e} + Done Concluída + No background jobs. Nenhuma tarefa em segundo plano. + Recover Unsaved Changes Recuperar alterações não salvas + Untitled Sem título + FilmCraft was closed while “{name}” had unsaved changes. O FilmCraft foi fechado enquanto “{name}” tinha alterações não salvas. + FilmCraft quit unexpectedly while “{name}” had unsaved changes. O FilmCraft fechou inesperadamente enquanto “{name}” tinha alterações não salvas. + Recover unsaved changes from {when}? Recuperar as alterações não salvas de {when}? + The project had not been saved yet. O projeto ainda não tinha sido salvo. + {n} sessions have unsaved changes: {n} sessões têm alterações não salvas: + Discard Descartar + Recover Recuperar + Not Now Agora não + Are you sure you want to discard your changes to '{file}'? Tem certeza de que deseja descartar as alterações em '{file}'? + Yes Sim + No Não + Add media to your project. Drop files anywhere in the window, or choose a source below. Adicione mídia ao projeto. Solte arquivos em qualquer lugar da janela ou escolha uma origem abaixo. + Browse files… Procurar arquivos… + Movies, audio and stills from disk Filmes, áudio e imagens estáticas do disco + Demo footage Clipes de demonstração + Six procedural 1080p clips with sound Seis clipes procedurais 1080p com som + Demo project Projeto de demonstração + A cut sequence with transitions, effects and music Uma sequência editada com transições, efeitos e música + Bars and Tone Barras e tom + SMPTE HD bars with 1 kHz reference tone Barras SMPTE HD com tom de referência de 1 kHz + Community Comunidade + Join us on Discord Junte-se a nós no Discord + OMF Export Settings Configurações de exportação OMF + AAF Export Settings Configurações de exportação AAF + OMF Title: Título OMF: + Mixdown video Mixar vídeo + Audio: Áudio: + Encapsulate Encapsular + Separate Audio Áudio separado + Embed Incorporar + Separate Files Arquivos separados + Link to Media Vincular à mídia + Audio File Format: Formato de arquivo de áudio: + Sample Rate: Taxa de amostragem: + Bits per Sample: Bits por amostra: + Rendering: Renderização: + Trim audio files Aparar arquivos de áudio + Handle Frames: Quadros de margem: + Render audio clip effects Renderizar efeitos de clipes de áudio + Breakout to mono Separar em mono + Exported {path} Exportado: {path} + Select a clip to view its metadata. Selecione um clipe para ver seus metadados. + Clip: {name} Clipe: {name} + Open Abrir + (no sequence) (nenhuma sequência) + Show or hide {color} markers Mostrar ou ocultar os marcadores da cor {color} + Marker Marcador + {tc} Chapter {tc} Capítulo + No markers. Press M to add one. Nenhum marcador. Pressione M para adicionar um. + All markers are hidden by the colour filter. O filtro de cor oculta todos os marcadores. + Type: Tipo: + Video: Vídeo: + Codec: Codec: + Duration: Duração: + Settings: Configurações: + Playhead: Cursor de reprodução: + Video {n}: Vídeo {n}: + Audio {n}: Áudio {n}: + UI: Interface: + {fps} fps · {n} frames queued {fps} fps · {n} quadros na fila + {panel} — coming in a later milestone {panel}: disponível em uma versão futura + Close Panel Fechar painel + Close Other Timeline Panels Fechar os outros painéis de linha do tempo + Close All Fechar tudo + Maximize Frame Maximizar quadro do painel + Restore Frame Restaurar quadro do painel + Restore Workspace Restaurar área de trabalho + Reveal Sequence in Project Mostrar sequência no projeto + Video Thumbnails Miniaturas de vídeo + Audio Waveforms Formas de onda de áudio + Delete Preset Excluir predefinição + Export Preset… Exportar predefinição… + Import Presets… Importar predefinições… + Effect presets Predefinições de efeitos + Save Preset Salvar predefinição + Name: Nome: + Type Tipo + Scale Escala + Anchor to In Point Ancorar no ponto de entrada + Anchor to Out Point Ancorar no ponto de saída + Without keyframes Sem quadros-chave + Description: Descrição: + Saved preset “{name}” Predefinição “{name}” salva + Current duration {seconds} s Duração atual: {seconds} s + Remix Properties Propriedades da remixagem + Target Duration: Duração desejada: + Segments: Segmentos: + Fewer · More Menos · Mais + Variations: Variações: + Remixed to {seconds} s Remixado para {seconds} s + {mode} · no clip {mode} · nenhum clipe + {mode} · no sequence {mode} · nenhuma sequência + Remove Row Remover linha + Add a timecode row Adicionar uma linha de timecode + Panner Panorâmico + Mute Silenciar + Send {n} Level Nível do envio {n} + Show Keyframes Mostrar quadros-chave + Clip Keyframes Quadros-chave do clipe + Track Keyframes Quadros-chave da faixa + Delete Excluir + Untitled Workspace Área de trabalho sem título + New Workspace Nova área de trabalho + Edit Workspaces Editar áreas de trabalho + Rename Renomear + Restore Original Restaurar original + Search effects Pesquisar efeitos + Apply to Selected Clips Aplicar aos clipes selecionados + Metadata Display Exibição de metadados + Save As New View Preset Salvar como nova predefinição de visualização + Manage Saved View Presets Gerenciar predefinições de visualização salvas + Freeform View Options Opções da visualização de forma livre + Save Arrangement Salvar disposição + Saves the view, columns, sort, thumbnail and font size. Salva a visualização, as colunas, a ordenação, a miniatura e o tamanho da fonte. + Grid spacing: Espaçamento da grade: + Default clip size: Tamanho padrão do clipe: + Snap to grid Encaixar na grade + Show clip names Mostrar nomes dos clipes + Show durations Mostrar durações + Show: Mostrar: + Search fields Pesquisar campos + Project Metadata Metadados do projeto + Add Property… Adicionar propriedade… + Column order Ordem das colunas + {n}. (empty) {n}. (vazio) + Restore Restaurar + Overwrite with Current View Sobrescrever com a visualização atual + Graphics text search arrives with M10.1–M10.2 A pesquisa de texto em gráficos chega com M10.1–M10.2 + Open a sequence to work with captions Abra uma sequência para trabalhar com legendas + Add captions Adicionar legendas + Create a caption track or import a caption file. Crie uma faixa de legendas ou importe um arquivo de legendas. + Create new caption track Criar nova faixa de legendas + Import captions file… Importar arquivo de legendas… + New {format} track Nova faixa {format} + Add caption at playhead Adicionar legenda no cursor de reprodução + Split caption at playhead Dividir legenda no cursor de reprodução + Merge selected captions Mesclar legendas selecionadas + Delete selected captions Excluir legendas selecionadas + No captions on this track. Press + to add one at the playhead. Nenhuma legenda nesta faixa. Pressione + para adicionar uma no cursor de reprodução. + No matching captions. Nenhuma legenda correspondente. + Open a sequence to see its transcript Abra uma sequência para ver sua transcrição + Transcribe sequence Transcrever sequência + Speech-to-text turns the dialogue into editable text. A conversão de fala em texto transforma o diálogo em texto editável. + This build has no speech-to-text; import a transcript with transcript.set. Esta compilação não inclui conversão de fala em texto; importe uma transcrição com transcript.set. + Transcribe Transcrever + Extract selected text Extrair texto selecionado + Lift selected text Levantar texto selecionado + Remove filler words Remover muletas + Create captions Criar legendas + Speaker Locutor + Box Caixa + Align Alinhar + Align left Alinhar à esquerda + Align center Centralizar + Align right Alinhar à direita + Position Posição + Position: top Posição: superior + Position: middle Posição: meio + Position: bottom Posição: inferior + (gap) (lacuna) + Out: {name} Saída: {name} + In: {name} Entrada: {name} + Play Around Edit (Space loops, Shift+K once) Reproduzir ao redor da edição (Espaço em loop, Shift+K uma vez) + Trim Backward Many Aparar muitos para trás + Trim Backward Aparar para trás + Apply Default Transitions Aplicar transições padrão + Apply Default Transitions to Selection (Shift+D) Aplicar transições padrão à seleção (Shift+D) + Trim Forward Aparar para a frente + Trim Forward Many Aparar muitos para a frente + Exit Trim Mode Sair do modo de aparagem + Voice-over record (right-click: Voice-Over Record Settings…) Gravar narração (clique direito: Configurações de gravação de narração…) + Voice-Over Record Settings… Configurações de gravação de narração… + Recording voice-over… Gravando narração… + Voice-over recorded: {p} Narração gravada: {p} + Voice-over recording stopped (nothing recorded) Gravação de narração interrompida (nada foi gravado) + Post-roll Pós-roll + ● Recording ● Gravando + Voice-Over Record Settings Configurações de gravação de narração + Source: Origem: + Default Input Entrada padrão + Input: Entrada: + Channel {n} Canal {n} + Countdown Sound Cues Bipes de contagem regressiva + Pre-roll: Pré-roll: + seconds segundos + Post-roll: Pós-roll: + Playback starts the pre-roll before the playhead (or the In point); with In/Out marked, recording punches in and out there. A reprodução começa com o pré-roll antes do cursor de reprodução (ou do ponto de entrada); com entrada e saída marcadas, a gravação entra e sai nesses pontos. + Effect Efeito + Clip Fx Editor Editor de efeitos do clipe + Track Fx Editor Editor de efeitos da faixa + Master Gain Ganho principal + Reset Redefinir + Band {n} Banda {n} + Solo Solo + Bypass Ignorar + Thr Limiar + Ratio Razão + Att Ataque + Rel Liberação + Gain Ganho + Output Gain Ganho de saída + Limiter Limitador + Link Channels Vincular canais + Limiter Threshold Limiar do limitador + Limiter Release Liberação do limitador + Auto Gate Gate automático + Threshold Limiar + Attack Ataque + Release Liberação + Hold Retenção + Compressor Compressor + Auto Makeup Compensação automática + Makeup Compensação + Expander Expansor + Soft Clip Clipping suave + (no sequences) (nenhuma sequência) + (no clips) (nenhum clipe) + Show Transport Controls Mostrar controles de transporte + Loop Loop + Multi-Camera Audio Follows Video Áudio multicâmera segue o vídeo + Multi-Camera Record Gravação multicâmera + Multi-Camera Selection Top Down Seleção multicâmera de cima para baixo + Show Multi-Camera Preview Monitor Mostrar monitor de pré-visualização multicâmera + Auto-Adjust Multi-Camera Playback Quality Ajustar automaticamente a qualidade de reprodução multicâmera + Transmit Multi-Camera View Transmitir visualização multicâmera + Multi-Camera Layout Layout multicâmera + Automatic Automático + Edit Cameras… Editar câmeras… + Add Marker (M) Adicionar marcador (M) + Mark In (I) Marcar entrada (I) + Mark Out (O) Marcar saída (O) + Go to In (Shift+I) Ir para a entrada (Shift+I) + Step Back 1 Frame (Left) Voltar 1 quadro (Esquerda) + Play-Stop Toggle (Space) Reproduzir/parar (Espaço) + Step Forward 1 Frame (Right) Avançar 1 quadro (Direita) + Go to Out (Shift+O) Ir para a saída (Shift+O) + Insert (,) Inserir (,) + Overwrite (.) Sobrescrever (.) + Export Frame (Shift+E) Exportar quadro (Shift+E) + Lift (;) Levantar (;) + Extract (') Extrair (') + Export Frame: use Export mode (M6) Exportar quadro: use o modo Exportar (M6) + Button Editor Editor de botões + Multi-Camera Record on Gravação multicâmera ativada + Multi-Camera Record off Gravação multicâmera desativada + {first} - Merged {first} - Mesclado + {first} Multicam {first} Multicâmera + In Points Pontos de entrada + Out Points Pontos de saída + Clip Marker Marcador de clipe + Audio Áudio + Merge Clips Mesclar clipes + Create Multi-Camera Source Sequence Criar sequência de origem multicâmera + Synchronize Clips Sincronizar clipes + Clip Name: Nome do clipe: + Sequence Name: Nome da sequência: + {n} clip(s): {names} {n} clipe(s): {names} + {n} clip(s) selected; the clip on the reference track stays put. {n} clipe(s) selecionado(s); o clipe da faixa de referência não se move. + Synchronize Point Ponto de sincronização + Ignore Hours Ignorar horas + first marker primeiro marcador + Offset Audio by: Deslocar o áudio em: + Offset: Deslocamento: + frames quadros + Reference track: Faixa de referência: + lowest (V1…) a mais baixa (V1…) + Remove Audio from AV Clip Remover o áudio do clipe AV + Camera 1 Câmera 1 + All Cameras Todas as câmeras + Switch Audio Alternar áudio + Camera Names Nomes das câmeras + Clip Names Nomes dos clipes + Enumerate Cameras Numerar câmeras + Camera Angle Metadata Metadados de ângulo de câmera + Move source clips to Processed Clips bin Mover os clipes de origem para a pasta Clipes processados + Synchronized {n} clip group(s) {n} grupo(s) de clipes sincronizado(s) + Merged clip created Clipe mesclado criado + Multi-camera source sequence with {n} camera(s) Sequência de origem multicâmera com {n} câmera(s) + No multi-camera clip at the playhead Nenhum clipe multicâmera no cursor de reprodução + Camera {n} — {name} Câmera {n} — {name} + Page {page} of {pages} Página {page} de {pages} + Previous page Página anterior + Next page Próxima página + Multi-Camera Record On/Off (0) Gravação multicâmera ativada/desativada (0) + Record Gravar + Edit Cameras Editar câmeras + Cameras shown in the Multi-Camera view (uncheck to hide). Câmeras mostradas na visualização multicâmera (desmarque para ocultar). + Recorded {n} multi-camera cut(s) {n} corte(s) multicâmera gravado(s) + Clamp Signal Limitar sinal + Presets Predefinições + Parade ({kind}) Parade ({kind}) + Waveform ({kind}) Forma de onda ({kind}) + Parade Type Tipo de parade + Waveform Type Tipo de forma de onda + Vectorscope Targets Alvos do vetorscópio + Colour Space Espaço de cores + Brightness Brilho + Signal Scale Escala do sinal + Histogram Histograma + Paste Attributes Colar atributos + Remove Attributes Remover atributos + New Offline File Novo arquivo offline + Nested Sequence Name Nome da sequência aninhada + Make Subclip Criar subclipe + Edit Subclip Editar subclipe + Modify Clip: Audio Channels Modificar clipe: canais de áudio + Modify Clip: Timecode Modificar clipe: timecode + Frame Hold Options Opções de quadro congelado + Field Options Opções de campo + Save Project Salvar projeto + Motion Movimento + Opacity Opacidade + Time Remapping Remapeamento de tempo + Channel Volume Volume do canal + In Point Ponto de entrada + Out Point Ponto de saída + Playhead Cursor de reprodução + Source Timecode Timecode de origem + Sequence Time Tempo da sequência + Video Attributes Atributos de vídeo + Audio Attributes Atributos de áudio + No effects Nenhum efeito + Scale Attribute Times Escalar os tempos dos atributos + File Name: Nome do arquivo: + Tape Name: Nome da fita: + Contains: Contém: + Video Vídeo + Width: Largura: + Height: Altura: + Frame Rate: Taxa de quadros: + Media Start: Início da mídia: + Subclip: {name} Subclipe: {name} + Start: Início: + End: Fim: + Restrict Trims To Subclip Boundaries Restringir aparagens aos limites do subclipe + Convert to Master Clip Converter em clipe mestre + Clip Channel Format: Formato de canais do clipe: + Number of Audio Clips: Número de clipes de áudio: + Media Source Channels Canais de origem da mídia + Ch. {n} Can. {n} + Clip {n} Clipe {n} + Source channels the selected audio clips play: Canais de origem que os clipes de áudio selecionados reproduzem: + Set Timecode: Definir timecode: + Use the file's timecode Usar o timecode do arquivo + Hold On Congelar em + Timecode: Timecode: + Hold Filters Congelar filtros + Reverse Field Dominance Inverter dominância de campo + Processing Options Opções de processamento + None Nenhum + Always Deinterlace Sempre desentrelaçar + Flicker Removal Remover cintilação + Save changes to “{project_name}” before closing? Salvar as alterações em “{project_name}” antes de fechar? + Don't Save Não salvar + All media is online. Toda a mídia está online. + {name} (copy) {name} (cópia) + Offline Offline + Missing Ausente + Link Media Vincular mídia + {n} clip(s) can't find their media. Locate them, search a folder, or leave them offline. {n} clipe(s) não encontram sua mídia. Localize-a, pesquise uma pasta ou deixe-os offline. + Clip Name Nome do clipe + File Name Nome do arquivo + File Path Caminho do arquivo + Status Status + Match file properties: Propriedades de arquivo que devem corresponder: + File Extension Extensão do arquivo + Clip ID (fingerprint) ID do clipe (impressão digital) + Duration Duração + Media Start Início da mídia + Frame Size / Rate Tamanho e taxa de quadros + Align Timecode Alinhar timecode + Relink others automatically Revincular os demais automaticamente + Search in: Pesquisar em: + Browse… Procurar… + Exact name matches only Somente correspondências exatas de nome + No candidates yet — Search a folder or Locate the file. Ainda não há candidatos: pesquise uma pasta ou localize o arquivo. + ✔ same file ✔ mesmo arquivo + ✔ matches ✔ corresponde + ✖ different file ✖ arquivo diferente + Preview Pré-visualização + Offline All Tudo offline + Locate… Localizar… + Linked {n} clip(s) {n} clipe(s) vinculado(s) + Nothing found with that name. Nada encontrado com esse nome. + Make Offline Tornar offline + Make {n} selected clip(s) offline. Their clips show the offline slate until you link them again. Tornar {n} clipe(s) selecionado(s) offline. Os clipes mostrarão a claquete offline até que sejam vinculados novamente. + Media files remain on disk Os arquivos de mídia permanecem no disco + Media files are deleted Os arquivos de mídia são excluídos + Create Proxies Criar proxies + {n} clip(s) selected. {n} clipe(s) selecionado(s). + Format and size Formato e tamanho + Destination Destino + Next to the original media, in a Proxies folder Ao lado da mídia original, em uma pasta Proxies + Proxies are made in the background and attached when done. Export always uses full-resolution media. Os proxies são criados em segundo plano e anexados ao terminar. A exportação sempre usa a mídia em resolução total. + Creating {n} proxy file(s)… Criando {n} arquivo(s) proxy… + Project Manager Gerenciador de projetos + Sequences Sequências + Resulting Project Projeto resultante + Collect Files and Copy to New Location Coletar arquivos e copiar para um novo local + Consolidate and Transcode Consolidar e transcodificar + Options Opções + Exclude Unused Clips Omitir clipes não usados + Include Handles: Incluir margens: + Include Proxies Incluir proxies + Include Preview Files Incluir arquivos de pré-visualização + Destination Path Caminho de destino + Disk space: original {original} · resulting {result} ({n} files) Espaço em disco: original {original} · resultante {result} ({n} arquivos) + Disk space: — Espaço em disco: — + Calculate Calcular + Project Manager: writing {path} Gerenciador de projetos: gravando {path} + Timeline: (no sequences) Linha do tempo: (nenhuma sequência) + Browse Procurar + Select audio clips, then click a preset to apply it. Selecione clipes de áudio e clique em uma predefinição para aplicá-la. + {name} (user) {name} (usuário) + Select audio clips to edit them here Selecione clipes de áudio para editá-los aqui + Multiple Clips Selected Vários clipes selecionados + The selected clips have different audio types. Assign one type to edit them together. Os clipes selecionados têm tipos de áudio diferentes. Atribua um tipo para editá-los juntos. + Assign an audio type to the selection: Atribua um tipo de áudio à seleção: + Clear Audio Type Limpar tipo de áudio + Preset: Predefinição: + (Custom) (Personalizado) + Preset name Nome da predefinição + Reduce Noise Reduzir ruído + Reduce Rumble Reduzir ronco + DeHum Remover zumbido + DeEss Remover sibilância + Reduce Reverb Reduzir reverberação + Dynamics Dinâmica + EQ Equalizador + Enhance Speech Aprimorar fala + Low Tone Tom grave + High Tone Tom agudo + Mix Mixagem + Reverb Reverberação + Stereo Width Largura estéreo + Remixing music to a target duration is not available yet. Trim the clip and add a crossfade instead. Ainda não é possível remixar a música para uma duração desejada. Apare o clipe e adicione uma transição cruzada. + Pan Pan + Auto-Match Correspondência automática + Not matched yet Ainda sem correspondência + Matches each of the {n} clips on its own. Faz a correspondência de cada um dos {n} clipes separadamente. + Duck against: Atenuar em relação a: + Untagged Sem marcação + Sensitivity Sensibilidade + Reduce By Reduzir em + Fades Fades + Generate Keyframes Gerar quadros-chave + Clip Volume Volume do clipe + (no clip selected) (nenhum clipe selecionado) + Master · {name} Mestre · {name} + Add Lumetri Color to clip Adicionar Cor Lumetri ao clipe + Input LUT LUT de entrada + Color Cor + Temperature Temperatura + Tint Tonalidade + Saturation Saturação + Exposure Exposição + Contrast Contraste + Highlights Realces + Shadows Sombras + Whites Brancos + Blacks Pretos + HDR HDR + HDR White Branco HDR + HDR Specular Especular HDR + Look Visual + Look LUT LUT de visual + Intensity Intensidade + Adjustments Ajustes + Faded Film Filme desbotado + Sharpen Nitidez + Vibrance Vivacidade + RGB Curves Curvas RGB + HDR Range Faixa HDR + Hue Saturation Curves Curvas de matiz e saturação + Hue vs Sat Matiz x saturação + Hue vs Hue Matiz x matiz + Hue vs Luma Matiz x luminância + Luma vs Sat Luminância x saturação + Sat vs Sat Saturação x saturação + Midtones Meios-tons + Key Chave + Hue Matiz + Hue Range Intervalo de matiz + Saturation Min Saturação mínima + Luma Min Luminância mínima + Luma Max Luminância máxima + Soften Suavizar + Show Mask Mostrar máscara + Off Desativado + Color/Gray Cor/cinza + Color/Black Cor/preto + White/Black Branco/preto + Refine Refinar + Denoise Reduzir ruído + Blur Desfoque + Correction Correção + Hue Shift Deslocamento de matiz + Midpoint Ponto médio + Roundness Arredondamento + Feather Difusão + LUT LUT + Color Match Correspondência de cores + Reference Referência + Face Detection (protect skin tones) Detecção de rostos (protege os tons de pele) + Apply Match Aplicar correspondência + Create ellipse mask Criar máscara de elipse + Create 4-point polygon mask Criar máscara de polígono de 4 pontos + Free draw bezier Bézier à mão livre + Click in the Program monitor to place mask points (drag for curves); click the first point or press Enter to close Clique no monitor do programa para posicionar pontos de máscara (arraste para curvas); clique no primeiro ponto ou pressione Enter para fechar + Delete Mask Excluir máscara + Inverted Invertida + Drag: move · Alt-click: smooth/corner · Cmd-click: delete Arrastar: mover · Alt+clique: suave/canto · Cmd+clique: excluir + Track selected mask backward Rastrear a máscara selecionada para trás + Track selected mask backward 1 frame Rastrear a máscara selecionada 1 quadro para trás + Track selected mask forward 1 frame Rastrear a máscara selecionada 1 quadro para a frente + Track selected mask forward Rastrear a máscara selecionada para a frente + Stop tracking Parar rastreamento + Tracking method: {method} Método de rastreamento: {method} + Composite Composto + Comparison Comparação + Video and Waveform Vídeo e forma de onda + System Default Padrão do sistema + Default location Local padrão + Font family Família de fontes + Label Colors Cores dos rótulos + Decoded frames held for the monitors and thumbnails: {used} of {budget}. Quadros decodificados mantidos para os monitores e miniaturas: {used} de {budget}. + FilmCraft keeps the rest of the RAM free for other applications. O FilmCraft deixa o restante da RAM livre para outros aplicativos. + not available in this session indisponível nesta sessão + Currently: {loc} — {files} file(s), {size} Atualmente: {loc} — {files} arquivo(s), {size} + ({ch} output channels) ({ch} canais de saída) + Programme left → device output {l}, right → device output {r}{dev}. Outputs beyond the device's channels fall back to 1 and 2. Programa esquerdo → saída {l} do dispositivo, direito → saída {r}{dev}. Saídas além dos canais do dispositivo voltam para 1 e 2. + Auto-saves go to: {dir} Os salvamentos automáticos vão para: {dir} + Recovery copy: {dir} Cópia de recuperação: {dir} + Last auto-save {a} · last recovery copy {j} Último salvamento automático {a} · última cópia de recuperação {j} + Last auto-save {a} Último salvamento automático {a} + Last recovery copy {j} Última cópia de recuperação {j} + Remove Media Cache Files Remover arquivos de cache de mídia + Deleted {n} file(s), {size}. {n} arquivo(s) excluído(s), {size}. + Reset… Redefinir… + Source · {name} Origem · {name} + Reset Effect Redefinir efeito + Reset Parameter Redefinir parâmetro + Save Preset… Salvar predefinição… + Custom Setup Configuração personalizada + Edit… Editar… + Show graphs Mostrar gráficos + Toggle animation Ativar/desativar animação + Eyedropper (pick a color from the Program monitor) Conta-gotas (capturar uma cor do monitor do programa) + Eyedropper: no picture to sample Conta-gotas: nenhuma imagem para amostrar + Add/Remove Keyframe Adicionar/remover quadro-chave + Temporal Interpolation Interpolação temporal + Linear Linear + Bezier Bézier + Auto Bezier Bézier automático + Continuous Bezier Bézier contínuo + Ease In Suavizar entrada + Ease Out Suavizar saída + Select a clip to grade Selecione um clipe para fazer a correção de cor + Select a clip to see its properties Selecione um clipe para ver suas propriedades + Anchor point Ponto de ancoragem + Rotation Rotação + Level Nível + Speed {n}% Velocidade {n}% + Value Valor + Velocity Velocidade + Default Padrão + Custom ({n} points) Personalizada ({n} pontos) + Select a graphic clip, or use the Type tool (T) on the Program monitor Selecione um clipe de gráfico ou use a ferramenta Texto (T) no monitor do programa + Create new graphic Criar novo gráfico + New Text Layer Nova camada de texto + New Text Novo texto + New Rectangle Novo retângulo + New Ellipse Nova elipse + Delete Layer Excluir camada + Select a layer to edit its properties. Selecione uma camada para editar suas propriedades. + Align Left Alinhar à esquerda + Align Center Horizontally Centralizar horizontalmente + Align Right Alinhar à direita + Align Top Alinhar acima + Align Center Vertically Centralizar verticalmente + Align Bottom Alinhar abaixo + Font Fonte + Font Style Estilo da fonte + Font Size Tamanho da fonte + Alignment Alinhamento + Left Align Text Alinhar texto à esquerda + Center Align Text Centralizar texto + Right Align Text Alinhar texto à direita + Justify Justificar + Tracking Espaçamento entre letras + Leading Entrelinha + Baseline Shift Deslocamento da linha de base + Text Box Width Largura da caixa de texto + Style Estilo + Faux Bold Negrito simulado + Faux Italic Itálico simulado + All Caps Tudo em maiúsculas + Small Caps Versaletes + Underline Sublinhado + OpenType OpenType + Kerning Kerning + Ligatures Ligaduras + Shape Forma + Size Tamanho + Corner Radius Raio do canto + Polygon Sides Lados do polígono + Fill Preenchimento + Stroke Traçado + Stroke 2 Traçado 2 + Background Fundo + Opacity Opacidade + Size Tamanho + Corner Radius Raio do canto + Shadow Sombra + Angle Ângulo + Distance Distância + Blur Desfoque + Search templates Pesquisar modelos + All Categories Todas as categorias + Install… Instalar… + Install Motion Graphics Template (.fcgt) Instalar modelo de gráficos de movimento (.fcgt) + No templates match. Nenhum modelo corresponde. + {category} · My Template {category} · Meu modelo + Apply Aplicar + Remove Remover + {n} editable properties {n} propriedades editáveis + Template: {name} Modelo: {name} + Intro Duration Duração da introdução + Outro Duration Duração do encerramento + Roll Rolagem + Start Off Screen Começar fora da tela + End Off Screen Terminar fora da tela + Preroll Pré-roll + Postroll Pós-roll + Video Frame Quadro de vídeo + Pin To Fixar em + Pinned Edges Bordas fixadas + FilmCraft Graphics Template Modelo de gráficos do FilmCraft + {n} Font {n} Fonte + {n} Size {n} Tamanho + {n} Color {n} Cor + {n} Position {n} Posição + Show {n} Mostrar {n} + My Templates Meus modelos + Export As Motion Graphics Template Exportar como modelo de gráficos de movimento + Category: Categoria: + Editable properties: Propriedades editáveis: + Saved as a FilmCraft graphics template (.fcgt) in your templates folder. Salvo como modelo de gráficos do FilmCraft (.fcgt) na sua pasta de modelos. + Exported graphics template to {path} Modelo de gráficos exportado para {path} + Replace Fonts in Projects Substituir fontes em projetos + Fonts used in the project: Fontes usadas no projeto: + {f} (missing) {f} (ausente) + Replace: Substituir: + Replaced {n} font uses {n} usos de fontes substituídos + Show/Hide Effects and Sends Mostrar/ocultar efeitos e envios + Audio Track Mixer menu Menu do misturador de faixas de áudio + Show/Hide Tracks… Mostrar/ocultar faixas… + Meter Input(s) Only Medir somente entradas + Show/Hide Tracks Mostrar/ocultar faixas + Show All Mostrar tudo + Post-Fader Pós-fader + Remove Effect Remover efeito + Fill the slots above first Preencha primeiro os slots acima + pre pré + Pre-Fader Pré-fader + Mute Send Silenciar envio + Remove Send Remover envio + New Submix Nova submixagem + Pan / balance (drag; double-click: centre) Pan / balanço (arraste; clique duplo: centro) + Solo safe Proteção de solo + Enable track for recording Ativar faixa para gravação + Volume (drag; double-click: 0 dB) Volume (arraste; clique duplo: 0 dB) + 5.1 panner (drag the puck; double-click: front centre) Panorâmico 5.1 (arraste o ponto; clique duplo: centro frontal) + Center Centro + Go to In Point Ir para o ponto de entrada + Go to Out Point Ir para o ponto de saída + Play In to Out Reproduzir da entrada à saída + Record (voice-over on the record-enabled track) Gravar (narração na faixa ativada para gravação) + Write keyframes Gravar quadros-chave + Something went wrong while drawing the window. Your project is still open; save it to be safe. Ocorreu um erro ao desenhar a janela. Seu projeto continua aberto; salve-o por precaução. + Continue Continuar + Go Back Voltar + Go Forward Avançar + Up One Level Subir um nível + File Types Displayed Tipos de arquivo exibidos + List View Visualização em lista + Thumbnail View Visualização em miniaturas + Ingest Ingestão + Open Ingest Settings Abrir configurações de ingestão + Import as Image Sequence Importar como sequência de imagens + {sel} of {total} items selected {sel} de {total} itens selecionados + {total} items {total} itens + Cannot open {dir}: {e} Não é possível abrir {dir}: {e} + Favorites Favoritos + Local Drives Unidades locais + Network Rede + Recent Directories Pastas recentes + (none) (nenhuma) + Remove from Favorites Remover dos favoritos + Add to Favorites Adicionar aos favoritos + Clear Recent Directories Limpar pastas recentes + Open In Source Monitor Abrir no monitor de origem + Reveal in Finder Mostrar no gerenciador de arquivos + Edit Columns… Editar colunas… + Hover Scrub Reprodução ao passar o mouse + New Media Browser Panel Novo painel de navegador de mídia + Create Folder For Imported Projects Criar pasta para os projetos importados + Allow Duplicate Media During Project Import Permitir mídia duplicada ao importar projetos + Refresh Atualizar + Edit Columns Editar colunas + Guides Guias + (no audio) (sem áudio) + Generating waveform… Gerando forma de onda… + Reference {tc} Referência {tc} + Set Definir + Set the reference to the playhead Definir a referência no cursor de reprodução + Reference: next frame Referência: próximo quadro + Reference: previous frame Referência: quadro anterior + RGBA Channels Canais RGBA + Add Guide Adicionar guia + Save Guides as Template Salvar guias como modelo + Manage Guides Gerenciar guias + Orientation: Orientação: + Vertical Vertical + Horizontal Horizontal + Position: Posição: + No saved guide templates. Nenhum modelo de guias salvo. + Project: {name} Projeto: {name} + Bin: {name} Pasta: {name} + Up one level Subir um nível + Video Usage: {vu} Audio Usage: {au} Uso de vídeo: {vu} Uso de áudio: {au} + Icon View Visualização em ícones + Freeform View Visualização de forma livre + Sort Icons Ordenar ícones + Clear (Delete) Limpar (Delete) + New Item Novo item + New Bin Nova pasta + Find Localizar + Automate to Sequence Automatizar para a sequência + Select items to clear Selecione os itens que deseja limpar + User Order Ordem do usuário + Descending Decrescente + Refresh Project Atualizar projeto + Reveal Project in Finder… Mostrar o projeto no gerenciador de arquivos… + New Bin From Selection Nova pasta a partir da seleção + New Search Bin Nova pasta de pesquisa + New Project Shortcut Novo atalho de projeto + List Lista + Icon Ícone + Freeform Forma livre + Preview Area Área de pré-visualização + Thumbnails Miniaturas + Thumbnails show effects applied As miniaturas mostram os efeitos aplicados + Thumbnail controls for all pointing devices Controles de miniaturas para todos os dispositivos apontadores + Refresh Sort Order Atualizar a ordenação + Metadata Display… Exibição de metadados… + Save Current View Preset Salvar a predefinição de visualização atual + Restore View Preset Restaurar predefinição de visualização + Project View Preset {n} Predefinição de visualização do projeto {n} + Freeform View Options… Opções da visualização de forma livre… + Media missing Mídia ausente + Unreadable Ilegível + Open in Source Monitor Abrir no monitor de origem + Open in Timeline Abrir na linha do tempo + New Sequence From Clip Nova sequência a partir do clipe + Set Poster Frame Definir quadro de pôster + Proxy Proxy + Open in Place Abrir no mesmo lugar + Open in New Tab Abrir em uma nova guia + Open in New Window Abrir em uma nova janela + {n} items {n} itens + Stack Empilhar + Unstack Desempilhar + Clip Size Tamanho do clipe + Larger Maior + Smaller Menor + Align to Grid Alinhar à grade + Reset to Grid Redefinir para a grade + Save Arrangement… Salvar disposição… + Restore Arrangement Restaurar disposição + Delete Arrangement Excluir disposição + Drop media here to create sequence. Solte mídia aqui para criar uma sequência. + Double-click a sequence in the Project panel to open it here. Clique duas vezes em uma sequência no painel Projeto para abri-la aqui. + Open Demo Project Abrir projeto de demonstração + Insert and overwrite sequences as nests or individual clips Inserir e sobrescrever sequências como aninhadas ou como clipes individuais + Snap in Timeline (S) Encaixar na linha do tempo (S) + Caption track options Opções da faixa de legendas + Timeline Display Settings Configurações de exibição da linha do tempo + Show Video Thumbnails Mostrar miniaturas de vídeo + Head Início + Head and Tail Início e fim + Continuous Contínuas + Show Audio Waveform Mostrar forma de onda de áudio + Expand All Tracks Expandir todas as faixas + Minimize All Tracks Minimizar todas as faixas + Unrendered: should play back in real time Não renderizado: deve reproduzir em tempo real + Unrendered: render to play back in real time Não renderizado: renderize para reproduzir em tempo real + Rendered preview Pré-visualização renderizada + ✓ Enable ✓ Ativar + Unlink Desvincular + (Default) (Padrão) + (RGB) Gamma (RGB) Gama + (RGB) Input Black Level (RGB) Nível de preto de entrada + (RGB) Input White Level (RGB) Nível de branco de entrada + (RGB) Output Black Level (RGB) Nível de preto de saída + (RGB) Output White Level (RGB) Nível de branco de saída + + Cmd + Cmd + + Opt + Opt + 10 Bit Black Point Ponto preto de 10 bits + 10 Bit White Point Ponto branco de 10 bits + 105mm Prime Lente fixa de 105 mm + 29.97 fps Drop-Frame 29,97 fps com drop-frame + 29.97 fps Non Drop-Frame 29,97 fps sem drop-frame + 35mm Prime Lente fixa de 35 mm + 3D Roll Rolagem 3D + 3D Rotate Rotação 3D + 3D Spin Giro 3D + 3D Spinback Giro reverso 3D + 4-Color Gradient Gradiente de 4 cores + 5.1 Media Mídia 5.1 + 5.1 Mixdown Type Tipo de mixagem 5.1 + 50-300mm Zoom Zoom de 50-300 mm + 59.94 fps Drop-Frame 59,94 fps com drop-frame + 59.94 fps Non Drop-Frame 59,94 fps sem drop-frame + ASC CDL ASC CDL + Accessible color contrast Contraste de cores acessível + Action-Safe Margin Margem de segurança de ação + Active Monitor Monitor ativo + Add Adicionar + Add tracks automatically when editing source clips onto the Timeline Adicionar faixas automaticamente ao editar clipes de origem na linha do tempo + Place files dropped onto the Timeline directly on the Timeline Colocar os arquivos soltos na linha do tempo diretamente nela + 1 file 1 arquivo + {n} files {n} arquivos + Additional Scale Escala adicional + Additive Dissolve Dissolução aditiva + Additive Dissolve (Legacy) Dissolução aditiva (legado) + Adjust Ajustar + Advanced Avançado + Aggressive Agressivo + Align and Transform Alinhar e transformar + All lines Todas as linhas + Allow Selection tool to choose Roll and Ripple trims without modifier key Permitir que a ferramenta de seleção escolha aparagens rolantes e em cascata sem tecla modificadora + Allow current tool to change trim type of previously selected edit point Permitir que a ferramenta atual altere o tipo de aparagem do ponto de edição selecionado anteriormente + Allow duplicate media during project import Permitir mídia duplicada ao importar projetos + Alpha Adjust Ajuste alfa + Alpha Blurriness Desfoque alfa + Alpha Channel Canal alfa + Alpha Glow Brilho alfa + Always import embedded CEA-708 closed captions with wide aspect. Sempre importar legendas ocultas CEA-708 incorporadas com proporção larga. + Always override audio tags Sempre substituir as marcações de áudio + Ambience Ambiente + Ambience Intensity Intensidade do ambiente + Ambience Light Color Cor da luz ambiente + Amount of Noise Quantidade de ruído + Amount to Decolor Quantidade de descoloração + Amount to Tint Quantidade de tingimento + Amplifier Amplificador + Amplify Amplificar + Amplitude Amplitude + Amplitude and Compression Amplitude e compressão + Analog Analógico + Analog Delay Delay analógico + Analyze all imported media to search for visuals or audio Analisar toda a mídia importada para pesquisar imagens ou áudio + Anamorphic Anamórfico + Anchor Âncora + Anchor Point Ponto de ancoragem + Angle Ângulo + Animation Animação + Anti-aliasing Quality Qualidade da suavização de serrilhado + Anti-flicker Filter Filtro anticintilação + Antialiasing for Best Quality Suavização de serrilhado para a melhor qualidade + Asymmetry Assimetria + At Startup Na inicialização + At playback end, return to beginning when restarting playback Ao fim da reprodução, voltar ao início ao reiniciá-la + Attempt to force hardware to document sample rate Tentar forçar o hardware para a taxa de amostragem do documento + Audio Codec Codec de áudio + Audio Duration Duração do áudio + Audio Effects Efeitos de áudio + Audio Hardware Hardware de áudio + Audio In Point Ponto de entrada do áudio + Audio Info Informações de áudio + Audio Out Point Ponto de saída do áudio + Audio Samples Amostras de áudio + Audio Time Units Unidades de tempo de áudio + Audio Transition Default Duration Duração padrão das transições de áudio + Audio Transitions Transições de áudio + Audio Usage Uso de áudio + Auto Automático + Auto Align Alinhamento automático + Auto Reframe Reenquadramento automático + Auto Save Salvamento automático + Auto Save also saves the current project(s) O salvamento automático também salva os projetos atuais + Auto-tag audio types in the timeline Marcar automaticamente os tipos de áudio na linha do tempo + Auto-transcribe all imported clips Transcrever automaticamente todos os clipes importados + Auto-transcribe only clips in sequences Transcrever automaticamente somente os clipes de sequências + Automatch Time Tempo de correspondência automática + Automatic Click Remover Removedor automático de cliques + Automatically Hide Dependent Clips Ocultar automaticamente os clipes dependentes + Automatically Save Every Salvar automaticamente a cada + Automatically delete cache files older than Excluir automaticamente os arquivos de cache com mais de + Automatically delete oldest cache files when cache exceeds Excluir automaticamente os arquivos de cache mais antigos quando o cache ultrapassar + Automatically refresh growing files Atualizar automaticamente os arquivos em crescimento + Automatically resume playback for growing files in Source Monitor Retomar automaticamente a reprodução de arquivos em crescimento no monitor de origem + Automatically save projects Salvar projetos automaticamente + Automatically transcribe clips Transcrever clipes automaticamente + Automation Keyframe Optimization Otimização de quadros-chave de automação + Background Color Cor de fundo + Background Corner Radius Raio do canto do fundo + Background Opacity Opacidade do fundo + Background Size Tamanho do fundo + Background fill Preenchimento de fundo + Balance Balanço + Balanced Voice Voz equilibrada + Band 1 Banda 1 + Band 2 Banda 2 + Band 3 Banda 3 + Band 4 Banda 4 + Band 5 Banda 5 + Band Pass Passa-banda + Band Slide Deslizamento de faixas + Band Stop Rejeita-banda + Bandpass Passa-banda + Barn Doors Portas de celeiro + Basic Básico + Basic 3D 3D básico + Basic Correction Correção básica + Bass Graves + Behind Atrás + Bessel Bessel + Bevel Alpha Chanfro alfa + Bevel join Junção chanfrada + Binauralizer - Ambisonics Binauralizador - Ambisonics + Bins Pastas + Black Preto + Black & White Preto e branco + Black Input Level Nível de entrada do preto + Black and white Preto e branco + Black and white with crushed blacks and strong contrast Preto e branco com pretos esmagados e contraste forte + Bleach Bypass Bleach bypass + Bleach-bypass style: desaturated with hard contrast Estilo bleach bypass: dessaturado e com contraste duro + Blend Mesclar + Blend Mode Modo de mesclagem + Blend With Original Mesclar com o original + Blending Mode Modo de mesclagem + Block Dissolve Dissolução em blocos + Block Height Altura do bloco + Block Motion Movimento de blocos + Block Size Tamanho do bloco + Block Width Largura do bloco + Blocks Blocos + Blue Blurriness Desfoque do azul + Blue Curve Curva do azul + Blue Offset Deslocamento do azul + Blue Power Potência do azul + Blue Slope Inclinação do azul + Blue-Blue Azul-azul + Blue-Const Azul-constante + Blue-Green Azul-verde + Blue-Red Azul-vermelho + Blur & Sharpen Desfoque e nitidez + Blur Amount Quantidade de desfoque + Blur Angle Ângulo do desfoque + Blur Dimensions Dimensões do desfoque + Blur Dissolve Dissolução com desfoque + Blur Layer Camada de desfoque + Blur Length Comprimento do desfoque + Blurriness Desfoque + Bokeh Blur Desfoque bokeh + Boost Reforço + Border Borda + Border Color Cor da borda + Border Width Largura da borda + Borders Bordas + Bottom Center Inferior central + Bottom Left Inferior esquerdo + Bottom Right Inferior direito + Bounce Salto + Branching Ramificação + Bright Brilhante + Bright Air Ar brilhante + Bright Studio Estúdio brilhante + Brightness & Contrast Brilho e contraste + British Stack Pilha britânica + Brush Hardness Dureza do pincel + Brush Opacity Opacidade do pincel + Brush Position Posição do pincel + Brush Size Tamanho do pincel + Brush Spacing (secs) Espaçamento do pincel (s) + Brush Strokes Pinceladas + Bubbles Bolhas + Bulge Protuberância + Bulge Smoother Suavização da protuberância + Bump Height Altura do relevo + Burn Alpha Queimar alfa + Burn Chroma Queimar croma + Burn Color Queimar cor + Butt cap Extremidade reta + Butterworth Butterworth + CEA-608 CEA-608 + CEA-708 CEA-708 + Cache analysis results for re-use Armazenar em cache os resultados da análise para reutilizá-los + Camera Angle Ângulo de câmera + Camera Blur Desfoque de câmera + Camera Shake Tremor de câmera + Capitalisation Uso de maiúsculas + Cathedral Catedral + Cell Pattern Padrão de células + Center Channel Pan Pan do canal central + Center Gradient Gradiente central + Center Input Entrada central + Center Out Saída central + Center Point (Latitude) Ponto central (latitude) + Center Point (Longitude) Ponto central (longitude) + Center Split Divisão central + Center of Sphere Centro da esfera + Cents Cents + Change to Color Alterar para cor + Changes are copied in the background a moment after each edit. If FilmCraft quits unexpectedly, they are offered the next time it starts. As alterações são copiadas em segundo plano logo após cada edição. Se o FilmCraft fechar inesperadamente, elas serão oferecidas na próxima vez que ele for iniciado. + Channel Canal + Channel Blur Desfoque de canal + Channel Mix Mixagem de canais + Channel Mixer Misturador de canais + Chaos Caos + Chebyshev Chebyshev + Checker Wipe Cortina xadrez + Checkerboard Tabuleiro de xadrez + Chinese Chinês + Choke Estreitar + Chorus Chorus + Chorus/Flanger Chorus/Flanger + Chroma Croma + Chroma Leak Vazamento de croma + Chromatic Aberration (Blue) Aberração cromática (azul) + Chromatic Aberration (Green) Aberração cromática (verde) + Chromatic Aberration (Red) Aberração cromática (vermelho) + Cineon Converter Conversor Cineon + Circle Círculo + Clarity Clareza + Clean Combo Combo limpo + Clean Up Noisy Dialogue Limpar diálogo com ruído + Clear Muddiness Remover embolamento + Client Cliente + Clip Clipe + Clip Level Nível do clipe + Clipping Clipping + Clock Wipe Cortina de relógio + Clock Wipe (Legacy) Cortina de relógio (legado) + Clockwise Sentido horário + Clone Clonar + Closed Captions Legendas ocultas + Color & Alpha Cor e alfa + Color 1 Cor 1 + Color 2 Cor 2 + Color 3 Cor 3 + Color 4 Cor 4 + Color Balance Equilíbrio de cores + Color Burn Subexposição de cor + Color Channel Canal de cor + Color Correction Correção de cor + Color Distortion Distorção de cor + Color Dodge Superexposição de cor + Color Emboss Relevo colorido + Color Key Chave de cor + Color Pass Passagem de cor + Color Replace Substituição de cor + Color Space Espaço de cores + Color Theme Tema de cores + Color To Leave Cor a manter + Color Tolerance Tolerância de cor + Color Wheels & Match Rodas de cores e correspondência + Colorize Colorir + Columns Colunas + Comment Comentário + Complexity Complexidade + Composite In Back Compor atrás + Composite In Front Compor na frente + Composite Using Compor usando + Composite on Original Compor sobre o original + Compound Blur Desfoque composto + Compression Before Clipping Compressão antes do clipping + Constant Gain Ganho constante + Constant Power Potência constante + Conversion Type Tipo de conversão + Convolution Reverb Reverberação por convolução + Cool Blue Azul frio + Cool, desaturated and punchy Frio, dessaturado e marcante + Core Width Largura do núcleo + Corner Canto + Corner Pin Fixação de cantos + Count Contagem + Counter-clockwise Sentido anti-horário + Crash Recovery Recuperação de falhas + Crawl Left Rastejar para a esquerda + Crawl Right Rastejar para a direita + Create folder for imported projects Criar pasta para os projetos importados + Creative Criativo + Crop Cortar + Crop Less <-> Smooth More Cortar menos <-> Suavizar mais + Cross Displacement Deslocamento cruzado + Cross Dissolve Dissolução cruzada + Cross Dissolve (Legacy) Dissolução cruzada (legado) + Cross Zoom Zoom cruzado + Cross Zoom (Legacy) Zoom cruzado (legado) + Crossfade Transição cruzada + Crossovers Frequências de cruzamento + Crystallize Cristalizar + Crystals Cristais + Cube Spin Giro de cubo + Curl Radius Raio da curvatura + Current Time Tempo atual + Cursor Cursor + Curvature Curvatura + Curve Curva + Curves Curvas + Custom Personalizado + Custom Color Cor personalizada + Cutoff Corte + Cyan-teal shadows and warm highlights with a little more contrast Sombras ciano-esverdeadas e realces quentes com um pouco mais de contraste + Cycle (in Revolutions) Ciclo (em revoluções) + Cycle Evolution Evolução cíclica + Damping Amortecimento + Damping HF Amortecimento de agudos + Damping LF Amortecimento de graves + Dark Escuro + Darken Escurecer + Darker Color Cor mais escura + Date Created Data de criação + Date Modified Data de modificação + Day for night: darker, cooler and less saturated Noite americana: mais escuro, mais frio e menos saturado + DeEsser De-esser + DeHummer Removedor de zumbido + DeNoise Redutor de ruído + DeReverb Redutor de reverberação + Decay Decaimento + Decay Time (RT60) Tempo de decaimento (RT60) + Default Audio Tracks Faixas de áudio padrão + Default Emoji font Fonte de emoji padrão + Default Media Scaling Escala de mídia padrão + Default Output Saída padrão + Default closed caption font Fonte padrão das legendas ocultas + Default language Idioma padrão + Default paragraph direction Direção de parágrafo padrão + Default subtitle and text font Fonte padrão de legendas e texto + Delay Delay + Delay 1 Delay 1 + Delay 2 Delay 2 + Delay 3 Delay 3 + Delay 4 Delay 4 + Delay and Echo Delay e eco + Delete files older than Excluir arquivos com mais de + Delete oldest files when the cache exceeds Excluir os arquivos mais antigos quando o cache ultrapassar + Delete… Excluir… + Depth Profundidade + Desaturate Dessaturar + Description Descrição + Detail Amplitude Amplitude do detalhe + Detail Level Nível de detalhe + Detailed Analysis Análise detalhada + Device Class Classe de dispositivo + Dialogue Diálogo + Diamond Losango + Difference Diferença + Dimensions Dimensões + Dimmed Esmaecido + Dip to Black Fade para preto + Dip to Black (Legacy) Fade para preto (legado) + Dip to Color Fade para cor + Dip to White Fade para branco + Dip to White (Legacy) Fade para branco (legado) + Direction Direção + Directional Direcional + Directional Blur Desfoque direcional + Directional Blur (Legacy) Desfoque direcional (legado) + Disable video output when in the background Desativar a saída de vídeo em segundo plano + Disperse Dispersar + Displacement Deslocamento + Display Color Management Gerenciamento de cores da tela + Display Color Settings Configurações de cor da tela + Display out of sync indicators for unlinked clips Mostrar indicadores de dessincronização em clipes desvinculados + Dissolve Dissolução + Dissolve Blocks Blocos de dissolução + Distance Distância + Distance to Image Distância até a imagem + Distort Distorcer + Distortion Distorção + Distortion Complexity Complexidade da distorção + Distortion Rate Velocidade da distorção + Distortion Type Tipo de distorção + Divide Dividir + Do not delete cache files automatically Não excluir arquivos de cache automaticamente + Don't label speakers Não identificar locutores + Double-click Clique duplo + Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered) Decodificação de rascunho em resolução de reprodução reduzida (H.264: mais rápida, alguns quadros menos filtrados) + Drive Drive + Drop Shadow Sombra projetada + Dry Seco + Dry Out Saída seca + Dry Up a Roomy Voice Secar uma voz com muita sala + Duck Deep Under Dialogue Atenuar bastante sob o diálogo + Duck Under Dialogue Atenuar sob o diálogo + Duck Under Voice and Effects Atenuar sob a voz e os efeitos + Ducking Atenuação automática + Dutch Holandês + Dynamic Link Dynamic Link + Dynamics Processing Processamento de dinâmica + Early Reflections Primeiras reflexões + Earthquake Terremoto + Ease In and Out Suavizar entrada e saída + Easing Suavização + Echo Eco + Echo Glow Brilho de eco + Echo Operator Operador de eco + Echo Time (seconds) Tempo de eco (segundos) + Echoes Ecos + Edge Color Cor da borda + Edge Feather Difusão da borda + Edge Glow Brilho da borda + Edge Sharpness Nitidez da borda + Edge Softness Suavidade da borda + Edge Thickness Espessura da borda + Edge Thin Afinar borda + Edge Threshold Limiar da borda + Edge Type Tipo de borda + Edge Width Largura da borda + Elliptical Elíptico + Emboss Relevo + Enable HSL Secondary Ativar HSL secundário + Enable Mercury Transmit Ativar Mercury Transmit + Enable ProRes hardware accelerated encoding, if available Ativar a codificação ProRes acelerada por hardware, se disponível + Enable clip and XMP metadata linking Ativar a vinculação de metadados de clipe e XMP + Enable developer mode Ativar o modo de desenvolvedor + Enable hardware accelerated decoding (requires restart) Ativar a decodificação acelerada por hardware (requer reinicialização) + Enable language auto-detection Ativar a detecção automática de idioma + Enable proxies Ativar proxies + End Color Cor final + End Point Ponto final + End of Ramp Fim da rampa + Error Erro + Evolution Evolução + Evolution Options Opções de evolução + Exciter Amount Quantidade do excitador + Exciter Mode Modo do excitador + Exclusion Exclusão + Exponential Fade Fade exponencial + Extended Dynamic Range Monitoring Monitoramento de faixa dinâmica estendida + FFT Filter Filtro FFT + Fade Fade + Fade Out Fade de saída + Faded Matte Matte desbotado + Falloff Atenuação + Far Away Distante + Fast Rápido + Faster Motion Movimento mais rápido + Feedback Realimentação + Feedback 1 Realimentação 1 + Feedback 2 Realimentação 2 + Feedback 3 Realimentação 3 + Feedback 4 Realimentação 4 + Feet + Frames 16mm Pés + quadros 16 mm + Feet + Frames 35mm Pés + quadros 35 mm + Female Feminina + File Size Tamanho do arquivo + Fill Background Preencher fundo + Fill Color Cor de preenchimento + Fill Left with Right Preencher esquerda com direita + Fill Point Ponto de preenchimento + Fill Right with Left Preencher direita com esquerda + Fill Selector Seletor de preenchimento + Film Dissolve Dissolução de filme + Film Dissolve (Legacy) Dissolução de filme (legado) + Film Roll Rolo de filme + Filter Filtro + Filter Frequency Frequência do filtro + Filter Resonance Ressonância do filtro + Filter and EQ Filtro e equalizador + Final Delay Delay final + Find Edges Localizar bordas + Flanger Flanger + Flare Reflexo + Flare Brightness Brilho do reflexo + Flare Center Centro do reflexo + Flare Color Cor do reflexo + Flash Flash + Flicker Cintilação + Flickers Cintilações + Flip Motion Movimento de virada + Flip Over Virar + Float Ponto flutuante + Focus Foco + Focus Blur Desfoque de foco + Focus Point Ponto de foco + Focus Shape Forma do foco + Focus Size Tamanho do foco + Fold Motion Movimento de dobra + Foldback Retorno + For color management parameters, go to the Settings tab in the Lumetri Color panel. Para os parâmetros de gerenciamento de cores, vá até a guia Configurações do painel Cor Lumetri. + Format Formato + Four Scopes (RGB, 8 bit) Quatro escopos (RGB, 8 bits) + Four Scopes (YUV, float, no clamp) Quatro escopos (YUV, ponto flutuante, sem limitar) + Fractal Influence Influência fractal + Fractal Type Tipo de fractal + Frame Quadro + Frame Color Cor da moldura + Frame Count Contagem de quadros + Frame Layout Layout do quadro + Frame Rate Taxa de quadros + Frame Width Largura da moldura + Frame cache for monitors and thumbnails Cache de quadros para monitores e miniaturas + Frames Quadros + Framing Enquadramento + French Francês + Frequency Frequência + From De + From East Do leste + From North Do norte + From South Do sul + From West Do oeste + From the Left Da esquerda + From the Right Da direita + Front + LFE Frontais + LFE + Front + Rear Frontais + traseiros + Front + Rear + LFE Frontais + traseiros + LFE + Front Only Somente frontais + Fuzz Fuzz + GB GB + Gamma Gama + Gamma Correction Correção de gama + Gamut Warning Aviso de gama de cores + Gamut Warning Color Cor do aviso de gama de cores + Gap Lacuna + Gap Color Cor da lacuna + Gaussian Blur Desfoque gaussiano + Gaussian Blur (Legacy) Desfoque gaussiano (legado) + General Geral + Generate Gerar + Generate Timecode Gerar timecode + Generate waveforms and conform audio using multithreading Gerar formas de onda e conformar o áudio com vários threads + Generate waveforms automatically during import Gerar formas de onda automaticamente durante a importação + German Alemão + Ghosts Fantasmas + Glass Vidro + Glint Cintilar + Glitch Glitch + Glow Brilho difuso + Glow Brightness Intensidade do brilho difuso + Glow Color Cor do brilho difuso + Glow Only Somente brilho difuso + Glow Radius Raio do brilho difuso + Glow Saturation Saturação do brilho difuso + Glow Width Largura do brilho difuso + Golden Hour Hora dourada + Good Boa + Gradient Gradiente + Gradient End Fim do gradiente + Gradient Layer Camada de gradiente + Gradient Placement Posicionamento do gradiente + Gradient Scatter Dispersão do gradiente + Gradient Shape Forma do gradiente + Gradient Start Início do gradiente + Gradient Wipe Cortina com gradiente + Gradient Wipe (Legacy) Cortina com gradiente (legado) + Grain Size Tamanho do grão + Graphic Equalizer (10 Bands) Equalizador gráfico (10 bandas) + Graphic Equalizer (20 Bands) Equalizador gráfico (20 bandas) + Graphic Equalizer (30 Bands) Equalizador gráfico (30 bandas) + Graphics Gráficos + Green Blurriness Desfoque do verde + Green Curve Curva do verde + Green Offset Deslocamento do verde + Green Power Potência do verde + Green Slope Inclinação do verde + Green-Blue Verde-azul + Green-Const Verde-constante + Green-Green Verde-verde + Green-Red Verde-vermelho + Grid Grade + Grow Crescer + Growing Files Arquivos em crescimento + Grunge Grunge + Grunge & Distort Grunge e distorção + GuitarSuite GuitarSuite + HDR Graphics White (Nits) Branco de gráficos HDR (nits) + HLS Vectorscope + Histogram Vetorscópio HLS + histograma + HSL Secondary HSL secundário + Hard Duro + Hard Clip Clipping duro + Hard Light Luz direta + Hard Limiter Limitador rígido + Hard Mix Mistura sólida + Hardware decoding Decodificação por hardware + Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Decodificação por hardware: Automático usa o decodificador de vídeo do sistema (VideoToolbox no macOS, Media Foundation no Windows, VA-API ou NVDEC para H.264 e HEVC no Linux) para os fluxos que ele suporta, e o decodificador próprio do FilmCraft para todo o resto ou se o hardware falhar. A mídia que já está aberta mantém seu decodificador até ser reaberta. + Head Size Tamanho da cabeça + Heart Coração + Height Altura + Hexagon Hexágono + Hide Back Face Ocultar face traseira + High Alta + High Cutoff Corte de agudos + High Frequency Cut Corte de altas frequências + High Frequency Damping Amortecimento de altas frequências + High Pass Passa-altas + High Shelf Prateleira de agudos + High Shelf Gain Ganho da prateleira de agudos + High contrast with held highlights and deep shadows Alto contraste com realces contidos e sombras profundas + Highlight Realce + Highlight Blue Balance Balanço de azul nos realces + Highlight Brightness Brilho dos realces + Highlight Color Cor de destaque + Highlight Green Balance Balanço de verde nos realces + Highlight Red Balance Balanço de vermelho nos realces + Highlight Rolloff Atenuação dos realces + Highlight Threshold Limiar dos realces + Highlight Tint Tonalidade dos realces + Highlights Lightness Luminosidade dos realces + Highpass Passa-altas + Hindi Hindi + Hindi digits Dígitos hindi + Horizontal 16:9 Horizontal 16:9 + Horizontal Alignment Alinhamento horizontal + Horizontal Blocks Blocos horizontais + Horizontal Decentering Descentralização horizontal + Horizontal Displacement Deslocamento horizontal + Horizontal Flip Inversão horizontal + Horizontal Margin Margem horizontal + Horizontal and Vertical Horizontal e vertical + Hue Center Centro de matiz + Hue Tolerance Tolerância de matiz + I/O Buffer Size Tamanho do buffer de E/S + Ignore Alpha Ignorar alfa + Image Control Controle de imagem + Immersive Video Vídeo imersivo + Import numbered stills as image sequences Importar imagens estáticas numeradas como sequências de imagens + Impulse Impulso + In a Room Em uma sala + In the Media Cache No cache de mídia + In the project No projeto + In/Out Duration Duração da entrada à saída + Include captions on import Incluir legendas ao importar + Incoming Clipe seguinte + Indeterminate Media Timebase Base de tempo da mídia indeterminada + Initial Delay Delay inicial + Inner Interno + Inner Radius Raio interno + Input Boost Reforço de entrada + Inset Inserção + Inside Dentro + Inside Color Cor interna + Inter Inter + Interior Room Tone Tom de sala interna + Internal Black Point Ponto preto interno + Internal White Point Ponto branco interno + Interpolation Interpolação + Intersect Interseção + Inverse Falloff Atenuação inversa + Invert Inverter + Invert Alpha Inverter alfa + Invert Blur Inverter desfoque + Invert Fill Inverter preenchimento + Invert Gradient Inverter gradiente + Invert Left Inverter esquerda + Invert Right Inverter direita + Iris Íris + Iris Box Íris quadrada + Iris Cross Íris em cruz + Iris Diamond Íris em losango + Iris Radius Raio da íris + Iris Rotation Rotação da íris + Iris Round Íris redonda + Iris Shape Forma da íris + Italian Italiano + Japanese Japonês + JetBrains Mono JetBrains Mono + Join Through Edits Unir edições contíguas + Ripple Trim In Aparagem em cascata da entrada + Ripple Trim Out Aparagem em cascata da saída + Roll Edit Edição rolante + Trim In Aparar entrada + Trim Out Aparar saída + Kaleidoscope Caleidoscópio + Keep a recovery copy of unsaved changes Manter uma cópia de recuperação das alterações não salvas + Key Color Cor-chave + Keying Chaveamento + Korean Coreano + Label Defaults Rótulos padrão + Label speakers Identificar locutores + Labels Rótulos + Large Hall Salão grande + Large Trim Offset Deslocamento de aparagem grande + Large Volume Adjustment Ajuste de volume grande + Latch Latch + Layer Name Nome da camada + Layers Camadas + Leave Color Manter cor + Left - Left Esquerda - esquerda + Left - Right Esquerda - direita + Left channel to device output Canal esquerdo para a saída do dispositivo + Left to Right Da esquerda para a direita + Left to right Da esquerda para a direita + Legacy Legados + Length Comprimento + Lens Blur Desfoque de lente + Lens Distortion Distorção de lente + Lens Flare Reflexo de lente + Lens Type Tipo de lente + Level 1 Nível 1 + Level 2 Nível 2 + Level 3 Nível 3 + Level 4 Nível 4 + Levels Níveis + Lifted blacks, softer contrast, muted colour Pretos elevados, contraste mais suave e cor discreta + Light 1 Luz 1 + Light 2 Luz 2 + Light 3 Luz 3 + Light 4 Luz 4 + Light 5 Luz 5 + Light Angle Ângulo da luz + Light Color Cor da luz + Light Intensity Intensidade da luz + Light Leak Vazamento de luz + Light Leaks Vazamentos de luz + Light Sweep Varredura de luz + Light Type Tipo de luz + Lighten Clarear + Lighter Color Cor mais clara + Lighting Effects Efeitos de iluminação + Lightning Relâmpago + Lights & Blurs Luzes e desfoques + Lights & Glows Luzes e brilhos + Line Thickness Espessura da linha + Linear Burn Subexposição linear + Linear Dodge (Add) Superexposição linear (adicionar) + Linear Light Luz linear + Linear Ramp Rampa linear + Linear Wipe Cortina linear + Linear Wipe (Legacy) Cortina linear (legado) + Linear keyframe thinning Redução linear de quadros-chave + Linear to Log De linear para log + Liquid Distortion Distorção líquida + Local Projects Projetos locais + Location Local + Log Note Nota de registro + Log to Linear De log para linear + Log to Log De log para log + Logo Cutout Recorte de logotipo + Long Shadow Sombra longa + Look-Ahead Time Tempo de antecipação + Loudness Loudness + Loudness Maximizer Maximizador de loudness + Loudness Meter Medidor de loudness + Louver Persiana + Low Baixa + Low Frequency Cut Corte de baixas frequências + Low Pass Passa-baixas + Low Shelf Prateleira de graves + Low Shelf Gain Ganho da prateleira de graves + Lower Left Inferior esquerdo + Lower Right Inferior direito + Lowpass Passa-baixas + Luma Luma + Luma Curve Curva de luma + Luma Fade Fade por luma + Luma Key Chave de luma + Luma Source Origem de luma + Luma and Chroma Luma e croma + Lumetri Presets Predefinições Lumetri + Luminance Luminância + Luminance Threshold Limiar de luminância + Luminosity Luminosidade + MB MB + Magenta Magenta + Magnify Ampliar + Magnify (Legacy) Ampliar (legado) + Maintain pitch while shuttling Manter o tom ao usar o shuttle + Major Radius Raio maior + Male Masculina + Mango Manga + Map Black To Mapear preto para + Map White To Mapear branco para + Maps full-range levels into 16–235 (video range) with a luma curve Converte níveis de faixa completa em 16–235 (faixa de vídeo) com uma curva de luma + Mask Only Somente máscara + Master Amplitude Amplitude principal + Mastering Masterização + Match frame sets in point O quadro correspondente define o ponto de entrada + Matte Matte + Matte Alpha Matte alfa + Matte Cleanup Limpeza do matte + Matte Generation Geração do matte + Matte Luma Matte de luma + Maximum Máximo + Maximum Amplitude Amplitude máxima + Maximum Block Size Tamanho máximo do bloco + Maximum Blur Desfoque máximo + Maximum Project Versions Número máximo de versões do projeto + Maximum Reduction Redução máxima + Maximum Scale Escala máxima + Media Mídia + Media Analysis Análise de mídia + Media Analysis & Transcription Análise e transcrição de mídia + Media Cache Cache de mídia + Media Cache Database Banco de dados do cache de mídia + Media Cache Files Arquivos de cache de mídia + Media Cache Management Gerenciamento do cache de mídia + Media Duration Duração da mídia + Media End Fim da mídia + Media Time Tempo da mídia + Media Timecode Timecode da mídia + Media Type Tipo de mídia + Media analysis and transcriptions will be cached in the Media Cache folder. Reanalysis won't be required if the media is imported into another project on this computer. As análises e transcrições de mídia serão armazenadas na pasta do cache de mídia. Não será necessário analisar novamente se a mídia for importada em outro projeto neste computador. + Median Mediana + Medium Room Sala média + Memory Memória + Metadata & Timecode Burn-in Gravação de metadados e timecode na imagem + Method Método + Metrics Kerning Kerning métrico + Mid Médios + Mid Point Ponto médio + Midtone Blue Balance Balanço de azul nos meios-tons + Midtone Green Balance Balanço de verde nos meios-tons + Midtone Red Balance Balanço de vermelho nos meios-tons + Midtones Lightness Luminosidade dos meios-tons + Minimum Mínimo + Minimum time Tempo mínimo + Minimum time interval thinning Redução por intervalo de tempo mínimo + Minor Radius Raio menor + Mirror Espelho + Mirror Alternate Espelho alternado + Missing font replacement Substituição de fontes ausentes + Miter join Junção em meia-esquadria + Miter limit Limite de meia-esquadria + Mixed Crystals Cristais mistos + Mode Modo + Modern High Gain Ganho alto moderno + Modulation Modulação + Modulation Rate Velocidade de modulação + Mono Media Mídia mono + Monochrome Monocromático + Monochrome toned cyan-blue Monocromático virado para azul-ciano + Monochrome toned warm brown Monocromático virado para marrom quente + Monoscopic Monoscópico + Monospace only Somente monoespaçadas + More colour in the less saturated areas Mais cor nas áreas menos saturadas + Morph Cut Corte com morphing + Mosaic Mosaico + Mosaic (Legacy) Mosaico (legado) + Motion Blur Desfoque de movimento + Motion Camera Câmera em movimento + Motion Preset Predefinição de movimento + Motion Tween Interpolação de movimento + Move Mover + Movie Filme + Movie (audio and video) Filme (áudio e vídeo) + Multiband Compressor Compressor multibanda + Multichannel Mono Media Mídia mono multicanal + Multiply Multiplicar + Multitap Delay Delay multitap + Music Música + Mute input during timeline recording Silenciar a entrada durante a gravação na linha do tempo + Name Nome + Narrow Estreito + Neon Color Cor neon + Neon Wipe Cortina neon + Next to original media files Ao lado dos arquivos de mídia originais + Night Noite + No Motion Sem movimento + No Scroll Sem rolagem + No plugins are installed. Changes will take effect the next time you start FilmCraft. Nenhum plug-in instalado. As alterações terão efeito na próxima vez que o FilmCraft for iniciado. + Noise Ruído + Noise & Grain Ruído e granulação + Noise (Legacy) Ruído (legado) + Noise Level Nível de ruído + Noise Reduction/Restoration Redução de ruído/restauração + Noise Type Tipo de ruído + Non-Additive Dissolve Dissolução não aditiva + Normal Normal + Notch 1 Notch 1 + Notch 2 Notch 2 + Notch 3 Notch 3 + Notch 4 Notch 4 + Notch 5 Notch 5 + Notch 6 Notch 6 + Notch Filter Filtro notch + Notch Width Largura do notch + Number Of Echoes Número de ecos + Number of Harmonics Número de harmônicos + Obsolete Obsoletos + Octagon Octógono + Offset Deslocamento + Offset (Turbulence) Deslocamento (turbulência) + Offset From Deslocar de + Offset To Deslocar até + Omni Omni + On Ativado + On Original Image Sobre a imagem original + On Transparent Sobre transparente + Open Air Ar livre + Open Most Recent Abrir o mais recente + Open in new window Abrir em uma nova janela + Open in place Abrir no mesmo lugar + Open new tab Abrir uma nova guia + Opens up the shadows Abre as sombras + Order Ordem + Orientation Orientação + Outer Externo + Outgoing Clipe anterior + Output Saída + Output Mapping Mapeamento de saídas + Outside Fora + Outside Color Cor externa + Overflow Transbordamento + Overlay Sobrepor + Overshoot Ultrapassagem + Page Peel Descascar página + Page Scroll Rolar página + Page Turn Virar página + Paint Bucket Balde de tinta + Paint On Black Pintar sobre preto + Paint On Original Image Pintar sobre a imagem original + Paint On Transparent Pintar sobre transparente + Paint On White Pintar sobre branco + Paint Style Estilo de pintura + Paint Surface Superfície de pintura + Pan (Y Axis) Pan (eixo Y) + Panel Wipe Cortina de painéis + Panels Painéis + Panner - Ambisonics Panorâmico - Ambisonics + Parade Parade + Parade (RGB) Parade (RGB) + Parametric Equalizer Equalizador paramétrico + Passband Ripple Ondulação da banda passante + Path Caminho + Path Points Pontos do caminho + Pause Media Encoder queue during playback Pausar a fila de codificação durante a reprodução + Peak Frequency Frequência de pico + Peak Gain Ganho de pico + Pedestal Pedestal + Pentagon Pentágono + Per line Por linha + Percent Blur Porcentagem de desfoque + Perspective Perspectiva + Phase Difference Diferença de fase + Phaser Phaser + Phone Call Chamada telefônica + Phosphor Fósforo + Phosphor Color Cor do fósforo + Photocopy Fotocópia + Photocopy Color Cor da fotocópia + Pillow Almofada + Pin All Fixar tudo + Pin Horizontal Fixar horizontal + Pin Light Luz pontual + Pin Vertical Fixar vertical + Pinning Fixação + Pitch Shifter Alterador de tom + Plate Placa + Plateau Wipe Cortina em platôs + Plateaus Platôs + Plates Placas + Play after rendering previews Reproduzir após renderizar as pré-visualizações + Play audio while scrubbing in Source and Program Monitors Reproduzir áudio ao percorrer nos monitores de origem e do programa + Playback Reprodução + Playhead position determines trim monitor loop playback A posição do cursor de reprodução determina a reprodução em loop do monitor de aparagem + Plugins Plug-ins + Podcast Voice Voz de podcast + Point 1 Ponto 1 + Point 1 Color Cor do ponto 1 + Point 1 Latitude Latitude do ponto 1 + Point 1 Longitude Longitude do ponto 1 + Point 2 Ponto 2 + Point 2 Color Cor do ponto 2 + Point 2 Latitude Latitude do ponto 2 + Point 2 Longitude Longitude do ponto 2 + Point 3 Ponto 3 + Point 3 Color Cor do ponto 3 + Point 3 Latitude Latitude do ponto 3 + Point 3 Longitude Longitude do ponto 3 + Point 4 Ponto 4 + Point 5 Ponto 5 + Point 6 Ponto 6 + Point 7 Ponto 7 + Point 8 Ponto 8 + Points Pontos + Policy Política + Pop Motion Movimento de surgimento + Portuguese Português + Position & Rotation Posição e rotação + Position Amount Quantidade de posição + Position, Scale & Rotation Posição, escala e rotação + Position, Scale, Rotation Posição, escala, rotação + Posterize Posterizar + Posterize Time Posterizar tempo + Pre-Delay Pré-delay + Prefix Prefixo + Presence Lift Realce de presença + Preserve Luminosity Preservar luminosidade + Preserve Scale Preservar escala + ProcAmp ProcAmp + Projecting cap Extremidade projetada + Projects Projetos + Pull Motion Movimento de puxar + Pulls highlights and whites down Abaixa os realces e os brancos + Push Empurrar + Push (Legacy) Empurrar (legado) + Q Q + RAM reserved for other applications RAM reservada para outros aplicativos + RGB RGB + RGB Split Divisão RGB + RGB-White RGB-branco + Radial Radial + Radial Blur Desfoque radial + Radial Ramp Rampa radial + Radial Wipe Cortina radial + Radial Wipe (Legacy) Cortina radial (legado) + Radio Announcer Locutor de rádio + Radio Voice Voz de rádio + Radius Raio + Ramp Rampa + Ramp Shape Forma da rampa + Random Aleatório + Random Seed Semente aleatória + Range Intervalo + Ray Raio de luz + Ray Color Cor do raio de luz + Ray Length Comprimento do raio de luz + Rays Raios de luz + Rays Only Somente raios de luz + Read Leitura + Rec. 2100 Rec. 2100 + Rec. 601 Rec. 601 + Rec. 709 Rec. 709 + Red Blurriness Desfoque do vermelho + Red Curve Curva do vermelho + Red Offset Deslocamento do vermelho + Red Power Potência do vermelho + Red Slope Inclinação do vermelho + Red-Blue Vermelho-azul + Red-Const Vermelho-constante + Red-Green Vermelho-verde + Red-Red Vermelho-vermelho + Reduce Axis Reduzir eixo + Reduce Interlace Flicker Reduzir cintilação de entrelaçamento + Reflected Refletido + Reflection Angle Ângulo de reflexão + Reflection Center Centro de reflexão + Refraction Refração + Reframe Offset Deslocamento do reenquadramento + Reframe Zoom Zoom do reenquadramento + Refresh growing Files Every Atualizar os arquivos em crescimento a cada + Relaxed Relaxado + Release Time Tempo de liberação + Relief Relevo + Remaining Restante + Remove Fringe Remover franja + Render Edit in Audition files to: Renderizar os arquivos editados no Audition em: + Render audio when rendering video Renderizar o áudio ao renderizar o vídeo + Repair Reparar + Repeat Edge Pixels Repetir pixels da borda + Replace Color Substituir cor + Replace With Clip From Source Monitor Substituir pelo clipe do monitor de origem + Replicate Replicar + Responsive Design - Position Design responsivo - posição + Responsive Design - Time Design responsivo - tempo + Restore open sequences when opening projects Restaurar as sequências abertas ao abrir projetos + Result Resultado + Retro Retrô + Reveal Nested Sequence Mostrar sequência aninhada + Reveal Original Image Mostrar a imagem original + Reveal in Project Mostrar no projeto + Reverb Amount Quantidade de reverberação + Reverse Inverter + Right - Left Direita - esquerda + Right - Right Direita - direita + Right channel to device output Canal direito para a saída do dispositivo + Right to left Da direita para a esquerda + Ripple trim adds edits to keep both sides of trim in sync A aparagem em cascata adiciona edições para manter os dois lados da aparagem sincronizados + Roll (Z Axis) Rolagem (eixo Z) + Room Size Tamanho da sala + Rotation Amount Quantidade de rotação + Roughen Rugosidade + Roughen Color Cor rugosa + Roughen Edges Bordas ásperas + Round cap Extremidade arredondada + Round join Junção arredondada + Rounded Crop Corte arredondado + Rows Linhas + Russian Russo + Rusty Enferrujado + Rusty Color Cor enferrujada + SFX Efeitos sonoros + Sample Rate Taxa de amostragem + Saturation at 50 % Saturação em 50 % + Save .cfa and .pek media cache files next to original media files when possible Salvar os arquivos de cache .cfa e .pek ao lado da mídia original quando possível + Scale (Degrees) Escala (graus) + Scale Amount Quantidade de escala + Scale From Escalar de + Scale Height Escalar altura + Scale Match Correspondência de escala + Scale To Escalar para + Scale Width Escalar largura + Scale to frame size Escalar para o tamanho do quadro + Scan Lines Linhas de varredura + Scene Cena + Scientific Filter Filtro científico + Scratch disk location for Captured Audio Local do disco de trabalho para o áudio capturado + Screen Tela + Seconds Segundos + Segments Segmentos + Semi-tones Semitons + Sequence Name Nome da sequência + Sequence Timecode Timecode da sequência + Set focus on the Timeline when performing Insert/Overwrite edits Focar a linha do tempo ao fazer edições de inserção/sobrescrita + Set to frame size Definir para o tamanho do quadro + Setting Configuração + Shadow Angle Ângulo da sombra + Shadow Blue Balance Balanço de azul nas sombras + Shadow Blur Desfoque da sombra + Shadow Color Cor da sombra + Shadow Distance Distância da sombra + Shadow Green Balance Balanço de verde nas sombras + Shadow Only Somente sombra + Shadow Opacity Opacidade da sombra + Shadow Red Balance Balanço de vermelho nas sombras + Shadow Size Tamanho da sombra + Shadow Tint Tonalidade das sombras + Shadows Lightness Luminosidade das sombras + Shake Sacudida + Shape Dissolve Dissolução com forma + Shape Flow Fluxo de formas + Shape Size Tamanho da forma + Shapes Formas + Shard Size Tamanho dos fragmentos + Sharp Colors Cores nítidas + Sharpen Amount Quantidade de nitidez + Shift Center To Deslocar o centro para + Shift clips that overlap trim point during ripple trimming Deslocar os clipes que se sobrepõem ao ponto de aparagem durante a aparagem em cascata + Shot Plano + Show "Project:" and "Bin:" in Project panel tab Mostrar "Projeto:" e "Pasta:" na guia do painel Projeto + Show Clip Mismatch Warning dialog Mostrar o aviso de incompatibilidade de clipe + Show Event Indicator Mostrar o indicador de eventos + Show Focus Area Mostrar área de foco + Show Home Mostrar Início + Show Mask Tracker Preview Mostrar a pré-visualização do rastreamento de máscara + Show Only Noise Mostrar somente o ruído + Show Open Dialog Mostrar a caixa de diálogo Abrir + Show Project Load Error Dialog Mostrar a caixa de diálogo de erro ao carregar projetos + Show Rich Tool Tips Mostrar dicas de ferramentas detalhadas + Show Tool Tips Mostrar dicas de ferramentas + Show Workspace Reset Warning dialog on double click Mostrar o aviso de redefinição da área de trabalho ao clicar duas vezes + Show system compatibility issues at startup Mostrar problemas de compatibilidade do sistema na inicialização + Shrink Encolher + Shutter Angle Ângulo do obturador + Similarity Semelhança + Simple Notch Filter Filtro notch simples + Simple Parametric EQ Equalizador paramétrico simples + Simple Text Texto simples + Single-band Compressor Compressor de banda única + Sinusoidal Senoidal + Skew Inclinação + Skew Axis Eixo de inclinação + Slats Ripas + Slice Fatia + Slices Fatias + Slide Deslizar + Slide (Legacy) Deslizar (legado) + Slope Inclinação + Slow Lento + Slower Motion Movimento mais lento + Small Room Sala pequena + Smart Limit Limite inteligente + Smart Tools Ferramentas inteligentes + Smart quotes Aspas tipográficas + Smooth Suave + Smooth Motion Movimento suave + Smooth Scroll Rolagem suave + Smoothness Suavidade + Snap playhead in Timeline when Snap is enabled Encaixar o cursor de reprodução na linha do tempo quando o encaixe estiver ativado + Soft Suave + Soft Clamp Limitação suave + Soft Edges (Best Quality) Bordas suaves (melhor qualidade) + Soft Light Luz indireta + Soft Wipe Cortina suave + Soften Harshness Suavizar a aspereza + Softness Suavidade + Solarize Solarizar + Solid Colors Cores sólidas + Source Point Ponto de origem + Source Text Texto de origem + Spacer Espaçador + Spanish Espanhol + Speaker Angle Ângulo do alto-falante + Speaker Labeling Identificação de locutores + Special Especial + Special Effects Efeitos especiais + Speech model Modelo de fala + Speed Velocidade + Spherize Esferizar + Spiky Pontiagudo + Spill Contaminação + Spill Suppression Supressão de contaminação + Spin Girar + Spin Direction Direção do giro + Spin Motion Movimento de giro + Split Dividir + Split (Legacy) Dividir (legado) + Spotlight Holofote + Spread Espalhamento + Spring Motion Movimento elástico + Square Quadrado + Square (Width Only) Quadrado (somente largura) + Square 1:1 Quadrado 1:1 + Stabilization Estabilização + Stabilize Only Somente estabilizar + Stabilize, Crop Estabilizar, cortar + Stabilize, Crop, Auto-scale Estabilizar, cortar, escalar automaticamente + Stabilize, Synthesize Edges Estabilizar, sintetizar bordas + Stages Estágios + Star Estrela + Star Wipe Cortina em estrela + Start Angle Ângulo inicial + Start Color Cor inicial + Start Point Ponto inicial + Start at 0 Começar em 0 + Start at 1 Começar em 1 + Start of Ramp Início da rampa + Start with an Empty Project Começar com um projeto vazio + Starting Intensity Intensidade inicial + Static Plates Placas estáticas + Step forward/back many Avançar/voltar muitos + Stereo Expand Expansão estéreo + Stereo Expander Expansor estéreo + Stereo Imagery Imagem estéreo + Stereo Media Mídia estéreo + Stereo Phasing Fase estéreo + Stereoscopic - Over/Under Estereoscópico - acima/abaixo + Stereoscopic - Side by Side Estereoscópico - lado a lado + Still Imagem estática + Still Image Default Duration Duração padrão de imagens estáticas + Stone Church Igreja de pedra + Stopband Attenuation Atenuação da banda de rejeição + Straight Color Cor direta + Strength Intensidade + Stretch Esticar + Stretch Gradient to Fit Esticar gradiente para ajustar + Stretch Map to Fit Esticar mapa para ajustar + Stretch Width or Height Esticar largura ou altura + Stretch Wipe Cortina esticada + Stretch to Fit Frame Esticar para ajustar ao quadro + Stripe Listra + Stripes Listras + Strobe Color Cor do estroboscópio + Strobe Duration (secs) Duração do estroboscópio (s) + Strobe Light Luz estroboscópica + Strobe Period (secs) Período do estroboscópio (s) + Stroke 2 Color Cor do traçado 2 + Stroke 2 Type Tipo do traçado 2 + Stroke 2 Width Largura do traçado 2 + Stroke Angle Ângulo do traçado + Stroke Color Cor do traçado + Stroke Density Densidade do traçado + Stroke Length Comprimento do traçado + Stroke Length (secs) Comprimento do traçado (s) + Stroke Only Somente traçado + Stroke Randomness Aleatoriedade do traçado + Stroke Type Tipo de traçado + Stroke Width Largura do traçado + Stroke line cap Extremidade da linha do traçado + Stroke line join Junção da linha do traçado + Studio Reverb Reverberação de estúdio + Stylize Estilizar + Subclip End Fim do subclipe + Subclip Start Início do subclipe + Subspace Warp Distorção de subespaço + Subtitle Legenda + Subtract Subtrair + Sum multichannel outputs to mono in Source Monitor Somar as saídas multicanal em mono no monitor de origem + Super Narrow Superestreito + Surface Gloss Brilho da superfície + Surface Material Material da superfície + Surround Reverb Reverberação surround + Swap Trocar + Swap Channels Trocar canais + Swivel Girar no eixo + Synthetic Sintético + TV Power Ligar TV + Tape Fita + Tape Name Nome da fita + Tape/Tube Fita/válvula + Target Aspect Proporção de destino + Target Color Cor de destino + Target Field of View Campo de visão de destino + Target Loudness Loudness de destino + Teal & Orange Verde-azulado e laranja + Telephone Telefone + Teletext Teletexto + Template Properties Propriedades do modelo + Text Animator Animador de texto + Text Color Cor do texto + The media cache holds render previews of unsaved projects and other files FilmCraft can recreate. Deleting them is always safe; FilmCraft rebuilds them as needed. O cache de mídia guarda pré-visualizações renderizadas de projetos não salvos e outros arquivos que o FilmCraft pode recriar. Excluí-los é sempre seguro; o FilmCraft os recria quando necessário. + Thickness Espessura + Tile Gradient Gradiente em mosaico + Tilt Inclinação + Tilt (X Axis) Inclinação (eixo X) + Time Tempo + Time and Pitch Tempo e tom + Timecode Conversion Conversão de timecode + Timeline Mouse Scrolling Rolagem com o mouse na linha do tempo + Timeline Playback Auto-Scrolling Rolagem automática da linha do tempo durante a reprodução + Tint Color Cor da tonalidade + To Até + Tolerance Tolerância + Tone Tom + Top Center Superior central + Top Left Superior esquerdo + Top Right Superior direito + Touch Touch + Track Matte Key Chave de matte de faixa + Transcription Transcrição + Transcription preferences Preferências de transcrição + Transform Transformar + Transformers Transformadores + Transience Transientes + Transition Completion Conclusão da transição + Transition Softness Suavidade da transição + Transparency Transparência + Trash Lixeira + Travel Motion Movimento de deslocamento + Treble Agudos + Triangle Triângulo + Trim Aparar + Tube Válvula + Tube-modeled Compressor Compressor com modelagem de válvula + Tubular Tubular + Turbulent Turbulento + Turbulent Displace Deslocamento turbulento + Turbulent Sharp Turbulento nítido + Turbulent Smooth Turbulento suave + Turbulent Smoother Turbulento mais suave + Tweed Tweed + Twirl Redemoinho + Twirl (Legacy) Redemoinho (legado) + Twirl Center Centro do redemoinho + Twirl Radius Raio do redemoinho + Twist Torção + Twist Smoother Suavização da torção + Typewriter Máquina de escrever + Ultra Key Ultra Key + Uniform Scale Escala uniforme + Uniform Spacing Espaçamento uniforme + Unsharp Mask Máscara de nitidez + Update it at least every Atualizá-la pelo menos a cada + Update timeline dynamically during ripple edits. Atualizar a linha do tempo dinamicamente durante as edições em cascata. + Upper Frequency Frequência superior + Upper Left Superior esquerdo + Upper Right Superior direito + Use Color Noise Usar ruído colorido + Use End Color Usar cor final + Use File Usar arquivo + Use Glow Color Usar cor do brilho difuso + Use Media Source Usar a origem da mídia + Use Tint Color Usar cor da tonalidade + Utility Utilitário + VHS Damage Dano de VHS + VR Blur Desfoque VR + VR Chroma Leaks Vazamentos de croma VR + VR Chromatic Aberrations Aberrações cromáticas VR + VR Color Gradients Gradientes de cor VR + VR De-Noise Redução de ruído VR + VR Digital Glitch Glitch digital VR + VR Fractal Noise Ruído fractal VR + VR Glow Brilho difuso VR + VR Gradient Wipe Cortina com gradiente VR + VR Iris Wipe Cortina de íris VR + VR Light Leaks Vazamentos de luz VR + VR Light Rays Raios de luz VR + VR Mobius Zoom Zoom de Möbius VR + VR Plane to Sphere Plano para esfera VR + VR Projection Projeção VR + VR Random Blocks Blocos aleatórios VR + VR Rotate Sphere Girar esfera VR + VR Sharpen Nitidez VR + VR Spherical Blur Desfoque esférico VR + Validate Content Credentials on import Validar as credenciais de conteúdo ao importar + Vectorscope (YUV) Vetorscópio (YUV) + Vectorscope + Waveform YC Vetorscópio + forma de onda YC + Vectorscope HLS Vetorscópio HLS + Vectorscope YUV Vetorscópio YUV + Venetian Blinds Persianas venezianas + Vertical 4:5 Vertical 4:5 + Vertical 9:16 Vertical 9:16 + Vertical Alignment Alinhamento vertical + Vertical Blocks Blocos verticais + Vertical Decentering Descentralização vertical + Vertical Displacement Deslocamento vertical + Vertical Flip Inversão vertical + Vertical Margin Margem vertical + Very Narrow Muito estreito + Video 1 Vídeo 1 + Video 2 Vídeo 2 + Video 3 Vídeo 3 + Video 4 Vídeo 4 + Video 5 Vídeo 5 + Video 6 Vídeo 6 + Video 7 Vídeo 7 + Video 8 Vídeo 8 + Video Codec Codec de vídeo + Video Duration Duração do vídeo + Video Effects Efeitos de vídeo + Video In Point Ponto de entrada do vídeo + Video Info Informações de vídeo + Video Limiter Limitador de vídeo + Video Out Point Ponto de saída do vídeo + Video Transition Default Duration Duração padrão das transições de vídeo + Video Transitions Transições de vídeo + Video Usage Uso de vídeo + Vignette Vinheta + Vivid Light Luz vívida + Vocal Booth Cabine de locução + Vocal Enhancer Realçador vocal + Voice Voz + Voice in a Hall Voz em um salão + Volumetric Rays Raios volumétricos + Warm Film Filme quente + Warm Room Sala quente + Warm Voice Voz quente + Warm print stock with gentle roll-off Cópia de filme quente com atenuação suave + Warm, soft late-afternoon light Luz quente e suave de fim de tarde + Warning Aviso + Warp Stabilizer Estabilizador de distorção + Wave Onda + Wave Height Altura da onda + Wave Speed Velocidade da onda + Wave Warp Distorção de onda + Wave Width Largura da onda + Waveform Forma de onda + Waveform (Luma) Forma de onda (luma) + Waveform (RGB) Forma de onda (RGB) + Wavelength Comprimento de onda + Wet Molhado + Wet Out Saída molhada + When Opening a Project Ao abrir um projeto + Whip Chicote + Whip (Legacy) Chicote (legado) + Whisper base (balanced) Whisper base (equilibrado) + Whisper small (most accurate) Whisper small (mais preciso) + Whisper tiny (fastest) Whisper tiny (mais rápido) + White Branco + White Input Level Nível de entrada do branco + White Is High Branco é alto + Wide Outdoor Externa ampla + Widener Alargador + Width Largura + Wiggle Oscilação + Wipe Cortina + Wipe Angle Ângulo da cortina + Wonder Glow Brilho maravilhoso + Wrap Back Envolver para trás + Write Gravação + Write XMP ID to files on import Gravar o ID XMP nos arquivos ao importar + Write clip markers to XMP Gravar os marcadores de clipe em XMP + Write-on Escrita progressiva + X Rotation Rotação X + Y Rotation Rotação Y + YC YC + YC no Chroma YC sem croma + YUV YUV + Yellow-green highlights and blue-lifted shadows from crossed channel curves Realces amarelo-esverdeados e sombras elevadas para o azul com curvas de canais cruzadas + Z Position Posição Z + Z Rotation Rotação Z + Zoom Zoom + Zoom Amount Quantidade de zoom + Zoom Blur Desfoque de zoom + Zoom Strength Intensidade do zoom + days dias + frames quadros + milliseconds milissegundos + minute(s) minuto(s) + second(s) segundo(s) + seconds segundos + AAF… AAF… + Audio… Áudio… + Avid Log Exchange… Avid Log Exchange… + Color… Cor… + EDL… EDL… + Edit Workspaces… Editar áreas de trabalho… + FCPXML… FCPXML… + Final Cut Pro XML… Final Cut Pro XML… + General… Geral… + OMF… OMF… + OpenTimelineIO… OpenTimelineIO… + Save Changes to this Workspace Salvar as alterações nesta área de trabalho + Save as New Workspace… Salvar como nova área de trabalho… + Create Search Bin Criar pasta de pesquisa + Edit Offline File Editar arquivo offline + Source Settings Configurações de origem + Update Metadata Atualizar metadados + Automate To Sequence Automatizar para a sequência + Scene Edit Detection Detecção de edição de cena + Analyze Analisar + Normalize Mix Track Normalizar faixa de mixagem + Simplify Sequence Simplificar sequência + Simplify Simplificar + Transcribe Sequence Transcrever sequência + Save as Template Salvar como modelo + Flash Cue Marker Marcador de referência Flash + System Compatibility Report Relatório de compatibilidade do sistema + no GPU adapter: the CPU compositor is used nenhum adaptador de GPU: o compositor da CPU é usado + Avid Log Exchange Avid Log Exchange + FilmCraft Project Projeto do FilmCraft + {name} Selection.fcproj {name} Seleção.fcproj + {name} (Simplified) {name} (simplificada) + Find in: Localizar em: + Find: Localizar: + Search: Pesquisar: + Clip Names and Markers Nomes de clipes e marcadores + Match: Corresponder: + All Todos + Any Qualquer + Case Sensitive Diferenciar maiúsculas e minúsculas + Scratch Disks Discos de trabalho + Ingest Settings Configurações de ingestão + Captured and Generated: Capturado e gerado: + Video Previews: Pré-visualizações de vídeo: + Audio Previews: Pré-visualizações de áudio: + Project Auto Save: Salvamento automático do projeto: + Same as Project Igual ao projeto + Path: {path} Caminho: {path} + Transcode Transcodificar + Copy and Create Proxies Copiar e criar proxies + Action: Ação: + Destination: Destino: + Next to the media Ao lado da mídia + Video Rendering and Playback Renderização e reprodução de vídeo + Renderer: Renderizador: + Display Format: Formato de exibição: + Milliseconds Milissegundos + Capture Captura + Capture Format: Formato de captura: + Action and Title Safe Areas Áreas de segurança de ação e de título + Title Safe Area Área de segurança de título + Action Safe Area Área de segurança de ação + Total Duration Duração total + Average Data Rate Taxa de dados média + Colour Cor + {codec} · {rate} Hz · {n} channel(s) {codec} · {rate} Hz · {n} canal(is) + Media Name: Nome da mídia: + Scene: Cena: + Shot/Take: Plano/tomada: + Log Note: Nota de registro: + Codec: {codec} Codec: {codec} + Write the metadata of the selected clip(s) ({n}) to XMP files next to their media? Gravar os metadados dos clipes selecionados ({n}) em arquivos XMP ao lado de sua mídia? + Existing XMP files written by other applications are left unchanged. Os arquivos XMP existentes gravados por outros aplicativos não são alterados. + To {name} Para {name} + Ordering: Ordenação: + Sort Order Ordem de classificação + Selection Order Ordem de seleção + Placement: Posicionamento: + Sequentially Sequencialmente + At Unnumbered Markers Em marcadores não numerados + Method: Método: + Insert Edit Edição de inserção + Overwrite Edit Edição de sobrescrita + Clip Overlap: Sobreposição de clipes: + Frames per Still: Quadros por imagem estática: + Transitions Transições + Apply Default Audio Transition Aplicar a transição de áudio padrão + Apply Default Video Transition Aplicar a transição de vídeo padrão + Ignore Options Opções de omissão + Ignore Audio Ignorar áudio + Ignore Video Ignorar vídeo + Apply a cut at each detected cut point Aplicar um corte em cada ponto de corte detectado + Create a subclip for each detected cut point Criar um subclipe para cada ponto de corte detectado + Generate clip markers at each detected cut point Gerar marcadores de clipe em cada ponto de corte detectado + Sensitivity: Sensibilidade: + Normalize Mix Track to: Normalizar faixa de mixagem para: + Disabled clips Clipes desativados + Empty tracks Faixas vazias + Gaps Lacunas + Video effects Efeitos de vídeo + Audio effects Efeitos de áudio + Text (graphics and captions) Texto (gráficos e legendas) + Clips Clipes + Move clips to the lowest track possible Mover os clipes para a faixa mais baixa possível + Keep: Manter: + Video and Audio Vídeo e áudio + Video Only Somente vídeo + Audio Only Somente áudio + Language: Idioma: + Audio on track {t} Áudio da faixa {t} + Audio analysis: Análise de áudio: + Recognize when different speakers are talking Reconhecer quando pessoas diferentes estão falando + Template Name: Nome do modelo: + Comments: Comentários: + (unavailable) (indisponível) + Decoders Decodificadores + Containers Contêineres + Export formats Formatos de exportação + Match {n} of {total} Correspondência {n} de {total} + Saved template {path} Modelo salvo em {path} + Scene Edit Detection: analysing in the background (see Progress) Detecção de edição de cena: analisando em segundo plano (consulte Progresso) + Delete Search Bin Excluir pasta de pesquisa + (removed from {list}) (removido de {list}) + {key} can't be used as a shortcut {key} não pode ser usado como atalho + Keyboard Shortcuts Atalhos de teclado + {active} (modified) {active} (modificado) + Loaded “{name}” “{name}” carregado + My Shortcuts Meus atalhos + Deleted preset “{active}” Predefinição “{active}” excluída + Export… Exportar… + Exported to {path} Exportado para {path} + Export: no file chosen (agents: shortcuts.export {path}) Exportar: nenhum arquivo escolhido (agentes: shortcuts.export {path}) + Imported “{name}” “{name}” importado + Import: no file chosen (agents: shortcuts.import {path}) Importar: nenhum arquivo escolhido (agentes: shortcuts.import {path}) + Keyboard: US (macOS) Teclado: EUA (macOS) + Keyboard: US Teclado: EUA + Save preset as: Salvar predefinição como: + Commands: Comandos: + {panel} Panel Painel {panel} + Application shortcuts work whatever panel has focus Os atalhos do aplicativo funcionam em qualquer painel que esteja em foco + Panel shortcuts override application shortcuts while that panel has focus Os atalhos de painel substituem os do aplicativo enquanto esse painel estiver em foco + Modifier held / selected Modificador pressionado / selecionado + Search commands or keys Pesquisar comandos ou teclas + Selected: {command} Selecionado: {command} + Command Comando + Shortcut Atalho + Type a shortcut… (Esc cancels) Digite um atalho… (Esc cancela) + Add another shortcut Adicionar outro atalho + No matching commands Nenhum comando correspondente + Key: Tecla: + Modifiers Modificadores + Nothing is assigned to this key Nada está atribuído a esta tecla + Click a key on the keyboard to see its shortcuts Clique em uma tecla do teclado para ver seus atalhos + Assign {key} to {command} Atribuir {key} a {command} + Conflict: {key} is assigned to {commands} ({ctx}) Conflito: {key} está atribuída a {commands} ({ctx}) + and e + Click a command's Shortcut cell and press keys to assign it; + adds another shortcut. Clique na célula Atalho de um comando e pressione as teclas para atribuí-lo; + adiciona outro atalho. + Cleared {n} shortcut(s) of {command} {n} atalho(s) de {command} removido(s) + No export preset named `{name}` Nenhuma predefinição de exportação chamada `{name}` + Open a sequence to export Abra uma sequência para exportar + Destinations Destinos + Media File Arquivo de mídia + Queue ({n}) Fila ({n}) + Send exports here with Send to Queue, then start the queue. Envie exportações para cá com Enviar para a fila e depois inicie a fila. + Ready Pronta + Encoding {n}% Codificando {n}% + Start Queue Iniciar fila + Stop Parar + Clear Finished Limpar concluídas + Not supported by FilmCraft's encoders yet Ainda não suportado pelos codificadores do FilmCraft + Sequence name Nome da sequência + Folder Pasta + Preset Predefinição + More presets… Mais predefinições… + Multiplexer Multiplexador + The sequence has no caption track. A sequência não tem faixa de legendas. + Export Options Opções de exportação + Burn Captions Into Video Gravar as legendas no vídeo + Create Sidecar File Criar arquivo sidecar + File Format Formato de arquivo + SubRip Subtitle (.srt) Legenda SubRip (.srt) + WebVTT (.vtt) WebVTT (.vtt) + Title Título + Creator Autor + Copyright Copyright + Image Imagem + Frame Size Tamanho do quadro + Match Source Igual à origem + Field Order Ordem dos campos + Aspect Proporção + Profile Perfil + Bitrate Encoding Codificação da taxa de bits + Target Bitrate Taxa de bits desejada + Adaptive ({bpp} bits per pixel) Adaptável ({bpp} bits por pixel) + Maximum Bitrate Taxa de bits máxima + Quality (CRF) Qualidade (CRF) + Key Frame Distance Distância entre quadros-chave + Quality Qualidade + Render at Maximum Depth Renderizar na profundidade máxima + Use Maximum Render Quality Usar a qualidade máxima de renderização + Export Audio Exportar áudio + Audio Format Formato de áudio + Uncompressed (PCM) Sem compressão (PCM) + Match Source ({seq_sr} Hz) Igual à origem ({seq_sr} Hz) + Channels Canais + Audio Bitrate Taxa de bits do áudio + Sample Size Tamanho da amostra + 16 bit 16 bits + 24 bit 24 bits + Size / Opacity Tamanho / opacidade + Image Overlay Sobreposição de imagem + PNG or JPEG file Arquivo PNG ou JPEG + Name Overlay Sobreposição de nome + Timecode Overlay Sobreposição de timecode + Luma Min / Max Luma mín. / máx. + Loudness Normalization Normalização de loudness + True Peak Limit Limite de pico real + Audio only Somente áudio + Scaling Escala + Scale To Fit Escalar para ajustar + Scale To Fill Escalar para preencher + Stretch To Fill Esticar para preencher + Summary Resumo + Sequence, {w}x{h}, {fps} fps, {rate} Hz Sequência, {w}x{h}, {fps} fps, {rate} Hz + Estimated file size: {size} Tamanho estimado do arquivo: {size} + Send to Queue Enviar para a fila + Exporting {path} Exportando {path} + Added to the export queue Adicionado à fila de exportação + Preset Manager Gerenciador de predefinições + Search presets Pesquisar predefinições + Favorites only Somente favoritos + Show in the Preset menu Mostrar no menu Predefinição + User Usuário + Save current settings as: Salvar as configurações atuais como: + Export presets Predefinições de exportação + Import: no file chosen (agents: export.presets.import {path}) Importar: nenhum arquivo escolhido (agentes: export.presets.import {path}) + Export: no file chosen (agents: export.presets.export {path, names}) Exportar: nenhum arquivo escolhido (agentes: export.presets.export {path, names}) + Quick Export: open a sequence first Exportação rápida: abra uma sequência primeiro + File Name & Location Nome e local do arquivo + Quick Export: {path} Exportação rápida: {path} + Graphics problem: switched to software compositing Problema gráfico: alternado para composição por software + GPU acceleration is unavailable; preview renders on the CPU A aceleração por GPU não está disponível; a pré-visualização é renderizada na CPU + Exporting Exportando + Click to select, or click in empty space and drag to marquee select. Use Shift, Opt, and Cmd for other options. Clique para selecionar, ou clique em um espaço vazio e arraste para selecionar com um retângulo. Use Shift, Opt e Cmd para outras opções. + Click to select all clips to the right in all tracks. Shift-click for a single track. Clique para selecionar todos os clipes à direita em todas as faixas. Shift+clique para uma única faixa. + Click to select all clips to the left in all tracks. Shift-click for a single track. Clique para selecionar todos os clipes à esquerda em todas as faixas. Shift+clique para uma única faixa. + Drag an edit point to ripple trim; later clips move to keep the gap closed. Arraste um ponto de edição para uma aparagem em cascata; os clipes seguintes se movem para manter a lacuna fechada. + Drag an edit point to roll it: the out of one clip and the in of the next move together. Arraste um ponto de edição para rolá-lo: a saída de um clipe e a entrada do seguinte se movem juntas. + Drag an edge to change the clip's speed so it fills the new duration. Arraste uma borda para alterar a velocidade do clipe de modo que ele preencha a nova duração. + Drag the edge of a music clip to remix it to the new duration at musically matching beats. Arraste a borda de um clipe de música para remixá-lo para a nova duração em batidas musicalmente correspondentes. + Click to split a clip. Shift-click to split all tracks. Clique para dividir um clipe. Shift+clique para dividir todas as faixas. + Drag a clip to slip its source in/out without moving it. Arraste um clipe para deslizar a entrada/saída de origem sem movê-lo. + Drag a clip to slide it between its neighbours. Arraste um clipe para deslizá-lo entre seus vizinhos. + Drag to scroll the timeline. Arraste para rolar a linha do tempo. + Click to zoom in; Opt-click to zoom out. Clique para ampliar; Opt+clique para reduzir. + FilmCraft hit an error O FilmCraft encontrou um erro + Details: {path} Detalhes: {path} + Opened {url} {url} aberto + (empty) (vazio) + Step Back 1 Frame Voltar 1 quadro + Step Forward 1 Frame Avançar 1 quadro + Match Playhead Corresponder ao cursor de reprodução + Gang to Program Vincular ao programa + AAC Audio Áudio AAC + AAC Audio 48 kHz 320 kbps Áudio AAC 48 kHz 320 kbps + MPEG-4 audio (.m4a), stereo 48 kHz AAC-LC at 320 kbps Áudio MPEG-4 (.m4a), estéreo, AAC-LC de 48 kHz a 320 kbps + AIFF AIFF + AIFF 48 kHz 16-bit AIFF 48 kHz 16 bits + AIFF, stereo 48 kHz 16-bit PCM AIFF, estéreo, PCM de 48 kHz e 16 bits + APV APV + APV 422-10 APV 422-10 + APV 422-12 APV 422-12 + Anamorphic 2:1 (2.0) Anamórfico 2:1 (2.0) + Animated GIF GIF animado + Animated GIF 640×360 GIF animado 640×360 + Apple ProRes Apple ProRes + Apple ProRes 422 Apple ProRes 422 + Apple ProRes 422 HQ Apple ProRes 422 HQ + Apple ProRes 422 LT Apple ProRes 422 LT + Apple ProRes 422 Proxy Apple ProRes 422 Proxy + Apple ProRes 4444 Apple ProRes 4444 + Apple ProRes 4444 with Alpha Apple ProRes 4444 com alfa + Apple ProRes 4444 XQ Apple ProRes 4444 XQ + Application Aplicativo + Apply Guide Template Aplicar modelo de guias + Auto Save Now Salvar automaticamente agora + Auto-Match Loudness Correspondência automática de loudness + Avid DNxHR Avid DNxHR + Avid DNxHR HQ Avid DNxHR HQ + Avid DNxHR LB Avid DNxHR LB + Avid DNxHR SQ Avid DNxHR SQ + Avid-style OP-Atom: DNxHR HQ picture file plus one 24-bit 48 kHz PCM file per channel (_A1, _A2…) OP-Atom no estilo Avid: arquivo de imagem DNxHR HQ mais um arquivo PCM de 24 bits e 48 kHz por canal (_A1, _A2…) + BMP BMP + BMP Sequence Sequência BMP + Baseline Baseline + Begin Text Editing for a Graphic Layer Começar a editar o texto de uma camada de gráfico + Begins With Começa com + Blur In Entrada com desfoque + Blur Out Saída com desfoque + Brightens inside a feathered circular mask Clareia o interior de uma máscara circular difusa + Broadcast Transmissão + CBR CBR + Cancel Dynamic Trim Cancelar aparagem dinâmica + Cancel Queued Export Cancelar exportação na fila + Center Left Centro à esquerda + Center Right Centro à direita + Center align text Centralizar texto + Change Mask Alterar máscara + Clear All Events Limpar todos os eventos + Clear Edit Point Selection Limpar a seleção de pontos de edição + Clear Finished Exports Limpar exportações concluídas + Clear Poster Frame Limpar quadro de pôster + Close Bin Fechar pasta + Close Sequence Fechar sequência + Contains Contém + Cut to Camera 1 Cortar para a câmera 1 + Cut to Camera 2 Cortar para a câmera 2 + Cut to Camera 3 Cortar para a câmera 3 + Cut to Camera 4 Cortar para a câmera 4 + Cut to Camera 5 Cortar para a câmera 5 + Cut to Camera 6 Cortar para a câmera 6 + Cut to Camera 7 Cortar para a câmera 7 + Cut to Camera 8 Cortar para a câmera 8 + Cut to Camera 9 Cortar para a câmera 9 + D1/DV NTSC (0.9091) D1/DV NTSC (0.9091) + D1/DV PAL (1.0940) D1/DV PAL (1.0940) + DVCPRO HD (1.5) DVCPRO HD (1.5) + Darkens outside a feathered oval mask Escurece o exterior de uma máscara oval difusa + Decrease Audio Tracks Height Diminuir a altura das faixas de áudio + Decrease Clip Volume Diminuir o volume do clipe + Decrease Clip Volume Many Diminuir muito o volume do clipe + Decrease Font Size by Five Units Diminuir o tamanho da fonte em cinco unidades + Decrease Font Size by One Unit Diminuir o tamanho da fonte em uma unidade + Decrease Leading by Five Units Diminuir a entrelinha em cinco unidades + Decrease Leading by One Unit Diminuir a entrelinha em uma unidade + Decrease Video Tracks Height Diminuir a altura das faixas de vídeo + Delete Captions Excluir legendas + Delete Graphic Layer Excluir camada de gráfico + Delete Guide Template Excluir modelo de guias + Delete Media Cache Files Excluir arquivos de cache de mídia + Discard Unsaved Changes Descartar alterações não salvas + Does Not Contain Não contém + Download Speech Model Baixar modelo de fala + Dynamic Trim Stop Parar aparagem dinâmica + Edit Caption Text Editar o texto da legenda + Ellipse Tool Ferramenta Elipse + Ends With Termina com + Entire Source Toda a origem + EssentialSound Som essencial + Export Frame Exportar quadro + Extend Next Edit To Playhead Estender a próxima edição até o cursor de reprodução + Extend Previous Edit To Playhead Estender a edição anterior até o cursor de reprodução + Extend Selected Edit to Playhead Estender a edição selecionada até o cursor de reprodução + Extend Selection Down Estender a seleção para baixo + Extend Selection Left Estender a seleção para a esquerda + Extend Selection Right Estender a seleção para a direita + Extend Selection Up Estender a seleção para cima + Fade In Fade de entrada + Gaussian Blur from 0 to 60 over the last second Desfoque gaussiano de 0 a 60 durante o último segundo + Gaussian Blur from 60 to 0 over the first second Desfoque gaussiano de 60 a 0 durante o primeiro segundo + Generate Ducking Keyframes Gerar quadros-chave de atenuação + Go to Next Edit Point Ir para o próximo ponto de edição + Go to Next Edit Point on Any Track Ir para o próximo ponto de edição em qualquer faixa + Go to Previous Edit Point Ir para o ponto de edição anterior + Go to Previous Edit Point on Any Track Ir para o ponto de edição anterior em qualquer faixa + Go to Selected Clip End Ir para o fim do clipe selecionado + Go to Selected Clip Start Ir para o início do clipe selecionado + Go to Sequence End Ir para o fim da sequência + Go to Sequence Start Ir para o início da sequência + H.264 H.264 + AV1 AV1 + H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sites de vídeo, VBR 1 passagem, 16 Mbps + H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 passagem, 20 Mbps desejados, AAC 320 kbps + H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 passagens, 18 Mbps + H.264 3840×2160 for video sharing sites, VBR 1 pass, 45 Mbps H.264 3840×2160 para sites de vídeo, VBR 1 passagem, 45 Mbps + H.264 3840×2160, VBR 1 pass, 60 Mbps target, AAC 320 kbps H.264 3840×2160, VBR 1 passagem, 60 Mbps desejados, AAC 320 kbps + H.264 3840×2160, VBR 2 pass, 50 Mbps H.264 3840×2160, VBR 2 passagens, 50 Mbps + H.264 9:16 portrait for short-form social video, scaled to fill, 12 Mbps H.264 9:16 vertical para vídeos curtos em redes sociais, escalado para preencher, 12 Mbps + H.264 at the sequence's frame size and rate; 0.05 bits per pixel H.264 com o tamanho e a taxa de quadros da sequência; 0,05 bit por pixel + H.264 at the sequence's frame size and rate; 0.1 bits per pixel H.264 com o tamanho e a taxa de quadros da sequência; 0,1 bit por pixel + H.264 at the sequence's frame size and rate; bitrate adapts to the frame size (0.2 bits per pixel) H.264 com o tamanho e a taxa de quadros da sequência; a taxa de bits se adapta ao tamanho do quadro (0,2 bit por pixel) + HD Anamorphic 1080 (1.333) HD anamórfico 1080 (1.333) + Hand Tool Ferramenta Mão + High Quality 1080p HD Alta qualidade 1080p HD + High Quality 2160p 4K Alta qualidade 2160p 4K + Image Sequence Sequência de imagens + Increase Audio Tracks Height Aumentar a altura das faixas de áudio + Increase Clip Volume Aumentar o volume do clipe + Increase Clip Volume Many Aumentar muito o volume do clipe + Increase Font Size by Five Units Aumentar o tamanho da fonte em cinco unidades + Increase Font Size by One Unit Aumentar o tamanho da fonte em uma unidade + Increase Leading by Five Units Aumentar a entrelinha em cinco unidades + Increase Leading by One Unit Aumentar a entrelinha em uma unidade + Increase Video Tracks Height Aumentar a altura das faixas de vídeo + Large blocks resolving to the picture over the first second Blocos grandes que se resolvem na imagem durante o primeiro segundo + Left align text Alinhar texto à esquerda + Looping GIF, 640×360 at 15 fps GIF em loop, 640×360 a 15 fps + Lower First Inferior primeiro + Lumetri Lumetri + MXF OP-Atom MXF OP-Atom + MXF OP-Atom DNxHR (Avid) MXF OP-Atom DNxHR (Avid) + MXF OP1a MXF OP1a + MXF OP1a DNxHR HQ MXF OP1a DNxHR HQ + MXF OP1a H.264 MXF OP1a H.264 + MXF OP1a ProRes 422 HQ MXF OP1a ProRes 422 HQ + MXF OP1a, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM, start timecode MXF OP1a, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz, timecode inicial + MXF OP1a, H.264 High long GOP (0.3 bits per pixel), 24-bit 48 kHz PCM MXF OP1a, H.264 High GOP longo (0,3 bit por pixel), PCM de 24 bits e 48 kHz + MXF OP1a, ProRes 422 HQ, 24-bit 48 kHz PCM, start timecode MXF OP1a, ProRes 422 HQ, PCM de 24 bits e 48 kHz, timecode inicial + Main Main + Mark In (Hover Scrub) Marcar entrada (reprodução ao passar o mouse) + Mark Out (Hover Scrub) Marcar saída (reprodução ao passar o mouse) + Masks Máscaras + Match Source – Adaptive High Bitrate Igual à origem – taxa de bits adaptável alta + Match Source – Adaptive Low Bitrate Igual à origem – taxa de bits adaptável baixa + Match Source – Adaptive Medium Bitrate Igual à origem – taxa de bits adaptável média + Matches Corresponde a + Maximize or Restore Active Frame Maximizar ou restaurar o quadro ativo do painel + Maximize or Restore Frame Under Cursor Maximizar ou restaurar o quadro do painel sob o cursor + Media Browser Settings Configurações do navegador de mídia + MediaBrowser Navegador de mídia + MediaCache Cache de mídia + Merge Captions Mesclar legendas + Mixer Misturador + Mosaic In Entrada em mosaico + Move All Audio Targets Down Mover todos os destinos de áudio para baixo + Move All Audio Targets Up Mover todos os destinos de áudio para cima + Move All Video Targets Down Mover todos os destinos de vídeo para baixo + Move All Video Targets Up Mover todos os destinos de vídeo para cima + Move Selection Down Mover a seleção para baixo + Move Selection End Mover a seleção para o fim + Move Selection Home Mover a seleção para o início + Move Selection Left Mover a seleção para a esquerda + Move Selection Page Down Mover a seleção uma página para baixo + Move Selection Page Up Mover a seleção uma página para cima + Move Selection Right Mover a seleção para a direita + Move Selection Up Mover a seleção para cima + Multi-Camera Record On/Off Toggle Ativar/desativar gravação multicâmera + Multicam Multicâmera + Navigate to End of Segment Ir para o fim do segmento + Navigate to Next Line Ir para a próxima linha + Navigate to Next Word Ir para a próxima palavra + Navigate to Previous Line Ir para a linha anterior + Navigate to Previous Word Ir para a palavra anterior + Navigate to Start of Segment Ir para o início do segmento + Navigation Navegação + Next Multi-Camera Page Próxima página multicâmera + Nudge Clip Selection Down Empurrar a seleção de clipes para baixo + Nudge Clip Selection Left Five Frames Empurrar a seleção de clipes cinco quadros para a esquerda + Nudge Clip Selection Left One Frame Empurrar a seleção de clipes um quadro para a esquerda + Nudge Clip Selection Right Five Frames Empurrar a seleção de clipes cinco quadros para a direita + Nudge Clip Selection Right One Frame Empurrar a seleção de clipes um quadro para a direita + Nudge Clip Selection Up Empurrar a seleção de clipes para cima + Nudge Selected Object down by five Empurrar o objeto selecionado cinco para baixo + Nudge Selected Object down by one Empurrar o objeto selecionado um para baixo + Nudge Selected Object to left by five Empurrar o objeto selecionado cinco para a esquerda + Nudge Selected Object to left by one Empurrar o objeto selecionado um para a esquerda + Nudge Selected Object to right by five Empurrar o objeto selecionado cinco para a direita + Nudge Selected Object to right by one Empurrar o objeto selecionado um para a direita + Nudge Selected Object up by five Empurrar o objeto selecionado cinco para cima + Nudge Selected Object up by one Empurrar o objeto selecionado um para cima + Nudge Volume +1dB Ajustar volume +1 dB + Nudge Volume +3dB Ajustar volume +3 dB + Nudge Volume -1dB Ajustar volume -1 dB + Nudge Volume -3dB Ajustar volume -3 dB + Numbered BMP stills (24-bit) Imagens estáticas BMP numeradas (24 bits) + JPEG JPEG + Targa Targa + DPX DPX + JPEG Sequence Sequência JPEG + Targa Sequence Sequência Targa + DPX Sequence Sequência DPX + Numbered JPEG stills (quality 90) Imagens estáticas JPEG numeradas (qualidade 90) + Numbered Targa stills (24-bit, uncompressed) Imagens estáticas Targa numeradas (24 bits, sem compressão) + Numbered DPX stills (10-bit RGB) Imagens estáticas DPX numeradas (RGB de 10 bits) + OpenEXR OpenEXR + OpenEXR Sequence Sequência OpenEXR + Numbered OpenEXR stills (half float, linear, uncompressed) Imagens estáticas OpenEXR numeradas (meia precisão em ponto flutuante, linear, sem compressão) + Numbered PNG stills at the sequence's frame size Imagens estáticas PNG numeradas no tamanho de quadro da sequência + Numbered TIFF stills (RGB, 8-bit) Imagens estáticas TIFF numeradas (RGB, 8 bits) + Opacity 0 → 100 % over the first second Opacidade de 0 a 100 % durante o primeiro segundo + Opacity 100 → 0 % over the last second Opacidade de 100 a 0 % durante o último segundo + Open Bin Abrir pasta + Open Search Abrir pesquisa + PNG PNG + PNG Sequence Sequência PNG + Panel Painel + Pen Tool Ferramenta Caneta + PiP 35% Upper Right PiP 35% superior direito + Picture-in-picture: 35 % scale in the upper right Picture-in-picture: escala de 35 % no canto superior direito + Play Around Reproduzir ao redor + Play In to Out with Preroll/Postroll Reproduzir da entrada à saída com pré-roll/pós-roll + Play from Playhead to Out Point Reproduzir do cursor de reprodução ao ponto de saída + Play/Stop Reproduzir/parar + Previous Multi-Camera Page Página multicâmera anterior + Progressive Progressivo + ProjectPanel Painel Projeto + QuickTime (Motion JPEG) QuickTime (Motion JPEG) + QuickTime, APV 422-10 (10-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-10 (10 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, APV 422-12 (12-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, APV 422-12 (12 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR HQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR HQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR LB (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR LB (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, DNxHR SQ (8-bit 4:2:2), 24-bit 48 kHz PCM QuickTime, DNxHR SQ (8 bits 4:2:2), PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 HQ, 24-bit 48 kHz PCM QuickTime, ProRes 422 HQ, PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 LT, 24-bit 48 kHz PCM QuickTime, ProRes 422 LT, PCM de 24 bits e 48 kHz + QuickTime, ProRes 422 Proxy, 24-bit 48 kHz PCM QuickTime, ProRes 422 Proxy, PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4), PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 (4:4:4) with alpha channel, 24-bit 48 kHz PCM QuickTime, ProRes 4444 (4:4:4) com canal alfa, PCM de 24 bits e 48 kHz + QuickTime, ProRes 4444 XQ (4:4:4), 24-bit 48 kHz PCM QuickTime, ProRes 4444 XQ (4:4:4), PCM de 24 bits e 48 kHz + QuickTime, ProRes 422, 24-bit 48 kHz PCM QuickTime, ProRes 422, PCM de 24 bits e 48 kHz + Quit FilmCraft Sair do FilmCraft + Rate Stretch Tool Ferramenta Alongamento de taxa + Razor Tool Ferramenta Lâmina + Rectangle Tool Ferramenta Retângulo + Remix Tool Ferramenta Remixagem + Right align text Alinhar texto à direita + Ripple Edit Tool Ferramenta Edição em cascata + Ripple Trim Next Edit to Playhead Aparagem em cascata da próxima edição até o cursor de reprodução + Ripple Trim Previous Edit to Playhead Aparagem em cascata da edição anterior até o cursor de reprodução + Rolling Edit Tool Ferramenta Edição rolante + Select Camera 1 Selecionar câmera 1 + Select Camera 2 Selecionar câmera 2 + Select Camera 3 Selecionar câmera 3 + Select Camera 4 Selecionar câmera 4 + Select Camera 5 Selecionar câmera 5 + Select Camera 6 Selecionar câmera 6 + Select Camera 7 Selecionar câmera 7 + Select Camera 8 Selecionar câmera 8 + Select Camera 9 Selecionar câmera 9 + Select Clip at Playhead Selecionar o clipe no cursor de reprodução + Select Find Box Selecionar a caixa de pesquisa + Select Next Clip Selecionar o próximo clipe + Select Next Panel Selecionar o próximo painel + Select Previous Clip Selecionar o clipe anterior + Select Previous Panel Selecionar o painel anterior + Select to Next Line Selecionar até a próxima linha + Select to Next Word Selecionar até a próxima palavra + Select to Previous Line Selecionar até a linha anterior + Select to Previous Word Selecionar até a palavra anterior + Select to Segment End Selecionar até o fim do segmento + Select to Segment Start Selecionar até o início do segmento + Selection Tool Ferramenta Seleção + Set Comparison Reference Definir a referência de comparação + Set Creative Look Definir o visual criativo + Set Input LUT Definir a LUT de entrada + Set Project Panel View Definir a visualização do painel Projeto + Show Next Screen Mostrar a próxima tela + Show Previous Screen Mostrar a tela anterior + Show Program Transcript Mostrar a transcrição do programa + Shuttle Left Shuttle para a esquerda + Shuttle Right Shuttle para a direita + Shuttle Slow Left Shuttle lento para a esquerda + Shuttle Slow Right Shuttle lento para a direita + Shuttle Stop Parar shuttle + Slide Clip Selection Left Five Frames Deslizar a seleção de clipes cinco quadros para a esquerda + Slide Clip Selection Left One Frame Deslizar a seleção de clipes um quadro para a esquerda + Slide Clip Selection Right Five Frames Deslizar a seleção de clipes cinco quadros para a direita + Slide Clip Selection Right One Frame Deslizar a seleção de clipes um quadro para a direita + Slide Tool Ferramenta Deslizar clipe + Slip Clip Selection Left Five Frames Deslizar o conteúdo da seleção de clipes cinco quadros para a esquerda + Slip Clip Selection Left One Frame Deslizar o conteúdo da seleção de clipes um quadro para a esquerda + Slip Clip Selection Right Five Frames Deslizar o conteúdo da seleção de clipes cinco quadros para a direita + Slip Clip Selection Right One Frame Deslizar o conteúdo da seleção de clipes um quadro para a direita + Slip Tool Ferramenta Deslizar conteúdo + Social Vertical 1080×1920 Vertical para redes sociais 1080×1920 + Soft Vignette Vinheta suave + Source In/Out Entrada/saída da origem + Split Caption Dividir legenda + Square Pixels (1.0) Pixels quadrados (1.0) + Stack Clips Empilhar clipes + Start Export Queue Iniciar a fila de exportação + Start Voice-over Recording Iniciar a gravação de narração + Step Back Five Frames Voltar cinco quadros + Step Back One Frame Voltar um quadro + Step Forward Five Frames Avançar cinco quadros + Step Forward One Frame Avançar um quadro + Stop Export Queue Parar a fila de exportação + Stop Multi-Camera Recording Parar a gravação multicâmera + Stop Voice-over Recording Parar a gravação de narração + Sync Voice-over Capture Sincronizar a captura de narração + TIFF TIFF + TIFF Sequence Sequência TIFF + TextPanel Painel Texto + Thumbnail Size Next Próximo tamanho de miniatura + Thumbnail Size Previous Tamanho de miniatura anterior + Toggle All Audio Targets Alternar todos os destinos de áudio + Toggle All Source Audio Alternar todo o áudio de origem + Toggle All Source Video Alternar todo o vídeo de origem + Toggle All Video Targets Alternar todos os destinos de vídeo + Toggle Audio During Scrubbing Alternar áudio ao percorrer + Toggle Full Screen Alternar tela cheia + Toggle Mute for All Targeted Audio Tracks Alternar mudo em todas as faixas de áudio de destino + Toggle Solo for All Targeted Audio Tracks Alternar solo em todas as faixas de áudio de destino + Toggle Source/Program Monitor Focus Alternar o foco entre os monitores de origem e do programa + Toggle Target Audio 1 Alternar destino de áudio 1 + Toggle Target Audio 2 Alternar destino de áudio 2 + Toggle Target Audio 3 Alternar destino de áudio 3 + Toggle Target Audio 4 Alternar destino de áudio 4 + Toggle Target Audio 5 Alternar destino de áudio 5 + Toggle Target Audio 6 Alternar destino de áudio 6 + Toggle Target Audio 7 Alternar destino de áudio 7 + Toggle Target Audio 8 Alternar destino de áudio 8 + Toggle Target Video 1 Alternar destino de vídeo 1 + Toggle Target Video 2 Alternar destino de vídeo 2 + Toggle Target Video 3 Alternar destino de vídeo 3 + Toggle Target Video 4 Alternar destino de vídeo 4 + Toggle Target Video 5 Alternar destino de vídeo 5 + Toggle Target Video 6 Alternar destino de vídeo 6 + Toggle Target Video 7 Alternar destino de vídeo 7 + Toggle Target Video 8 Alternar destino de vídeo 8 + Toggle Track Output for All Targeted Video Tracks Alternar a saída de todas as faixas de vídeo de destino + Toggle Trim Type Alternar tipo de aparagem + Toggle View Alternar visualização + Track Select Backward Tool Ferramenta Selecionar faixa para trás + Track Select Forward Tool Ferramenta Selecionar faixa para a frente + Track Selected Mask Rastrear a máscara selecionada + Trim Next Edit to Playhead Aparar a próxima edição até o cursor de reprodução + Trim Previous Edit to Playhead Aparar a edição anterior até o cursor de reprodução + Trimming Aparagem + Type Tool Ferramenta Texto + Unstack Clips Desempilhar clipes + Upper First Superior primeiro + VBR, 1 pass VBR, 1 passagem + VBR, 2 pass VBR, 2 passagens + CRF (constant quality) CRF (qualidade constante) + Vimeo 1080p Full HD Vimeo 1080p Full HD + Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD + Voice-over Record Gravação de narração + Voiceover Narração + WAV, stereo 48 kHz 16-bit PCM WAV, estéreo, PCM de 48 kHz e 16 bits + WAV, stereo 48 kHz 24-bit PCM WAV, estéreo, PCM de 48 kHz e 24 bits + Warm Tint Tonalidade quente + Warm highlights, brown shadows Realces quentes, sombras marrons + Waveform Audio Waveform Audio + Waveform Audio 48 kHz 16-bit Waveform Audio 48 kHz 16 bits + Waveform Audio 48 kHz 24-bit Waveform Audio 48 kHz 24 bits + Web & Social Web e redes sociais + Work Area Região de trabalho + Write Automation Gravar automação + YouTube 1080p Full HD YouTube 1080p Full HD + YouTube 2160p 4K Ultra HD YouTube 2160p 4K Ultra HD + Zoom Program Monitor to 100% Ampliar o monitor do programa para 100% + Zoom Program Monitor to Fit Ajustar o monitor do programa à janela + Zoom Source Monitor to 100% Ampliar o monitor de origem para 100% + Zoom Source Monitor to Fit Ajustar o monitor de origem à janela + Zoom Tool Ferramenta Zoom + with: por: + Interface Language Idioma da interface + System Language Idioma do sistema + Cinematic Cinematográfico + Film Emulation Emulação de filme + Technical Técnico + Teal Shadows, Warm Skin Sombras azul-petróleo, pele quente + Night Exterior Externa noturna + Golden Dusk Crepúsculo dourado + Steel Blue Azul-aço + Hard Sun Sol forte + Faded Print Cópia desbotada + Silver Retention Retenção de prata + Cross Processed Processamento cruzado + Warm Negative Negativo quente + Neutral Mono Monocromático neutro + Hard Mono Monocromático intenso + Sepia Tone Tom sépia + Cold Mono Monocromático frio + Lift Shadows Levantar sombras + Protect Highlights Proteger realces + Half Saturation Saturação em 50 % + Boost Vibrance Aumentar vivacidade + Legal Range Squeeze Compressão para a faixa de vídeo + A boxed label with a round pointer; move the label and the pointer follows. Um rótulo em caixa com um ponteiro redondo; ao mover o rótulo, o ponteiro o acompanha. + A rounded tag that fits its text, with a status dot. Uma etiqueta arredondada que se ajusta ao texto, com um ponto de status. + A title on a rounded card with a soft shadow. Um título em um cartão arredondado com uma sombra suave. + Accent Color Cor de ênfase + Bar Color Cor da barra + Box Color Cor da caixa + Box Opacity Opacidade da caixa + Callout – Pointer Chamada – Ponteiro + Callout – Tag Chamada – Etiqueta + Callouts Chamadas + Credits Créditos + Dot Color Cor do ponto + Heading Cabeçalho + Large centred title, accent rule and subtitle. Título grande centralizado, linha de destaque e subtítulo. + Lower Third – Rule Terço inferior – Linha + Lower Third – Slab Terço inferior – Bloco + Lower Thirds Terços inferiores + Name Color Cor do nome + Name Size Tamanho do nome + Name and role on a coloured slab that widens with the name. Nome e função sobre um bloco colorido que se alarga com o nome. + Name over a thin rule that always matches the name's width. Nome sobre uma linha fina que sempre acompanha a largura do nome. + Pointer Color Cor do ponteiro + Role Função + Rule Color Cor da linha + Show Dot Mostrar ponto + Show Role Mostrar função + Slab Color Cor do bloco + Tag Color Cor da etiqueta + Title Color Cor do título + Title Size Tamanho do título + Title – Boxed Título – Em caixa + Title – Centered Título – Centralizado + Titles Títulos + Select an item to preview it Selecione um item para pré-visualizá-lo + End Credits Créditos finais + End Credits – Roll Créditos finais – Rolagem + Ticker – Crawl Letreiro – Rastejamento + Centred credits that roll up from below the frame and leave at the top. Créditos centralizados que sobem de baixo do quadro e saem pelo topo. + A news-style line that crawls in from the right and leaves at the left. Uma linha no estilo de telejornal que entra pela direita e sai pela esquerda. + out saída + in entrada + The language changed but could not be saved: {e} O idioma mudou, mas não pôde ser salvo: {e} + no Chinese font is installed on this system; the interface stays in English nenhuma fonte chinesa está instalada neste sistema; a interface permanece em inglês + no Japanese font is installed on this system; the interface stays in English nenhuma fonte japonesa está instalada neste sistema; a interface permanece em inglês + Show Mostrar + Sort Ordenar + Reverse the order Inverter a ordem + Grab bag Mosaico + Table Tabela + No contributor data was built into this copy. Esta cópia não inclui dados de colaboradores. + No model credits were built into this copy. Esta cópia não inclui créditos de modelos. + Company Empresa + Model Modelo + Version Versão + Commits Commits + % of all commits % de todos os commits + Lines +/− Linhas +/− + Audio output failed: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio falhou: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Audio output is not responding: playing without sound (check Settings ▸ Audio Hardware) A saída de áudio não está respondendo: reproduzindo sem som (verifique Preferências ▸ Hardware de áudio) + Clip Speed / Duration Velocidade/duração do clipe + Speed: Velocidade: + Reverse Speed Inverter velocidade + Ripple Edit, Shifting Trailing Clips Edição em cascata, deslocando os clipes seguintes + About Sobre + Contributors Colaboradores + Models Modelos + Main (8-bit) Main (8 bits) + Main 10 (10-bit, HDR) Main 10 (10 bits, HDR) + Main (8-bit; Main 10 for HDR sequences) Main (8 bits; Main 10 para sequências HDR) + Encoder Codificador + Hardware (H.265 has no software encoder) Hardware (o H.265 não tem codificador por software) + Hardware (AV1 has no software encoder) Hardware (o AV1 não tem codificador por software) + Text Properties Propriedades do texto + Text Box Height Altura da caixa de texto + Paragraph Text Texto de parágrafo + Point Text Texto de ponto + Text Layer Type Tipo de camada de texto + Text Styling Estilo do texto + New Color Matte Novo matte de cor + Color Matte Color Cor do matte de cor + Color: Cor: + · {time} left · faltam {time} + Discard take Descartar tomada + Voice-over take discarded Tomada de narração descartada + Binary assets added Recursos binários adicionados + Binary assets removed Recursos binários removidos + Deselect All Project Items Desmarcar todos os itens do projeto + Display name Nome de exibição + First commit Primeiro commit + H.265 (HEVC) H.265 (HEVC) + Last commit Último commit + Line delta Diferença de linhas + Lines added Linhas adicionadas + Lines deleted Linhas excluídas + Merged PRs PRs mesclados + Name (A–Z) Nome (A–Z) + Real name Nome real + Select All Project Items Selecionar todos os itens do projeto + Username Nome de usuário + Vertical Type Tool Ferramenta Texto vertical + Go to previous keyframe Ir para o quadro-chave anterior + Go to next keyframe Ir para o próximo quadro-chave + Add keyframe Adicionar quadro-chave + Remove keyframe Remover quadro-chave + Turn on animation and add keyframe Ativar a animação e adicionar quadro-chave + Click to select, or click in empty space and drag to marquee select. Use Shift, Alt, and Ctrl for other options. Clique para selecionar, ou clique em um espaço vazio e arraste para selecionar com um retângulo. Use Shift, Alt e Ctrl para outras opções. + Click to zoom in; Alt-click to zoom out. Clique para ampliar; Alt+clique para reduzir. + Drag: move · Alt-click: smooth/corner · Ctrl-click: delete Arrastar: mover · Alt+clique: suave/canto · Ctrl+clique: excluir + Include Alpha Channel Incluir canal alfa + Sequence Settings Configurações da sequência + Color Management Gerenciamento de cores + VR Properties Propriedades de RV + Editing Mode: Modo de edição: + Timebase: Base de tempo: + Frame Size: Tamanho do quadro: + Pixel Aspect Ratio: Proporção de pixels: + Fields: Campos: + Channel Format: Formato de canais: + Number of Channels: Número de canais: + Preview File Format: Formato de arquivo de pré-visualização: + Working Color Space: Espaço de cores de trabalho: + Projection: Projeção: + Layout: Layout: + Captured View: Visualização capturada: + Video Previews Pré-visualizações de vídeo + No Fields (Progressive Scan) Sem campos (varredura progressiva) + Custom ({ratio}) Personalizado ({ratio}) + {rate} frames/second {rate} quadros/segundo + {rate} fps Drop-Frame Timecode Timecode drop-frame a {rate} fps + {rate} fps Non Drop-Frame Timecode Timecode non-drop-frame a {rate} fps + {rate} fps Timecode Timecode a {rate} fps + horizontal horizontal + vertical vertical + Scale motion effects proportionally when changing frame size Escalar os efeitos de movimento proporcionalmente ao alterar o tamanho do quadro + Maximum Bit Depth Profundidade de bits máxima + Maximum Render Quality Qualidade de renderização máxima + Composite in Linear Color Compor em cor linear + Auto Tone Map Media (HDR and log into SDR) Mapeamento de tons automático da mídia (HDR e log para SDR) + Configure… Configurar… + Not supported in FilmCraft yet Ainda não suportado no FilmCraft + FilmCraft always composites in linear light O FilmCraft sempre compõe em luz linear + Loop marked range Loop do intervalo marcado + Play Source Reproduzir origem + Source Play/Stop Reproduzir/parar origem + Stop Source Parar origem + New Sequence Nova sequência + Tracks Faixas + tracks faixas + Clear Transition Limpar transição + Set Transition Duration Definir duração da transição -# System appearance mode menu command. +# System appearance modes and theme cards. + Active Ativo + Appearance Mode Modo de aparência + Dark Theme Tema escuro + Light Theme Tema claro Next Appearance Mode Próximo modo de aparência + Sync with system Sincronizar com o sistema + UI Scale Escala da interface + Auto (system) Automática (sistema) + In Entrada + Out Saída + Export Markers as CSV… Exportar marcadores como CSV… + Fill Type Tipo de preenchimento + Gradient Angle Ângulo do gradiente + Linear Gradient Gradiente linear + Solid Sólido + GPU Rendering Renderização por GPU + Composite on the GPU when available Compor na GPU quando disponível + Text to Speech Texto para fala + Edit Narration Editar narração Project Notes Notas do projeto + Get Project Notes Obter notas do projeto + Edit Project Notes Editar notas do projeto + Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas deste projeto: tarefas, instruções, números de quadro… Salvas com o projeto. + Use Effect Shutter Angle Usar o ângulo do obturador do efeito + +# The native macOS application menu (Settings…). + Settings Preferências diff --git a/crates/ui-egui/src/i18n/pt-br.tsv.attribution b/crates/ui-egui/src/i18n/pt-br.tsv.attribution new file mode 100644 index 00000000..bc354c51 --- /dev/null +++ b/crates/ui-egui/src/i18n/pt-br.tsv.attribution @@ -0,0 +1,8 @@ +asset: pt-br.tsv +title: Brazilian Portuguese interface translation catalog +author: FilmCraft contributors +source: original work +license: MIT OR Apache-2.0 +license-file: LICENSE-MIT and LICENSE-APACHE +added: 2026-10-11 by vangel76 +notes: Original English-to-Brazilian-Portuguese interface translations using clean-room NLE vocabulary. diff --git a/docs/gaps.md b/docs/gaps.md index 56454106..9b89ee23 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -201,11 +201,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** ## G14. Localization: 8 of the 12 key languages missing -- **Have:** Spanish, Japanese, Simplified Chinese and French (#498) catalogs cover ~99.6–100% of - the ~3,500 cataloged strings; Portuguese (Brazil) and Ukrainian ~10% (menus). +- **Have:** Spanish, Japanese, Simplified Chinese, French (#498) and Portuguese (Brazil) (#859) + catalogs cover ~99.6–100% of the ~3,500 cataloged strings; Ukrainian ~10% (menus). - **Missing:** Hindi, Arabic (no RTL interface layout; Arabic titles reported broken, #395), Indonesian, German beyond menus (#490), Korean (#508; needs Hangul fonts in craft-fonts), - Vietnamese, Portuguese beyond menus; Premiere also ships Italian and Russian. Engine errors, + Vietnamese; Premiere also ships Italian and Russian. Engine errors, CLI and MCP stay English. No language has a recorded native-speaker review. - **Estimate:** 150–230 h. Doc: [localization-parity.md](localization-parity.md). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 6124d1c7..5a81a3b5 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -17,8 +17,9 @@ are reachable over the control channel. One catalog per language, commands, panels, workspaces, presets) are translated where they are drawn. Searches match the displayed labels as well as English sources. Tests fail when a `tl!` literal, a menu label or a registry name has no Spanish entry; `zh-cn.tsv` must also cover every `tl!` literal and menu -label; `ja.tsv` and `fr.tsv` have the same entries but are not yet required by tests (French must -cover the core menus and keep no entry without a Spanish one). Engine error messages, the +label; `ja.tsv`, `fr.tsv` and `pt-br.tsv` have the same entries but are not yet required by tests +(French must cover the core menus, Brazilian Portuguese every menu label, and neither may keep an +entry without a Spanish one). Engine error messages, the control channel, the CLI and MCP stay English. Fonts come from [craft-fonts](https://github.com/storytold/craft-fonts) (`CRAFT_FONTS_DIR`, all @@ -43,7 +44,7 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. | Hindi | hi | 0 (0%) | — | **no Devanagari shaping in the interface** | — | none | 30–50 h (shaping in UI + catalog + fonts) | | Arabic | ar | 0 (0%) | — | **no RTL layout, no shaping in the interface**; titles broken (#395) | — | none | 40–70 h | | French | fr | 3,526 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#498) | review | -| Portuguese (Brazil) | pt-br | 348 (9.9%) | menus only | Latin | not recorded | menus only | 5–9 h + review | +| Portuguese (Brazil) | pt-br | 3,541 (same entries as Spanish, plus the macOS Settings item) | as Spanish | Latin (bundled fonts) | not recorded; welcome | partial (#859) | review | | Indonesian | id | 0 (0%) | — | Latin | — | none | 6–10 h + review | | Japanese | ja | 3,487 (99.7%) | as zh-cn; system-font kanji may pick the Chinese fallback (#547) | CJK fonts, vertical title text | not recorded | partial | 4–8 h + review | | German | de | 0 (0%) | — | Latin | — | none (requested #490) | 6–10 h + review | @@ -53,9 +54,9 @@ whole interface, one PR) at ~4–8 h per language for the catalog plus tests. Premiere also ships Italian and Russian, which we don't (6–10 h each). -**Localization dimension:** 5 of the 12 key languages near-complete (English, Spanish, Japanese, -Simplified Chinese, French), 1 menus-only, 6 none; against Premiere's 10 languages we cover 5 well -and 1 partly. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script +**Localization dimension:** 6 of the 12 key languages near-complete (English, Spanish, Japanese, +Simplified Chinese, French, Portuguese (Brazil)), 6 none; against Premiere's 10 languages we cover 6 +well. **~35%, 150–230 h** for all twelve to `full` plus Italian and Russian, of which the script work (RTL and complex shaping in the egui interface) is the largest and least parallel part. Every language needs native-speaker review (human). From 7625ddf4de80c9c2b2fbce3bb5bbc9e37040114d Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:22:09 +0200 Subject: [PATCH 080/203] #854: GIF frame delays add up to the sequence duration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: the GIF exporter gave every frame the same delay of 1000·den/num ms, which the encoder truncates to whole centiseconds. At 24 fps each 41.67 ms frame became 4 cs, so a 4.0 s sequence played in 3.84 s (4 % fast); other non-integer rates drifted the same way. Fix: each frame's delay now runs from its rounded start time to the next frame's (gif_delay_cs), so delays alternate (4, 4, 5, 4, 4, 4 cs at 24 fps) and add up to the exact duration. Checked i128 math also replaces the unchecked `(1000 * den) as u32` cast. Co-Authored-By: Claude Opus 5.5 --- crates/export/src/lib.rs | 17 +++++++++++++++-- crates/export/src/tests.rs | 25 +++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index e9d5b128..d6a26863 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -1671,6 +1671,19 @@ pub fn frame_span(rate: FrameRate, range: TimeRange) -> (i64, i64) { (f0, f1.max(f0)) } +/// The delay of the `i`th GIF frame (from the start of the export) in centiseconds, the unit GIF +/// stores. Each frame runs from its rounded start time to the next frame's, so the delays add up +/// to the exact duration (24 fps: 4, 4, 5, 4, 4, 4 cs …) instead of every frame truncating +/// 4.17 cs to 4 cs and the animation playing 4 % fast (#854). +fn gif_delay_cs(rate: FrameRate, i: i64) -> u16 { + let num = i128::from(rate.num.max(1)); + let den = i128::from(rate.den.max(0)); + // start of frame `k` in centiseconds, rounded half up + let at = |k: i64| i128::from(k.max(0)).saturating_mul(200).saturating_mul(den).saturating_add(num) / num.saturating_mul(2); + let cs = at(i.saturating_add(1)).saturating_sub(at(i)); + u16::try_from(cs.clamp(0, i128::from(u16::MAX))).unwrap_or(u16::MAX) +} + /// An in-memory writer that stays reachable after the encoder that owns a clone is dropped. #[derive(Clone, Default)] struct SharedBuf(Arc>>); @@ -1809,7 +1822,6 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so } else { None }; - let delay = image::Delay::from_numer_denom_ms((1000 * pipe.rate.den) as u32, pipe.rate.num as u32); let mut f = f0; while f < f1 { if cancelled() { @@ -1818,8 +1830,9 @@ pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, so let end = (f + batch).min(f1); if let Some(enc) = gif.as_mut() { let frames: Vec> = (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources).0).collect(); - for rgba in frames { + for (fi, rgba) in (f..end).zip(frames) { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame".into()))?; + let delay = image::Delay::from_numer_denom_ms(u32::from(gif_delay_cs(pipe.rate, fi - f0)) * 10, 1); enc.encode_frame(image::Frame::from_parts(img, 0, 0, delay)).map_err(|e| ExportError::Encode(e.to_string()))?; } } else { diff --git a/crates/export/src/tests.rs b/crates/export/src/tests.rs index 3b0d6bca..9417a7fd 100644 --- a/crates/export/src/tests.rs +++ b/crates/export/src/tests.rs @@ -89,6 +89,31 @@ fn wav_png_gif() { assert!(std::path::Path::new(&tmp("seq005.png")).exists()); } +/// GIF delays are whole centiseconds (#854): at 24 fps they alternate 4/4/5 cs so a second of +/// frames lasts 1000 ms, not 24 × 40 ms = 960 ms. +#[test] +fn gif_frame_delays_add_up_to_the_duration() { + let d: Vec = (0..6).map(|i| gif_delay_cs(FrameRate::FPS_24, i)).collect(); + assert_eq!(d, [4, 4, 5, 4, 4, 4]); + let ntsc: u64 = (0..30).map(|i| u64::from(gif_delay_cs(FrameRate::FPS_29_97, i))).sum(); + assert_eq!(ntsc, 100, "30 frames at 29.97 fps last 1.001 s"); + // hostile rates neither panic nor overflow + assert_eq!(gif_delay_cs(FrameRate { num: 0, den: 0 }, 3), 0); + let _ = gif_delay_cs(FrameRate { num: 1, den: i64::MAX }, i64::MAX); + + let (p, seq, m) = project(); + let path = tmp("timing.gif"); + let mut s = ExportSettings { format: Format::Gif, path: path.clone(), scale: 0.25, ..Default::default() }; + s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(24))); + export(&p, seq, &s, &m, &Progress::default()).unwrap(); + use image::AnimationDecoder; + let dec = image::codecs::gif::GifDecoder::new(std::io::Cursor::new(std::fs::read(&path).unwrap())).unwrap(); + let frames = dec.into_frames().collect_frames().unwrap(); + assert_eq!(frames.len(), 24); + let ms: u32 = frames.iter().map(|f| f.delay().numer_denom_ms()).map(|(n, d)| n / d.max(1)).sum(); + assert_eq!(ms, 1000); +} + #[test] fn prores_export_roundtrip() { let (p, seq, m) = project(); From 50560a8364d5b602ded6e25f42d9edc97d3720c8 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 02:28:15 +0200 Subject: [PATCH 081/203] #786: right-click Label menus show the label colours Problem: #802 gave Edit > Label a colour swatch per label, but the Label submenus of the Project panel item menu and the Timeline clip menu still listed bare built-in names: no colour, no custom names from Preferences > Labels, and no automation ids. Fix: a shared menus::label_choice draws each choice as Edit > Label does (the user's name, a swatch of the user's colour in the left gutter) and registers it (project.itemMenu.label.