Merge upstream/main into cs-ui

The merge brought in Language::bcp47() without a Cs arm, so the branch did
not compile; added "cs". Translated the seven strings main added since
(audio output errors with the new ">" separator, the update check).
Czech: 3,617 rows, i18n-report 0 missing, ui-egui lib tests 244/0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Losminek
2026-10-26 18:48:53 +01:00
255 changed files with 16283 additions and 3785 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Bug report
description: Report a reproducible problem in FilmCraft.
labels: [bug]
body:
- type: markdown
attributes:
value: |
Please report one problem per issue and search existing issues first.
For security vulnerabilities, follow [SECURITY.md](https://github.com/storytold/filmcraft/blob/main/SECURITY.md) instead of posting sensitive details here.
- type: input
id: version
attributes:
label: FilmCraft version or commit
description: Copy the version from Help > About FilmCraft, or give the commit if built from source.
placeholder: "0.8.0"
validations:
required: true
- type: input
id: environment
attributes:
label: Operating system and architecture
placeholder: "Windows 11 x64, macOS 15 Apple silicon, Ubuntu 24.04 x86_64, or browser and version"
validations:
required: true
- type: dropdown
id: build
attributes:
label: Installation type
options:
- Official installer
- Portable archive or AppImage
- Flatpak
- Web app
- Built from source
- Other or unknown
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce
description: Start from opening a project. Include the panel, tool, menu item, or command used.
placeholder: |
1. Open or create...
2. Select...
3. Observe...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual behavior
description: Include any error message and whether it happens every time.
validations:
required: true
- type: textarea
id: document
attributes:
label: Media, sequence, export, and hardware details
description: If relevant, include container/codec, resolution, frame rate, bit depth/HDR, sequence settings, export preset, GPU/driver, and hardware decoding or encoding settings.
- type: textarea
id: evidence
attributes:
label: Screenshots, logs, or a minimal sample
description: Optional. Remove secrets, personal paths, and private project or media content. Attach only files you have permission to share; do not attach malicious files.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: Security vulnerability
url: https://github.com/storytold/filmcraft/blob/main/SECURITY.md
about: Read the reporting guidance before sharing sensitive details or malicious files.
- name: Community help
url: https://discord.gg/artcraft
about: Ask usage questions and discuss FilmCraft workflows with the community.
@@ -0,0 +1,33 @@
name: Feature request
description: Suggest an improvement to a FilmCraft workflow.
labels: [enhancement]
body:
- type: markdown
attributes:
value: |
Please search existing issues and describe one feature per request.
For usage questions, visit the [community chat](https://discord.gg/artcraft).
- type: textarea
id: problem
attributes:
label: The problem or workflow
description: What are you trying to accomplish, and what gets in the way?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Requested behavior
description: Describe how the feature would work from the user's point of view.
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives or workarounds
description: What have you tried, and where does it fall short?
- type: textarea
id: context
attributes:
label: Additional context
description: Optional examples, related issues, or your platform/version if relevant. Share only material you have permission to publish.
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
uses: storytold/craft-common-actions/.github/workflows/gate.yml@main
with:
# Same craft-fonts commit as release.yml.
craft-fonts-ref: abb83316d96aa59c1cf64784289e378fe9fa5695
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
timeout-minutes: 120
packages: >-
pkg-config libxkbcommon-dev libwayland-dev libx11-dev libxcb1-dev libxcursor-dev
+1 -1
View File
@@ -32,6 +32,6 @@ jobs:
# Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos.
uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main
with:
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
skip-tests: true
run-package-check: true
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
with:
app-name: filmcraft
app-title: FilmCraft
craft-fonts-ref: bfcbf714e45a3ea8e98f88ab2b7971372a576414
craft-fonts-ref: b076417b5880bd4154bd22f00695b1d28c6bea83
version: ${{ inputs.version }}
secrets:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
+8
View File
@@ -124,6 +124,14 @@ See [§0](#0-never-crash). It outranks every other engineering rule.
### 3.2 The rest
**Invariant — move identity uniqueness:** A move plan lists each clip identity at most once.
The core move operation rejects repeated ids before mutation or id allocation; placing one identity
on multiple tracks makes selection and later identity-based editing ambiguous. Copies receive new ids
and retain each entry's requested destination. Destination track creation and the edit commit together.
In an idle session, a rejected duplicate move retains the project and its undo and redo history.
Selection-derived edit plans keep each identity once in first-occurrence order before expanding
linked partners; explicit move requests with repeated identities are still rejected.
See `CLAUDE.md`: pure Rust, dependency layering (`cargo xtask layers`), exact `Tick` time, everything is
a command, everything is agent-drivable, and the quality gates (`cargo xtask ci`) before every commit.
+9
View File
@@ -173,3 +173,12 @@ SHA-256 (`crates/speech/src/models.rs`, `docs/transcripts.md`).
| Asset | Author | Source | Licence |
|---|---|---|---|
| Whisper tiny / base / small speech-recognition weights (`model.safetensors`, `config.json`, `generation_config.json`, `tokenizer.json`) | OpenAI | `https://huggingface.co/openai/whisper-{tiny,base,small}` (revisions `169d4a43…`, `e37978b9…`, `973afd24…`); weights released with `https://github.com/openai/whisper` | MIT (weights and code, github.com/openai/whisper); the Hugging Face conversion is labelled Apache-2.0 |
## egui dependency fonts
| Asset | Author | Source | Licence | Use |
|---|---|---|---|---|
| `epaint_default_fonts::EGUI_ICONS`, `epaint_default_fonts::EMOJI_ICON` (dependency font inputs) | John Slegers | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [emoji-icon-font](https://github.com/jslegers/emoji-icon-font) | MIT (Copyright (c) 2014 John Slegers; full upstream text in `NOTICE`) | egui-icons.ttf is the five-glyph subset, included by default_fonts independently of monochrome_emoji_fonts; emoji-icon-font.ttf is the full monochrome fallback. Dependency assets unmodified here; no font file copied into this repository. |
| `epaint_default_fonts::UBUNTU_LIGHT` (dependency font input) | Canonical Ltd. | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts) | Ubuntu Font Licence 1.0 (Copyright 2011 Canonical Ltd.; full upstream text in `NOTICE`) | Ubuntu-Light.ttf is egui's default proportional fallback, included by `default_fonts`; `system_fonts` is not enabled. Dependency asset unmodified here; no font file copied into this repository. |
| `epaint_default_fonts::NOTO_EMOJI_REGULAR` (dependency font input) | The Noto Project Authors (Google) | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Noto Emoji](https://fonts.google.com/noto/specimen/Noto+Emoji) | OFL-1.1 (Copyright 2013 Google Inc.; full upstream text in `NOTICE`) | NotoEmoji-Regular.ttf is the monochrome emoji fallback, included because the workspace enables `monochrome_emoji_fonts`. Dependency asset unmodified here; no font file copied into this repository. |
| `epaint_default_fonts::HACK_REGULAR` (dependency font input) | Source Foundry Authors; Bitstream Inc.; DejaVu contributors | [egui 6d518fc7](https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts), [Hack](https://github.com/source-foundry/Hack) | MIT (Copyright (c) 2018 Source Foundry Authors) and the Bitstream Vera License (Copyright (c) 2003 Bitstream, Inc.); the DejaVu work is public domain; full upstream notices in `NOTICE` | Hack-Regular.ttf is egui's default monospace fallback, included by `default_fonts`. Dependency asset unmodified here; no font file copied into this repository. |
Generated
+984 -322
View File
File diff suppressed because it is too large Load Diff
+29 -7
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
default-members = ["crates/*", "apps/*", "xtask"]
[workspace.package]
version = "0.12.0"
version = "0.13.0"
edition = "2024"
license = "MIT OR Apache-2.0"
rust-version = "1.95"
@@ -72,15 +72,19 @@ chrono = { version = "0.4.45", default-features = false, features = ["std"] }
rayon = "1"
proptest = "1"
roxmltree = "0.21"
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "f0ad6c1e1d297908915f4be315b0fcfc9be7b5d4", default-features = false }
craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7" }
craft-media = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", default-features = false }
# 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"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["serde", "native-windows"] }
# egui 0.37: bundled fonts are opt-in, and the monochrome symbol/emoji faces moved to their own
# feature. FilmCraft paints ✔ ✓ ✖ ⚡ ⚠ ⚙ ♪ ☑ in panel chrome, so both features are part of the
# production font policy (system_fonts stays off).
egui = { version = "0.37", features = ["default_fonts", "monochrome_emoji_fonts"] }
eframe = { version = "0.37", default-features = false }
egui_kittest = "0.37"
craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "b926b5c03e256c86ca10934f58d03f628810b5b7", features = ["egui"] }
rfd = "0.17"
[workspace.lints.rust]
@@ -153,3 +157,21 @@ opt-level = 3
[profile.release]
lto = "thin"
codegen-units = 1
# Keep the complete egui family and native event bridge on reviewed fork commits.
[patch.crates-io]
ecolor = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
emath = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
epaint_default_fonts = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-winit = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui-wgpu = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_glow = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_extras = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_kittest = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_system_font_provider = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
eframe = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_demo_lib = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
egui_inspection = { git = "https://github.com/storytold/egui.git", rev = "6d518fc799681571963842f95b55a6b68431b15a" }
winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457d84ee4975110bfda6797c44dc" }
+265
View File
@@ -19,3 +19,268 @@ Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
BIZ UDMincho, Noto Sans CJK SC, Noto Sans Arabic, Noto Sans Thai, Noto Sans Georgian) — OFL-1.1. Their licence texts ship with the packages as OFL-<family>.txt.
Public-domain film and NASA frames and CC0 music shown in README screenshots (docs/images/) — public domain / CC0-1.0. See ATTRIBUTION.md.
egui icon font subset (dependency asset)
----------------------------------------
epaint_default_fonts 0.37 embeds egui-icons.ttf, a five-glyph subset of
emoji-icon-font by John Slegers. The full monochrome emoji-icon-font fallback
uses the same MIT license. Upstream license text follows unchanged.
MIT License
Copyright (c) 2014 John Slegers
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Noto Emoji font (dependency asset)
----------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds NotoEmoji-Regular.ttf, the monochrome emoji fallback used when egui's
`monochrome_emoji_fonts` feature is enabled. The font's copyright notice and the
upstream SIL Open Font License 1.1 text follow unchanged.
Copyright 2013 Google Inc. All Rights Reserved.
This Font Software is licensed under the SIL Open Font License,
Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font
creation efforts of academic and linguistic communities, and to
provide a free and open framework in which fonts may be shared and
improved in partnership with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply to
any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software
components as distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to,
deleting, or substituting -- in part or in whole -- any of the
components of the Original Version, by changing formats or by porting
the Font Software to a new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed,
modify, redistribute, and sell modified and unmodified copies of the
Font Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components, in
Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the
corresponding Copyright Holder. This restriction only applies to the
primary font name as presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created using
the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Ubuntu Light font (dependency asset)
------------------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds Ubuntu-Light.ttf as a default fallback. The font's copyright notice and the
upstream Ubuntu Font Licence 1.0 text follow unchanged.
Copyright 2011 Canonical Ltd. Licensed under the Ubuntu Font Licence 1.0
-------------------------------
UBUNTU FONT LICENCE Version 1.0
-------------------------------
PREAMBLE
This licence allows the licensed fonts to be used, studied, modified and
redistributed freely. The fonts, including any derivative works, can be
bundled, embedded, and redistributed provided the terms of this licence
are met. The fonts and derivatives, however, cannot be released under
any other licence. The requirement for fonts to remain under this
licence does not require any document created using the fonts or their
derivatives to be published under this licence, as long as the primary
purpose of the document is not to be a vehicle for the distribution of
the fonts.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this licence and clearly marked as such. This may
include source files, build scripts and documentation.
"Original Version" refers to the collection of Font Software components
as received under this licence.
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to
a new environment.
"Copyright Holder(s)" refers to all individuals and companies who have a
copyright ownership of the Font Software.
"Substantially Changed" refers to Modified Versions which can be easily
identified as dissimilar to the Font Software by users of the Font
Software comparing the Original Version with the Modified Version.
To "Propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification and with or without charging
a redistribution fee), making available to the public, and in some
countries other activities as well.
PERMISSION & CONDITIONS
This licence does not grant any rights under trademark law and all such
rights are reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of the Font Software, to propagate the Font Software, subject to
the below conditions:
1) Each copy of the Font Software must contain the above copyright
notice and this licence. These can be included either as stand-alone
text files, human-readable headers or in the appropriate machine-
readable metadata fields within text or binary files as long as those
fields can be easily viewed by the user.
2) The font name complies with the following:
(a) The Original Version must retain its name, unmodified.
(b) Modified Versions which are Substantially Changed must be renamed to
avoid use of the name of the Original Version or similar names entirely.
(c) Modified Versions which are not Substantially Changed must be
renamed to both (i) retain the name of the Original Version and (ii) add
additional naming elements to distinguish the Modified Version from the
Original Version. The name of such Modified Versions must be the name of
the Original Version, with "derivative X" where X represents the name of
the new work, appended to that name.
3) The name(s) of the Copyright Holder(s) and any contributor to the
Font Software shall not be used to promote, endorse or advertise any
Modified Version, except (i) as required by this licence, (ii) to
acknowledge the contribution(s) of the Copyright Holder(s) or (iii) with
their explicit written permission.
4) The Font Software, modified or unmodified, in part or in whole, must
be distributed entirely under this licence, and must not be distributed
under any other licence. The requirement for fonts to remain under this
licence does not affect any document created using the Font Software,
except any version of the Font Software extracted from a document
created using the Font Software may only be distributed under this
licence.
TERMINATION
This licence becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER
DEALINGS IN THE FONT SOFTWARE.
Hack font (dependency asset)
----------------------------
epaint_default_fonts 0.37, pinned with egui 0.37 at
https://github.com/storytold/egui/tree/6d518fc799681571963842f95b55a6b68431b15a/crates/epaint_default_fonts/fonts,
embeds Hack-Regular.ttf as the default monospace fallback. The upstream notice and
licences follow unchanged.
The work in the Hack project is Copyright 2018 Source Foundry Authors and licensed under the MIT License
The work in the DejaVu project was committed to the public domain.
Bitstream Vera Sans Mono Copyright 2003 Bitstream Inc. and licensed under the Bitstream Vera License with Reserved Font Names "Bitstream" and "Vera"
MIT License
Copyright (c) 2018 Source Foundry Authors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
BITSTREAM VERA LICENSE
Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a trademark of Bitstream, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy of the fonts accompanying this license ("Fonts") and associated documentation files (the "Font Software"), to reproduce and distribute the Font Software, including without limitation the rights to use, copy, merge, publish, distribute, and/or sell copies of the Font Software, and to permit persons to whom the Font Software is furnished to do so, subject to the following conditions:
The above copyright and trademark notices and this permission notice shall be included in all copies of one or more of the Font Software typefaces.
The Font Software may be modified, altered, or added to, and in particular the designs of glyphs or characters in the Fonts may be modified and additional glyphs or characters may be added to the Fonts, only if the fonts are renamed to names not containing either the words "Bitstream" or the word "Vera".
This License becomes null and void to the extent applicable to Fonts or Font Software that has been modified and is distributed under the "Bitstream Vera" names.
The Font Software may be sold as part of a larger software package but no copy of one or more of the Font Software typefaces may be sold by itself.
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
Except as contained in this notice, the names of Gnome, the Gnome Foundation, and Bitstream Inc., shall not be used in advertising or otherwise to promote the sale, use or other dealings in this Font Software without prior written authorization from the Gnome Foundation or Bitstream Inc., respectively. For further information, contact: fonts at gnome dot org.
+37
View File
@@ -0,0 +1,37 @@
# Security policy
FilmCraft processes untrusted media, projects, interchange files, fonts and presets,
and provides CLI, control-channel and MCP automation. Security reports about those
inputs, automation boundaries, dependencies or release artifacts are welcome.
## Reporting a vulnerability
GitHub private vulnerability reporting is not currently enabled for this repository,
and no dedicated private security address is documented. Contact the maintainers
through the [project's existing community channels](https://discord.gg/artcraft)
with a minimal, non-exploitable description and ask for a private transfer method.
Do not publish a working exploit, malicious attachment, credential, private project,
or sensitive crash dump in a public issue or chat. Wait for a private channel before
sharing those details; if none is established, withhold sensitive material.
A useful report includes the FilmCraft version or commit, operating system and
architecture, affected component, impact, reproduction steps, and any workaround.
For media or project parsing problems, initially describe the file format, size,
cryptographic hash and whether the sample is synthetic or privately owned. Remove
personal paths, credentials and unrelated project content from logs. Coordinate
sample transfer with a maintainer instead of uploading a malicious file publicly.
Please allow maintainers time to investigate and coordinate a fix before disclosing
exploitable details. This policy does not promise a response deadline or a particular
supported-version window. Include the exact affected version and, when safe to do
so, whether the problem also occurs on the latest release.
## Regression samples
A regression fixture must be minimized, legally redistributable, free of private
content, and documented with the expected safe failure. Follow [AGENTS.md](AGENTS.md)
for clean-room implementation, asset attribution and the no-crash rules. Large media
fixtures belong in the project's approved corpus workflow rather than ordinary PRs.
Ordinary feature requests, editing problems and usage questions should use the
normal issue forms or community help channel.
+6 -1
View File
@@ -424,6 +424,10 @@ impl Services for WebServices {
}
}
thread_local! {
static DOWNLOAD_CLOCK: web_time::Instant = web_time::Instant::now();
}
/// Offer bytes as a browser download named `name`. Downloads are clicked at least
/// [`crate::download_pacing::GAP_MS`] apart: Chrome drops a burst of them (#378).
pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
@@ -434,7 +438,8 @@ pub fn download(name: &str, data: &[u8]) -> Result<(), wasm_bindgen::JsValue> {
a.set_href(&url);
a.set_download(file_name(name));
a.style().set_property("display", "none")?;
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(js_sys::Date::now()));
let now_ms = DOWNLOAD_CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0);
let delay = DOWNLOADS.with(|d| d.borrow_mut().reserve(now_ms));
let name = name.to_owned();
wasm_bindgen_futures::spawn_local(async move {
if delay > 0.0 {
+1 -1
View File
@@ -264,7 +264,7 @@ pub fn install_hooks(app: &mut FilmcraftApp) {
// Saving writes the file in memory and offers it as a download (`fs::WebServices`).
app.hooks.pick_save = Some(Box::new(|name: &str| Some(format!("/projects/{name}"))));
app.hooks.pick_save_as = Some(Box::new(|_filter: &str, _exts: &[&str], name: &str| {
let file = std::path::Path::new(name).file_name().map_or(name.into(), |n| n.to_string_lossy());
let file = std::path::Path::new(name).file_name().map_or(name.into(), std::ffi::OsStr::to_string_lossy);
Some(format!("/exports/{file}"))
}));
// Link Media ▸ Locate…, Attach Proxies, Reconnect Full Resolution: the picker is async, so the
+5 -2
View File
@@ -94,12 +94,13 @@ impl eframe::App for WebApp {
self.app.raw_input_hook(ctx, raw_input);
}
fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
/// Hidden logic pass: host actions, app logic, API replies, jobs, audio, autosave, then IME reset; it does not draw UI.
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let actions = ACTIONS.with(|a| std::mem::take(&mut *a.borrow_mut()));
for f in actions {
f(self, ctx);
}
self.app.logic(ctx, frame);
self.app.logic_inner(ctx);
api::poll_replies();
// exports advance between frames (no threads)
if self.app.session.pump_jobs(std::time::Duration::from_millis(30)) {
@@ -109,6 +110,7 @@ impl eframe::App for WebApp {
a.pump();
}
self.autosave.tick(&self.app.session, ctx);
self.app.finish_ime_reset_logic(ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
@@ -159,6 +161,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
canvas,
web_options,
Box::new(move |cc| {
filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx);
set_context(cc.egui_ctx.clone());
let ctx = cc.egui_ctx.clone();
fs::set_on_data(move || ctx.request_repaint());
+13 -3
View File
@@ -28,10 +28,18 @@ enum Outcome {
Clean { ok: bool },
}
#[derive(Default)]
pub struct Autosave {
policy: Policy,
outcomes: Rc<RefCell<Vec<Outcome>>>,
/// Monotonic host clock. Web `logic` may run for hidden tabs; egui's input time is stale there.
clock: web_time::Instant,
}
impl Default for Autosave {
/// Default policy with a monotonic host clock, so hidden-tab logic never uses a stale egui input time.
fn default() -> Self {
Self { policy: Policy::default(), outcomes: Rc::default(), clock: web_time::Instant::now() }
}
}
impl Autosave {
@@ -40,7 +48,7 @@ impl Autosave {
if !opfs::available() {
return;
}
let now = ctx.input(|i| i.time);
let now = self.clock.elapsed().as_secs_f64();
for o in self.outcomes.borrow_mut().drain(..) {
match o {
Outcome::Snapshot { revision, ok } => self.policy.written(revision, ok, now),
@@ -64,7 +72,9 @@ impl Autosave {
}
})
};
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision, "time": js_sys::Date::now()});
// No wall-clock timestamp: recovery policy scheduling uses the monotonic clock
// above, and the snapshot schema needs no display time.
let meta = json!({"dirty": true, "name": s.project.name, "revision": s.revision});
let d1 = done(self.outcomes.clone(), failed.clone(), left.clone(), ctx.clone());
let d2 = done(self.outcomes.clone(), failed, left, ctx.clone());
opfs::write_then(SNAPSHOT.into(), filmcraft_format::encode(&s.project, false), Some(d1));
+8 -1
View File
@@ -68,8 +68,15 @@ thread_local! {
static TRANSFERS: RefCell<Transfers> = RefCell::new(Transfers::default());
}
thread_local! {
/// Monotonic browser clock. `Date::now()` is wall time and can move backwards across NTP or
/// suspend/resume; codec idle/staleness accounting must not.
static CLOCK: web_time::Instant = web_time::Instant::now();
}
/// Milliseconds from a monotonic browser clock; codec idle and staleness accounting must never use wall time.
fn now_ms() -> f64 {
js_sys::Date::now()
CLOCK.with(|clock| clock.elapsed().as_secs_f64() * 1000.0)
}
fn get(o: &JsValue, k: &str) -> JsValue {
+8 -2
View File
@@ -27,7 +27,7 @@ filmcraft-gpu = { workspace = true }
filmcraft-ui-egui = { workspace = true }
filmcraft-platform = { workspace = true }
egui = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "wayland", "x11"] }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "clipboard", "wayland", "x11"] }
serde_json = { workspace = true }
# Constant-time comparison of control-channel credentials.
subtle = "2.6"
@@ -44,7 +44,7 @@ open = "5"
[dev-dependencies]
glob = "0.3"
# graphics.rs: enumerate the real adapters in a test.
pollster = "0.4"
pollster = "1"
[target.'cfg(target_os = "macos")'.dependencies]
muda = "0.21"
@@ -54,6 +54,9 @@ objc2 = "0.6"
# Same winit as eframe: macOS event-loop options (no app activation when driven by an agent).
winit = { version = "0.30", default-features = false }
[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies]
winit = { version = "0.30", default-features = false, features = ["x11"] }
[target.'cfg(target_os = "linux")'.dependencies]
# Settings ▸ Appearance ▸ Sync with system: the XDG desktop portal's colour scheme. The zbus the
# portal file dialogs (rfd) and accesskit already use.
@@ -62,3 +65,6 @@ zbus = "5.19"
# Icon + VERSIONINFO for filmcraft.exe (build.rs); only needed when building on Windows.
[target.'cfg(windows)'.build-dependencies]
winresource = "0.1"
[target.'cfg(windows)'.dependencies]
craft-update = { workspace = true }
+2 -1
View File
@@ -51,11 +51,12 @@ enum Note {
}
impl Note {
/// Localized status text for a fallback device (name interpolated through `fmt`) or for no output device.
fn text(&self) -> String {
use filmcraft_ui_egui::i18n::{fmt, t};
match self {
Note::Fallback(device) => fmt(t("Audio hardware: the default output device cannot be opened; using '{device}'"), &[("device", device)]),
Note::NoDevice => t("No audio output device found: playing without sound (check Settings ▸ Audio Hardware)").to_string(),
Note::NoDevice => t("No audio output device found: playing without sound (check Settings > Audio Hardware)").to_string(),
}
}
}
+18 -1
View File
@@ -78,6 +78,11 @@ fn tell(text: &str, error: bool) {
}
fn main() -> eframe::Result {
// Windows: the update helper is a copy of this program started with `--finish-update`.
#[cfg(windows)]
if let Some(result) = craft_update::self_update::finish(&craft_update::suite::FILMCRAFT, &std::env::args_os().skip(1).collect::<Vec<_>>()) {
std::process::exit(i32::from(result.is_err()));
}
// First, so the panic hook and every start-up record are logged (`logging`).
let logger = logging::install();
// lossy, so a value that is not Unicode is reported like any other bad port instead of ignored
@@ -190,6 +195,7 @@ fn native_options(agent: bool) -> eframe::NativeOptions {
fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<AtomicBool>) -> eframe::AppCreator<'static> {
let Launch { control_port, control_input_only, files, demo, startup_flag, recover, data_dir, graphics: _ } = launch;
Box::new(move |cc| {
filmcraft_ui_egui::install_ime_reset_callbacks(&cc.egui_ctx);
created.store(true, Ordering::Relaxed);
// A fallback graphics mode says so in the window, with a button back to the default.
let notice = graphics.degraded().then(|| {
@@ -248,6 +254,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
}
}
let mut app = FilmcraftApp::new(session);
app.set_startup_update_check(true);
app.graphics_notice = notice;
if recover == Some(false) {
app.dialog = None;
@@ -289,7 +296,7 @@ fn app_creator(launch: Launch, graphics: graphics::GraphicsStart, created: Arc<A
let path = std::path::Path::new(name);
let mut dialog = rfd::FileDialog::new()
.add_filter(filmcraft_ui_egui::i18n::t(filter), &file_filters::extensions(exts))
.set_file_name(path.file_name().map_or(name.into(), |n| n.to_string_lossy()));
.set_file_name(path.file_name().map_or(name.into(), std::ffi::OsStr::to_string_lossy));
if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
dialog = dialog.set_directory(dir);
}
@@ -435,6 +442,16 @@ fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
b.with_activate_ignoring_other_apps(false);
}));
}
// winit has no file drag-and-drop on Wayland (only on X11), so dropping files from the file
// manager showed a "no" cursor. Run through XWayland when it's there; FILMCRAFT_WAYLAND=1
// keeps the native Wayland backend.
#[cfg(all(unix, not(target_os = "macos")))]
if std::env::var_os("DISPLAY").is_some() && std::env::var_os("FILMCRAFT_WAYLAND").is_none() {
return Some(Box::new(|b| {
use winit::platform::x11::EventLoopBuilderExtX11;
b.with_x11();
}));
}
let _ = agent;
None
}
+1 -1
View File
@@ -17,7 +17,7 @@ serde_json = { workspace = true }
thiserror = { workspace = true }
rmcp = { version = "3.5", default-features = false, features = ["server", "macros", "transport-io"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-std", "sync", "net", "io-util", "time", "fs"] }
base64 = "0.22"
base64 = "0.23"
schemars = "1"
image = { version = "0.25", default-features = false, features = ["png"] }
tempfile = "3"
+3 -2
View File
@@ -4,6 +4,7 @@
//! `notifications/cancelled`, deleting the partial output. The session lock is only held for
//! short polls, so other requests are answered while the export runs.
use std::ffi::OsStr;
use std::path::Path;
use std::time::{Duration, SystemTime};
@@ -118,10 +119,10 @@ impl FilmcraftMcp {
/// never touched, even when they were saved during the export.
pub fn remove_partial(path: &str, since: SystemTime) {
let p = Path::new(path);
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(|s| s.to_str()), p.file_name().and_then(|s| s.to_str())) else {
let (Some(dir), Some(stem), Some(file)) = (p.parent(), p.file_stem().and_then(OsStr::to_str), p.file_name().and_then(OsStr::to_str)) else {
return;
};
let ext = p.extension().and_then(|s| s.to_str()).unwrap_or_default();
let ext = p.extension().and_then(OsStr::to_str).unwrap_or_default();
let dir = if dir.as_os_str().is_empty() { Path::new(".") } else { dir };
let Ok(entries) = std::fs::read_dir(dir) else { return };
// file times are coarser than the clock
+17 -1
View File
@@ -14,7 +14,7 @@ and writes caption files, converts them to and from caption tracks, and draws ca
| SubRip `.srt` | yes | yes | BOM, UTF-16, Windows-1252 fallback, CRLF/CR, missing or wrong indexes, `,` or `.` milliseconds, 1–9 fraction digits, missing hours, SSA coordinates, cues not separated by blank lines. Inline tags are kept as text. |
| WebVTT `.vtt` | yes | yes | Cue identifiers, cue settings (verbatim), a leading `<v Speaker>` voice span (as the caption's speaker) and STYLE / REGION / NOTE blocks are kept. A missing `WEBVTT` signature is tolerated. |
| Scenarist SCC `.scc` | yes | yes | CEA-608 at 29.97 fps, drop-frame or not. Reads pop-on, roll-up and paint-on (channel 1); writes pop-on. |
| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. |
| MacCaption MCC `.mcc` | yes | yes | One SMPTE ST 334-2 caption distribution packet (CDP) per frame carrying caption data, omitting empty frames, in MacCaption's hex text with letter abbreviations. Writes 29.97 fps (`30DF` / `30`) with CEA-608 field 1 (the SCC schedule) **and** CEA-708 service 1 (two-window pop-on emulation). Reads CEA-608 field 1 when present, else CEA-708 service 1; time code rates 30DF, 30, 60DF, 60, 25, 24, 50. |
| EBU STL `.stl` | yes | yes | EBU Tech 3264-E: GSI block + 128-byte TTI blocks, `STL25.01` (25 fps) or `STL30.01` (read/written as 29.97 fps, non-drop labels), ISO/IEC 6937 Latin text (accents as diacritic + letter), italics / underline codes as `<i>` / `<u>`, extension blocks for long subtitles, start-of-programme (TCP) subtracted. |
| TTML `.ttml` (IMSC1) | yes | yes | Writes the IMSC1 Text profile (TTML2 namespaces): `<p>` per cue, `<br/>`, styled `<span>`s for `<i>` / `<b>` / `<u>`, `ttm:agent` speakers, exact frame times (`123f`) at the sequence rate. Reads any TTML (TTML1, TTML2, the 2006 `ttaf1` drafts): clock / frame / offset / tick times, `dur`, nested time containers (`body` / `div` begins offset and ends clip their contents; `seq` containers are read as `par` with a warning), timed spans, referenced styles. |
| DFXP `.dfxp` | yes | yes | TTML1 with the DFXP presentation profile; clock times in milliseconds (frame-exact after `snap_to_frames`). |
@@ -64,6 +64,22 @@ Text is wrapped at 32 columns, bottom-aligned (last row 15) and centred. Charact
character, as the standard requires); others are dropped. If there is no room to load a caption
before its in point (for example a caption at 00:00:00:00), it appears as soon as it is loaded.
### MCC writer
The writer merges the scheduled CEA-608 and CEA-708 frames in timestamp order. Frames without
caption data are omitted, including during a displayed caption; its explicit erase/hide command
still lands at its out point. CDP sequence counters advance per emitted packet, wrapping at 16 bits.
Export work and output size therefore follow the scheduled caption data, not the time between
cues or their duration (#2811). Empty-frame insertion is an optional MCC export behavior; see
[EZTitles' MCC export documentation, "Write empty frames"](https://eztitles.com/Webhelp/EZTitles/export_subtitles_cc_mcc.htm).
FilmCraft does not offer continuous padding for consumers that require a packet on every video frame.
Regression tests cover sparse output, both caption streams individually and together, drop-frame
and non-drop timecodes, exact cue round trips, and timestamps near the tick storage limit.
An external FFmpeg 9.0.2 decode of the two-cue regression fixture preserved text and all four
timestamps (2.002–4.004 s and 120.120–122.122 s); the combined-stream export has 29 data lines,
down from 3,614. No media fixtures are committed.
## Burn-in
`burn::render_caption` lays out a caption with the track style (font size relative to a 1080-line
+77 -14
View File
@@ -1,4 +1,4 @@
//! MacCaption MCC (`.mcc`): closed-caption data as text, one line per video frame, each line a
//! MacCaption MCC (`.mcc`): closed-caption data as text, one line per frame carrying data, each line a
//! SMPTE ST 334-2 caption distribution packet (CDP) carrying CEA-608 byte pairs and CEA-708
//! (DTVCC) packets, written in hexadecimal with MacCaption's letter abbreviations.
//!
@@ -19,8 +19,8 @@
//! bytes)), optional time code / service info sections, footer `74`, the counter again and a
//! checksum that makes the packet's byte sum 0 mod 256. The ancillary packet then ends with one ST 291 checksum byte
//! (low 8 bits of DID + SDID + data count + CDP bytes).
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): every frame from the first caption
//! data to one frame after the last carries a CDP with 20 cc_data triplets: field 1 CEA-608
//! - **Writing** (29.97 fps, `30DF` or `30` timecode labels): each scheduled caption-data frame
//! carries a CDP with 20 cc_data triplets; empty frames are omitted. Each CDP contains field 1 CEA-608
//! pop-on data (the same schedule as [SCC](crate::scc)), a null field-2 pair, CEA-708
//! service 1 data and padding. The CEA-708 captions emulate pop-on with two windows: each
//! caption is defined (hidden) and loaded into one window while the other shows, then a
@@ -542,14 +542,19 @@ pub fn write(doc: &Document, drop_frame: bool) -> String {
write_with(doc, drop_frame, true, true)
}
/// [`write`] choosing which caption streams to include.
/// Write MCC with independently selected CEA-608 field 1 and CEA-708 service 1 streams.
///
/// Emit only scheduled data frames, preserving their timestamps and explicit erase/hide
/// commands. Empty time spans add no packets; CDP counters advance per emitted packet and
/// wrap at 16 bits. `drop_frame` selects 30DF rather than 30 timecode labels at 29.97 fps.
/// An empty document or two disabled streams produces a header with no data lines.
pub fn write_with(doc: &Document, drop_frame: bool, cea608: bool, cea708: bool) -> String {
let f1 = if cea608 { scc::schedule(doc) } else { BTreeMap::new() };
let dtv = if cea708 { schedule_708(doc) } else { BTreeMap::new() };
let mut out = String::from("File Format=MacCaption_MCC V1.0\n\n");
out.push_str("///////////////////////////////////////////////////////////////////////////////////\n");
out.push_str("// Closed captions written by FilmCraft: SMPTE ST 334-2 caption distribution packets\n");
out.push_str("// carrying CEA-608 field 1 and CEA-708 service 1 data, one packet per frame.\n");
out.push_str("// carrying CEA-608 field 1 and CEA-708 service 1 data; empty frames omitted.\n");
out.push_str("///////////////////////////////////////////////////////////////////////////////////\n\n");
let n = doc.cues.len() as u64;
let h = doc.cues.iter().fold(0xcbf2_9ce4_8422_2325u64 ^ n, |h, c| (h ^ c.start.0 as u64 ^ (c.end.0 as u64).rotate_left(17)).wrapping_mul(0x100_0000_01b3));
@@ -563,15 +568,20 @@ pub fn write_with(doc: &Document, drop_frame: bool, cea608: bool, cea708: bool)
));
out.push_str("Creation Program=FilmCraft\n");
out.push_str(&format!("Time Code Rate={}\n\n", if drop_frame { "30DF" } else { "30" }));
let first = f1.keys().next().copied().into_iter().chain(dtv.keys().next().copied()).min();
let last = f1.keys().next_back().copied().into_iter().chain(dtv.keys().next_back().copied()).max();
if let (Some(a), Some(b)) = (first, last) {
for (seq, f) in (a..=b + 1).enumerate() {
let data = FrameData { f1: f1.get(&f).copied(), dtvcc: dtv.get(&f).cloned().unwrap_or_default() };
let (_, hh, mm, ss, ff) = frames_to_fields(f, RATE, drop_frame);
let sep = if drop_frame { ';' } else { ':' };
out.push_str(&format!("{hh:02}:{mm:02}:{ss:02}{sep}{ff:02}\t{}\n", compress(&build_cdp(seq as u16, &data))));
}
// Merge the sparse schedules without walking (or allocating for) input-derived time gaps.
// Erase/hide commands are scheduled data too, so omitting padding preserves cue end times.
let mut f1 = f1.into_iter().peekable();
let mut dtv = dtv.into_iter().peekable();
let mut seq = 0u16;
while let Some(f) = f1.peek().map(|(f, _)| *f).into_iter().chain(dtv.peek().map(|(f, _)| *f)).min() {
let data = FrameData {
f1: f1.next_if(|(frame, _)| *frame == f).map(|(_, pair)| pair),
dtvcc: dtv.next_if(|(frame, _)| *frame == f).map(|(_, pairs)| pairs).unwrap_or_default(),
};
let (_, hh, mm, ss, ff) = frames_to_fields(f, RATE, drop_frame);
let sep = if drop_frame { ';' } else { ':' };
out.push_str(&format!("{hh:02}:{mm:02}:{ss:02}{sep}{ff:02}\t{}\n", compress(&build_cdp(seq, &data))));
seq = seq.wrapping_add(1);
}
out
}
@@ -664,10 +674,12 @@ pub fn parse(text: &str) -> Result<Document> {
mod tests {
use super::*;
/// Build a caption fixture from start/end frame indices on the MCC 29.97 fps grid.
fn cue(s: i64, e: i64, text: &str) -> Cue {
Cue { start: RATE.tick_of(s), end: RATE.tick_of(e), text: text.into(), ..Default::default() }
}
/// Check that MCC abbreviations round-trip bytes and reject an incomplete hex pair.
#[test]
fn abbreviations_round_trip() {
let b = [0x61, 0x01, 0x49, 0x96, 0x69, 0xfa, 0, 0, 0xfa, 0, 0, 0xfc, 0x80, 0x80, 0x00, 0x12, 0xe1, 0, 0, 0, 0xfb, 0x80, 0x80, 0xfd, 0x80, 0x80];
@@ -677,6 +689,7 @@ mod tests {
assert_eq!(expand("T52S5"), None);
}
/// Verify CDP framing, caption triplets, sequence fields, and both packet checksums.
#[test]
fn cdp_structure_and_checksum() {
let d = FrameData { f1: Some((0x14, 0x20)), dtvcc: vec![(true, 0x02, 0x21), (false, 0x89, 0x01)] };
@@ -697,6 +710,7 @@ mod tests {
assert_eq!(t[19], [0xfa, 0, 0]);
}
/// Preserve caption text and timing through combined-stream and CEA-708-only exports.
#[test]
fn writes_and_reads_608_and_708() {
let doc = Document {
@@ -717,6 +731,55 @@ mod tests {
assert!(parse("garbage").is_err());
}
/// Keep separated short captions compact while preserving cues and valid CDP packets.
/// Exercise each stream combination with both drop-frame and non-drop timecode labels.
#[test]
fn export_size_depends_on_caption_data_not_empty_frames() {
// A small reproducer: the old writer emits thousands of padding-only lines.
let doc = Document { cues: vec![cue(60, 120, "First"), cue(3_600, 3_660, "Last")], ..Default::default() };
for drop_frame in [false, true] {
for (cea608, cea708) in [(true, true), (true, false), (false, true)] {
let text = std::panic::catch_unwind(|| write_with(&doc, drop_frame, cea608, cea708)).unwrap();
let lines: Vec<_> = text.lines().filter_map(|line| line.split_once('\t')).collect();
assert!(lines.len() < 100, "{} lines for two short captions", lines.len());
assert!(text.len() < 16_384);
assert_eq!(parse(&text).unwrap().cues, doc.cues);
for (seq, (_, data)) in lines.iter().enumerate() {
let bytes = expand(data).unwrap();
assert_eq!(u16::from_be_bytes([bytes[8], bytes[9]]), seq as u16);
assert_eq!(bytes[3..76].iter().map(|&b| b as u32).sum::<u32>() % 256, 0);
let trips = cc_triplets(&bytes).unwrap();
assert!(trips.iter().any(|&[m, a, b]| m & 4 != 0 && (m & 3 >= 2 || a & 0x7f != 0 || b & 0x7f != 0)));
}
}
}
}
/// Bound output for distant or long cues near the tick limit without changing their timing.
#[test]
fn distant_and_long_cues_round_trip_near_tick_limit() {
let last = RATE.frame_at(crate::Tick(i64::MAX)) - 10;
for cues in [vec![cue(60, 120, "First"), cue(last - 60, last, "Last")], vec![cue(60, last, "Long")]] {
let doc = Document { cues, ..Default::default() };
for drop_frame in [false, true] {
for (cea608, cea708) in [(true, true), (true, false), (false, true)] {
let text = std::panic::catch_unwind(|| write_with(&doc, drop_frame, cea608, cea708)).unwrap();
assert!(text.len() < 16_384);
assert_eq!(parse(&text).unwrap().cues, doc.cues);
}
}
}
}
/// Emit only the MCC header when the document is empty or both streams are disabled.
#[test]
fn empty_schedules_have_no_data_lines() {
let doc = Document { cues: vec![cue(60, 120, "Caption")], ..Default::default() };
assert!(!write_with(&doc, true, false, false).contains('\t'));
assert!(!write(&Document::default(), true).contains('\t'));
}
/// Prevent CEA-708 text and line breaks from overflowing a window's declared dimensions.
#[test]
fn decoder_keeps_708_text_within_declared_window_dimensions() {
let mut decoder = Dec708::new();
+2 -3
View File
@@ -18,7 +18,7 @@ use std::collections::{HashMap, HashSet};
use filmcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
use crate::{Cue, Document, Error, Result, TICKS_PER_MS};
use crate::{Cue, Document, Error, Result};
/// Which TTML flavour to write.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -116,8 +116,7 @@ fn esc(s: &str) -> String {
}
fn clock(t: Tick) -> String {
let ms = (t.0.max(0) + TICKS_PER_MS / 2) / TICKS_PER_MS;
format!("{:02}:{:02}:{:02}.{:03}", ms / 3_600_000, (ms / 60_000) % 60, (ms / 1000) % 60, ms % 1000)
crate::format_clock(t, '.')
}
/// Time expression for `t`: frames at `rate` when it is on a frame boundary, else clock time.
+1
View File
@@ -1,3 +1,4 @@
mod roundtrip;
mod ttml_body_timing;
mod ttml_clock_overflow;
mod ttml_speakers;
@@ -0,0 +1,18 @@
//! TTML / DFXP export must not overflow for cue times near `i64::MAX` (e.g. from an MCC with a
//! huge timecode).
use filmcraft_captions::{Cue, Document, Format, WriteOptions, write};
use filmcraft_time::Tick;
fn doc(end: Tick) -> Document {
Document { cues: vec![Cue { start: Tick(0), end, text: "x".into(), ..Cue::default() }], ..Document::default() }
}
#[test]
fn ttml_and_dfxp_write_survive_tick_max() {
for format in [Format::Ttml, Format::Dfxp] {
let out = write(&doc(Tick(i64::MAX)), format, WriteOptions::default());
let s = String::from_utf8(out).unwrap();
assert!(s.contains("end=\""), "{format:?}: {s}");
}
}
+13 -7
View File
@@ -337,10 +337,12 @@ impl AviSource {
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 mut w = st.audio.clone().unwrap_or_default();
let Ok((sample_rate, channels)) = filmcraft_media::bounded_audio_dimensions(w.sample_rate, u32::from(w.channels)) else { continue };
w.sample_rate = sample_rate;
w.channels = u16::try_from(channels).unwrap_or(u16::MAX);
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 (mut sample_rate, mut channels) = (sample_rate, channels);
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);
@@ -537,14 +539,18 @@ impl MediaSource for AviSource {
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 source_frames = filmcraft_media::resample_source_frames(frames, ainfo.sample_rate, sample_rate)?;
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
let ratio = f64::from(ainfo.sample_rate) / f64::from(sample_rate);
let s0 = (start as f64 * ratio).floor() as i64;
let need = (frames as f64 * ratio).ceil() as i64 + 2;
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; 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 {
if pk_start >= s0.saturating_add(need_i64) {
break;
}
let pk = self.audio_packet(a, &mut st, ainfo, i)?;
+21 -5
View File
@@ -463,7 +463,7 @@ const CONTINUE_THROUGH: usize = 48;
fn decode_pool() -> Option<&'static rayon::ThreadPool> {
static POOL: std::sync::OnceLock<Option<rayon::ThreadPool>> = std::sync::OnceLock::new();
POOL.get_or_init(|| {
let n = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(1);
let n = std::thread::available_parallelism().map(std::num::NonZeroUsize::get).unwrap_or(1);
rayon::ThreadPoolBuilder::new().num_threads(n).thread_name(|i| format!("decode-{i}")).build().ok()
})
.as_ref()
@@ -494,7 +494,7 @@ fn isolated<T: Send>(f: impl FnOnce() -> crate::Result<T> + Send) -> crate::Resu
let job = slot.lock().unwrap_or_else(PoisonError::into_inner).take();
job.map(|job| pool.install(job))
});
spawned.map(|h| h.join())
spawned.map(std::thread::ScopedJoinHandle::join)
});
return match waited {
Ok(Ok(Some(r))) => r,
@@ -672,7 +672,7 @@ impl GopCache {
// after the guard on every return path: dropping a decoder can block (a hardware session
// waits for its frames in flight) and must not hold this cache's lock.
let mut released: Vec<Box<dyn VideoDecoder>> = Vec::new();
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let mut st = self.shared.state.lock().unwrap_or_else(PoisonError::into_inner);
let _decoding = DecodingGuard::enter(me);
st.last_used = Instant::now();
st.pin = want_pts;
@@ -875,7 +875,7 @@ impl GopCache {
count_frames(&out);
out.into_iter().map(|d| (d.pts, self.finish(d.frame), d.draft)).collect::<Vec<_>>()
});
let mut st = self.shared.state.lock().unwrap_or_else(|e| e.into_inner());
let mut st = self.shared.state.lock().unwrap_or_else(PoisonError::into_inner);
if st.spare.len() < 16 {
st.spare.push(dec);
}
@@ -1082,6 +1082,8 @@ mod tests {
use rayon::prelude::*;
let s = Arc::new(ParSamples(samples(240, 30, 4, false)));
let (tx, rx) = std::sync::mpsc::channel();
let done = Arc::new(AtomicUsize::new(0));
let counted = done.clone();
std::thread::spawn(move || {
for round in 0..40usize {
// export-like: many frame workers, frames of several GOPs of a fresh source at once
@@ -1089,11 +1091,25 @@ mod tests {
(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);
counted.fetch_add(1, Ordering::Relaxed);
});
}
let _ = tx.send(());
});
assert!(rx.recv_timeout(Duration::from_secs(120)).is_ok(), "frame requests deadlocked");
// A deadlock stops every request of a round for good. On a loaded machine the 3,840
// requests can take minutes, so the limit applies to the time between two answered ones.
let mut answered = 0;
loop {
match rx.recv_timeout(Duration::from_secs(120)) {
Ok(()) => break,
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
let now = done.load(Ordering::Relaxed);
assert!(now > answered, "frame requests deadlocked after {now} frames");
answered = now;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => panic!("a frame request failed"),
}
}
}
#[test]
+2 -1
View File
@@ -77,7 +77,7 @@ impl From<CodecError> for filmcraft_media::MediaError {
}
/// Our own (pure-Rust) decoders, in the order they are tried.
const BUILTIN_FACTORIES: [VideoDecoderFactory; 10] = [
const BUILTIN_FACTORIES: [VideoDecoderFactory; 11] = [
video::h264_factory,
video::hevc_factory,
video::vp8_factory,
@@ -88,6 +88,7 @@ const BUILTIN_FACTORIES: [VideoDecoderFactory; 10] = [
video::dnx_factory,
video::mjpeg_factory,
video::mpeg2_factory,
video::raw_factory,
];
fn factories() -> &'static RwLock<Vec<VideoDecoderFactory>> {
+25 -18
View File
@@ -142,6 +142,20 @@ fn codec_delay_ticks(t: &filmcraft_matroska::Track) -> i64 {
i64::try_from((t.codec_delay_ns as i128 + scale_ns / 2) / scale_ns).unwrap_or(i64::MAX)
}
/// The stream description of an audio track, `None` when its advertised sample rate or channel
/// count is out of range (see [`filmcraft_media::bounded_audio_dimensions`]).
fn audio_info(t: &filmcraft_matroska::Track) -> Option<AudioStreamInfo> {
let a = t.audio.clone().unwrap_or_default();
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
let mut channels = (a.channels as u32).max(1);
if let Some(h) = opus_head(&t.codec) {
rate = crate::audio::OPUS_RATE;
channels = h.channels as u32;
}
let (rate, channels) = filmcraft_media::bounded_audio_dimensions(rate, channels).ok()?;
Some(AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) })
}
/// The track timestamp that media time zero stands for (#715): the earliest start among the
/// playable tracks. Block timestamps count from the start of the Segment, and a remux that keeps
/// its source's timestamps (`ffmpeg -copyts`, a cut from a recording) starts its first picture
@@ -339,7 +353,9 @@ impl MkvSource {
.tracks
.iter()
.enumerate()
.filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty())
// A track whose advertised dimensions are refused is no stream at all, so `atracks[k]` and
// `info.audio_streams[k]` stay the same track.
.filter(|(_, t)| t.kind == TrackKind::Audio && !t.samples.is_empty() && audio_info(t).is_some())
.map(|(i, _)| i)
.take(filmcraft_media::MAX_AUDIO_STREAMS)
.collect();
@@ -407,20 +423,7 @@ impl MkvSource {
hdr: v.color.as_ref().and_then(hdr_metadata),
}
});
let audio_streams: Vec<AudioStreamInfo> = atracks
.iter()
.filter_map(|&i| file.tracks.get(i))
.map(|t| {
let a = t.audio.clone().unwrap_or_default();
let mut rate = a.output_sampling_frequency.unwrap_or(a.sampling_frequency).round().max(1.0) as u32;
let mut channels = (a.channels as u32).max(1);
if let Some(h) = opus_head(&t.codec) {
rate = crate::audio::OPUS_RATE;
channels = h.channels as u32;
}
AudioStreamInfo { sample_rate: rate, channels, codec: codec_label(&t.codec), bits_per_sample: a.bit_depth.map(|b| b as u32) }
})
.collect();
let audio_streams: Vec<AudioStreamInfo> = atracks.iter().filter_map(|&i| file.tracks.get(i)).filter_map(audio_info).collect();
let info = MediaInfo {
name: name.to_string(),
kind: if video.is_some() { MediaKind::Movie } else { MediaKind::AudioOnly },
@@ -628,15 +631,19 @@ impl MediaSource for MkvSource {
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
let src_rate = ainfo.sample_rate;
let ch = ainfo.channels.max(1) as usize;
let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?;
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
let ratio = src_rate as f64 / sample_rate as f64;
let s0 = (start as f64 * ratio).floor() as i64;
let need = (frames as f64 * ratio).ceil() as i64 + 2;
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; 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 i < n_packets {
let Some(&pk_start) = a.starts.get(i) else { break };
if pk_start >= s0 + need {
if pk_start >= s0.saturating_add(need_i64) {
break;
}
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
+12 -4
View File
@@ -484,20 +484,24 @@ impl MediaSource for Mp4Source {
let ainfo = self.info.audio_streams.get(stream).ok_or(MediaError::NoStream("audio"))?;
let src_rate = ainfo.sample_rate;
let ch = ainfo.channels.max(1) as usize;
let source_frames = filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)?;
let need = source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))?;
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
// Map the requested window to source samples (edit list offset applied: presentation = pts + edit_offset).
let ratio = src_rate as f64 / sample_rate as f64;
let p0 = (start as f64 * ratio).floor() as i64;
// (in i128: a hostile edit list can make the offset large enough to overflow i64)
let offset = i128::from(a.offset) * i128::from(src_rate) / i128::from(track.timescale.max(1));
let s0 = i64::try_from(i128::from(p0) - offset).unwrap_or(if offset > 0 { i64::MIN } else { i64::MAX });
let need = (frames as f64 * ratio).ceil() as i64 + 2;
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
let mut st = a.state.lock().unwrap_or_else(|e| e.into_inner());
let first = a.starts.partition_point(|&x| x <= s0.max(0)).saturating_sub(1);
let mut i = first;
while let Some(sample) = track.samples.get(i) {
let Some(&pk_start) = a.starts.get(i) else { break };
if pk_start >= s0.saturating_add(need) {
if pk_start >= s0.saturating_add(need_i64) {
break;
}
let pk = self.audio_packet(a, ainfo, &mut st, i)?;
@@ -589,7 +593,8 @@ fn audio_stream_info(t: &filmcraft_isobmff::Track, bytes: &crate::Src) -> Option
}
c => codec_label(c),
};
Some(AudioStreamInfo { sample_rate: rate.max(1), channels: ch.max(1), codec, bits_per_sample: bits })
let (rate, ch) = filmcraft_media::bounded_audio_dimensions(rate, ch).ok()?;
Some(AudioStreamInfo { sample_rate: rate, channels: ch, codec, bits_per_sample: bits })
}
fn codec_label(c: &CodecConfig) -> String {
@@ -609,6 +614,9 @@ fn codec_label(c: &CodecConfig) -> String {
CodecConfig::Hevc(_) => "HEVC".into(),
CodecConfig::Vp9(c) => format!("VP9 (Profile {})", c.profile),
CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"vp08" => "VP8".into(),
CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"raw " => "Uncompressed RGB".into(),
CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"2vuy" => "Uncompressed YUV 4:2:2".into(),
CodecConfig::Unknown { fourcc, .. } if fourcc.0 == *b"v210" => "Uncompressed YUV 4:2:2 10-bit".into(),
CodecConfig::Av1(c) => format!("AV1 ({} Profile)", ["Main", "High", "Professional"].get(c.seq_profile as usize).unwrap_or(&"Main")),
CodecConfig::Apv(a) => match filmcraft_apv::Profile::from_idc(a.profile_idc) {
Some(p) => p.name().into(),
+19 -7
View File
@@ -330,7 +330,7 @@ impl MxfSource {
if let Some(&a0) = atracks.first() {
let si0 = file.tracks[a0].sound.clone().unwrap_or_default();
let rate = si0.sample_rate;
let mut channels = 0;
let mut channels: u32 = 0;
for &ai in &atracks {
let t = &file.tracks[ai];
let Some(si) = t.sound.as_ref() else { continue };
@@ -342,11 +342,18 @@ impl MxfSource {
let stored = t.stored_sample_frames().saturating_sub(first);
let presented = t.duration.map_or(stored, |du| ((du as i128 * n as i128 / d.max(1) as i128) as u64).min(stored));
aframes = aframes.min(presented);
channels += si.channels.max(1);
let next =
channels.checked_add(si.channels.max(1)).ok_or_else(|| CodecError::Unsupported("MXF: combined audio channel count overflows".into()))?;
if next > filmcraft_media::MAX_AUDIO_CHANNELS {
return Err(CodecError::Unsupported(format!("MXF: combined audio exceeds {} channels", filmcraft_media::MAX_AUDIO_CHANNELS)));
}
channels = next;
combined.push((ai, first));
}
let sr = if rate.is_valid() { (rate.num as f64 / rate.den as f64).round() as u32 } else { 48_000 };
audio = Some(AudioStreamInfo { sample_rate: sr.max(1), channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) });
if let Ok((sr, channels)) = filmcraft_media::bounded_audio_dimensions(sr, channels) {
audio = Some(AudioStreamInfo { sample_rate: sr, channels, codec: file.tracks[a0].codec.name().into(), bits_per_sample: Some(si0.bits) });
}
}
if aframes == u64::MAX {
aframes = 0;
@@ -461,15 +468,20 @@ impl MediaSource for MxfSource {
let ainfo = self.info.audio().ok_or(MediaError::NoStream("audio"))?;
let src_rate = ainfo.sample_rate;
let ch = ainfo.channels.max(1) as usize;
let exact = src_rate == sample_rate;
let source_frames = if exact { frames } else { filmcraft_media::resample_source_frames(frames, src_rate, sample_rate)? };
let need =
if exact { source_frames } else { source_frames.checked_add(2).ok_or_else(|| MediaError::Decode("audio source window overflows".into()))? };
filmcraft_media::validate_audio_buffer_shape(ch, need)?;
filmcraft_media::validate_audio_buffer_shape(ch, frames)?;
let mut out = AudioBuffer::silence(sample_rate, ch, frames);
let ratio = src_rate as f64 / sample_rate as f64;
let exact = src_rate == sample_rate;
let s0 = if exact { start } else { (start as f64 * ratio).floor() as i64 };
let need = if exact { frames as i64 } else { (frames as f64 * ratio).ceil() as i64 + 2 };
let need_i64 = i64::try_from(need).unwrap_or(i64::MAX);
// clip the request to the presented range [0, aframes)
let a = s0.max(0);
let b = (s0 + need).min(self.aframes as i64);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need.max(0) as usize]; ch];
let b = s0.saturating_add(need_i64).min(self.aframes as i64);
let mut src: Vec<Vec<f32>> = vec![vec![0.0; need]; ch];
if b > a {
let mut c0 = 0;
for &(ti, first) in &self.atracks {
+27
View File
@@ -236,6 +236,33 @@ fn mp4_flac_audio() {
assert!(peak > 0.05 && peak < 1.0, "audio peak {peak}");
}
/// #2340: uncompressed QuickTime video (`raw ` 24-bit RGB, `2vuy`, `v210`) decodes, with fl32
/// audio, instead of being reported unreadable.
#[test]
fn quicktime_uncompressed_video() {
let src = ["-f", "lavfi", "-i", "color=c=0x3060C0:s=64x36:r=24:d=0.5", "-f", "lavfi", "-i", "sine=frequency=1000:sample_rate=48000:d=0.5"];
for (name, vcodec) in [
("raw_rgb24.mov", ["-c:v", "rawvideo", "-pix_fmt", "rgb24"]),
("raw_2vuy.mov", ["-c:v", "rawvideo", "-pix_fmt", "uyvy422"]),
("raw_v210.mov", ["-c:v", "v210", "-pix_fmt", "yuv422p10le"]),
] {
let args: Vec<&str> = src.iter().copied().chain(vcodec).chain(["-c:a", "pcm_f32be", "-shortest"]).collect();
let Some(b) = fixture(name, &args) else { return };
let src = crate::open_bytes(name, b).unwrap_or_else(|e| panic!("{name}: {e}"));
let f = src.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND / 4), scale: 1.0 }).unwrap_or_else(|e| panic!("{name}: {e}"));
assert_eq!((f.width, f.height), (64, 36), "{name}");
assert!(src.info().video.as_ref().is_some_and(|v| v.codec.starts_with("Uncompressed")), "{name}: {:?}", src.info().video);
let rgba = f.to_rgba8();
let px = &rgba[(18 * 64 + 32) * 4..][..3];
for (got, want) in px.iter().zip([0x30u8, 0x60, 0xc0]) {
assert!((*got as i32 - want as i32).abs() <= 6, "{name}: {px:?}");
}
let a = src.audio(12_000, 4800, 48_000).expect("fl32 audio");
let peak = a.channels[0].iter().fold(0f32, |m, s| m.max(s.abs()));
assert!(peak > 0.05 && peak < 1.0, "{name}: 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"]);
+60 -1
View File
@@ -1094,6 +1094,13 @@ fn esds_object_type(raw: &[u8]) -> Option<u8> {
raw.get(p).copied()
}
/// Uncompressed QuickTime / MP4 video (`raw ` 24-bit RGB, `2vuy`, `v210`, #2340).
pub fn raw_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
let v = e.video.as_ref()?;
let format = RawFormat::from_mov(e.format.0, v.depth)?;
Some(RawVideoDecoder::new(format, u32::from(v.width), u32::from(v.height), false).map(|d| Box::new(d) as Box<dyn VideoDecoder>))
}
pub fn mpeg2_factory(e: &SampleEntry) -> Option<Result<Box<dyn VideoDecoder>>> {
match &e.codec {
CodecConfig::Unknown { fourcc, raw } if is_mpeg2_entry(&fourcc.0, raw) => {
@@ -1168,6 +1175,11 @@ pub enum RawFormat {
Nv12,
/// Luma only (`Y800`, `GREY`, `Y8 `).
Gray8,
/// 24-bit R, G, B; rows unpadded (QuickTime `raw ` at depth 24).
Rgb24,
/// 10-bit 4:2:2 Y'CbCr, six pixels in four little-endian words, rows padded to 128 bytes
/// (QuickTime `v210`); shown at 8 bits.
V210,
}
impl RawFormat {
@@ -1193,13 +1205,24 @@ impl RawFormat {
})
}
/// The layout of a QuickTime / MP4 video sample entry (`raw ` RGB at depth 24, `2vuy`, `v210`).
pub fn from_mov(fourcc: [u8; 4], depth: u16) -> Option<RawFormat> {
match (&fourcc, depth) {
(b"raw ", 24) => Some(RawFormat::Rgb24),
(b"2vuy", _) => Some(RawFormat::Uyvy),
(b"v210", _) => Some(RawFormat::V210),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
RawFormat::Bgr24 => "Uncompressed RGB 24-bit",
RawFormat::Bgr24 | RawFormat::Rgb24 => "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",
RawFormat::V210 => "Uncompressed YUV 4:2:2 10-bit",
}
}
}
@@ -1254,6 +1277,42 @@ impl RawVideoDecoder {
}
VideoFrame::rgba8(self.width, self.height, rgba)
}
RawFormat::Rgb24 => {
let stride = w * 3;
if s.len() < stride * h {
return Err(short());
}
let mut rgba = Vec::with_capacity(w * h * 4);
for px in s.get(..stride * h).unwrap_or_default().as_chunks::<3>().0 {
rgba.extend_from_slice(&[px[0], px[1], px[2], 255]);
}
VideoFrame::rgba8(self.width, self.height, rgba)
}
RawFormat::V210 => {
// 6 pixels per 16 bytes; each row padded to a multiple of 128 bytes (48 pixels)
let stride = w.div_ceil(48) * 128;
if s.len() < stride * h {
return Err(short());
}
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) {
// the row's 10-bit samples in order: Cb Y Cr Y Cb Y Cr Y …, kept at 8 bits
let mut comps = row.as_chunks::<4>().0.iter().flat_map(|b| {
let word = u32::from_le_bytes(*b);
[word & 0x3ff, (word >> 10) & 0x3ff, (word >> 20) & 0x3ff].map(|c| (c >> 2) as u8)
});
for x in 0..cw {
let (Some(cb), Some(y0), Some(cr), Some(y1)) = (comps.next(), comps.next(), comps.next(), comps.next()) else { break };
u.push(cb);
v.push(cr);
y.push(y0);
if 2 * x + 1 < w {
y.push(y1);
}
}
}
self.yuv([y, u, v], filmcraft_frame::Chroma::C422)
}
RawFormat::Yuy2 | RawFormat::Uyvy => {
let stride = cw * 4;
if s.len() < stride * h {
+2 -1
View File
@@ -1,4 +1,4 @@
//! Draft decoding of VP9, HEVC and AV1 through the media stack (as `h264_draft.rs` for H.264):
//! Draft decoding of H.264, VP9, HEVC and AV1 through the media stack:
//! off by default, approximate frames only reach draft requests, and decoding for an export after
//! draft playback (same source, same GOP cache) still matches ffmpeg bit for bit.
@@ -50,6 +50,7 @@ fn check(name: &str, args: &[&str], w: usize, h: usize) {
/// One test (the GOP cache statistics are process-wide, so the codecs run one after another).
#[test]
fn draft_playback_never_reaches_exports() {
crate::h264_draft::check();
vp9();
hevc();
av1();
+3 -2
View File
@@ -8,8 +8,9 @@ use common::*;
use filmcraft_media::FrameRequest;
use filmcraft_media::cancel::with_draft;
#[test]
fn draft_playback_never_reaches_exports() {
/// Verify H.264 default, draft and export decoding against ffmpeg.
/// Called by the shared draft-codec test because GOP statistics are process-wide.
pub(super) fn check() {
let ff = filmcraft_testkit::require_ffmpeg!();
// B-pyramid: references and non-reference B pictures (the ones draft mode leaves unfiltered)
let Some(file) = fixture(
+17 -1
View File
@@ -226,7 +226,14 @@ impl Lut {
return Err(err("keyword after table data"));
}
match key {
"TITLE" => title = line[5..].trim().trim_matches('"').to_string(),
"TITLE" => {
// a '#' inside a quoted title is part of the title, not a comment
let rest = raw.trim_start()[5..].trim_start();
title = match rest.strip_prefix('"').and_then(|q| q.split_once('"')) {
Some((t, _)) => t.to_string(),
None => line[5..].trim().trim_matches('"').to_string(),
};
}
"LUT_1D_SIZE" => {
size1 = it.next().and_then(|v| v.parse().ok()).ok_or_else(|| err("bad LUT_1D_SIZE"))?;
if !(2..=65536).contains(&size1) {
@@ -559,6 +566,15 @@ mod tests {
}
}
#[test]
fn cube_title_with_hash_roundtrips() {
let mut c = Lut3d::identity(2);
c.title = "Look #2".into();
assert_eq!(Lut3d::parse_cube(&c.to_cube()).unwrap().title, "Look #2");
let text = c.to_cube().replace("TITLE \"Look #2\"", "TITLE \"Look\" # note");
assert_eq!(Lut::parse_cube(&text).unwrap().title, "Look");
}
#[test]
fn cube_roundtrip_3d_1d_and_shaper() {
let mut c = wild(17);
+1 -1
View File
@@ -311,7 +311,7 @@ pub fn decode(data: &[u8], max_samples: usize) -> Result<Decoded> {
}
let mut hufs = Vec::with_capacity(comps.len());
for c in &comps {
hufs.push(tables.get(c.table).and_then(|t| t.as_ref()).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?);
hufs.push(tables.get(c.table).and_then(Option::as_ref).ok_or(Error::Malformed("lossless JPEG: missing Huffman table"))?);
}
let samples = scan(data, pos + len, &hufs, width, height, precision, predictor, pt, restart_interval)?;
return Ok(Decoded { width, height, components: comps.len(), precision, predictor, samples });
+51 -39
View File
@@ -72,48 +72,60 @@ fn sweep() {
for n in depth.truncations(seed.len()) {
run(&seed[..n], &format!("seed {si} truncate {n}"));
}
// The header (and the start of the scan-line index): every byte to extreme values, every bit.
for p in 0..seed.len().min(0x300) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
let mut d = seed.clone();
d[p] = v;
run(&d, &format!("seed {si} byte {p:#x}={v:#x}"));
header_flips(&depth, seed, si);
big_fields(seed, si);
random_rounds(&depth, seed, si);
}
}
/// The header (and the start of the scan-line index): every byte to extreme values, every bit.
fn header_flips(depth: &Depth, seed: &[u8], si: usize) {
for p in 0..seed.len().min(0x300) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.clone();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p:#x}.{b}"));
let mut d = seed.to_vec();
d[p] = v;
run(&d, &format!("seed {si} byte {p:#x}={v:#x}"));
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.to_vec();
d[p] ^= 1 << b;
run(&d, &format!("seed {si} flip {p:#x}.{b}"));
}
}
}
/// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C).
fn big_fields(seed: &[u8], si: usize) {
for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] {
let mut d = seed.to_vec();
if let Some(s) = d.get_mut(off..off + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u16 {off:#x}={v:#x}"));
}
}
fn random_rounds(depth: &Depth, seed: &[u8], si: usize) {
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.to_vec();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
// Big dimension fields with the rest intact (lines 0x18, width 0x1A, macroblock rows 0x16C).
for (off, v) in [(0x18usize, 0xffffu16), (0x1a, 0xffff), (0x18, 0x4000), (0x1a, 0x4000), (0x16c, 0xffff)] {
let mut d = seed.clone();
if let Some(s) = d.get_mut(off..off + 2) {
s.copy_from_slice(&v.to_be_bytes());
}
run(&d, &format!("seed {si} u16 {off:#x}={v:#x}"));
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ si as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.clone();
for _ in 0..1 + rng.below(8) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
if depth.round(i) {
run(&d, &format!("seed {si} random {i}"));
}
if depth.round(i) {
run(&d, &format!("seed {si} random {i}"));
}
}
}
+6
View File
@@ -556,6 +556,12 @@ pub fn close_gap(seq: &mut Sequence, track: TrackId, t: Tick) -> Result<()> {
/// Move items by (track, time) offsets with overwrite (default drag) or insert (Cmd-drag) semantics.
/// `moves`: (item, destination track, new start).
pub fn move_items(seq: &mut Sequence, moves: &[(ClipId, TrackId, Tick)], insert_mode: bool, ctx: &mut EditCtx) -> Result<()> {
let mut seen = std::collections::HashSet::new();
for (clip, _, _) in moves {
if !seen.insert(*clip) {
return Err(EditError::Other(format!("clip {} is listed more than once in this move", clip.0)));
}
}
let mut work = seq.clone();
let mut placed = Vec::new();
for (c, dest, start) in moves {
+15
View File
@@ -77,6 +77,21 @@ impl Fx {
}
}
#[test]
fn move_rejects_repeated_clip_ids_without_mutation() {
for insert_mode in [false, true] {
let mut fx = Fx::new();
let (v1, v2) = (fx.v(0), fx.v(1));
let c = fx.put(v1, 0, 10, 0);
let before = fx.seq.clone();
let mut n = fx.next;
let result = move_items(&mut fx.seq, &[(c, v1, f(20)), (c, v2, f(20))], insert_mode, &mut Fx::ctx(&mut n));
assert!(result.is_err(), "moving one identity to two tracks must be rejected: {result:?}");
assert_eq!(fx.seq, before);
assert_eq!(n, fx.next, "a rejected move consumes no ids");
}
}
#[test]
fn overwrite_splits_underlying() {
let mut fx = Fx::new();
+16 -3
View File
@@ -826,6 +826,7 @@ fn close_project(s: &mut Session, p: &Value) -> Result<Value> {
return Err(EngineError::Other("the project has unsaved changes: save it first, or pass {\"force\": true} to discard them".into()));
}
s.project = std::sync::Arc::new(Project::new("Untitled"));
s.document_epoch = s.document_epoch.wrapping_add(1);
s.history = Default::default();
s.history.limit = 200;
s.state = Session::default().state;
@@ -929,8 +930,12 @@ fn consolidate_duplicates(s: &mut Session, _: &Value) -> Result<Value> {
let mut groups: BTreeMap<String, Vec<ItemId>> = BTreeMap::new();
for it in s.project.items.values() {
if let ItemKind::Media(m) = &it.kind {
// same file (or generator), interpretation and streams
let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration);
let generator_rate = if matches!(&m.media, MediaRef::Generator(filmcraft_media::Generator::CountingLeader)) {
m.info.video.as_ref().map(|v| v.frame_rate)
} else {
None
};
let shape = (m.info.video.is_some(), m.info.has_audio(), m.info.duration, generator_rate);
let key = serde_json::to_string(&(&m.media, &m.interpret, m.offline, shape)).unwrap_or_default();
groups.entry(key).or_default().push(it.id);
}
@@ -1234,7 +1239,15 @@ fn parse_format(f: &str) -> Option<AudioChannels> {
fn audio_channels(s: &mut Session, p: &Value) -> Result<Value> {
let items: Vec<ItemId> =
items_p(s, p).into_iter().filter(|i| s.project.item(*i).and_then(|it| it.as_media()).is_some_and(|m| m.info.has_audio())).collect();
if items.is_empty() || p.get("channels").is_some() {
// `channels` is the only thing the timeline-clip branch below reads, so it alone decides
// which branch runs: no project item selected used to fall into it too (on the theory that
// there was nothing else to do), but a `channels`-less call with no item selected (e.g. a
// stray `format` with an empty project selection, #1845) then reset the selected clips'
// `source_channels` to `[]` via `unwrap_or_default()` instead of being rejected.
if items.is_empty() && p.get("channels").is_none() {
return Err(bad("clip.audioChannels", "select a clip with audio, or a project item to set its format"));
}
if p.get("channels").is_some() {
// timeline audio clips: which source channels each plays
let clips = match p.get("clips").and_then(Value::as_array) {
Some(a) if a.iter().all(Value::is_u64) => a.iter().filter_map(|v| v.as_u64().map(ClipId)).collect(),
+79
View File
@@ -228,6 +228,33 @@ fn remove_unused_and_consolidate_duplicates() {
assert!(s.project.item(dup).is_some());
}
#[test]
fn consolidate_keeps_counting_leaders_with_distinct_source_rates() {
let mut s = Session::default();
s.execute("file.newProject", json!({"name": "Leader rates"})).unwrap();
s.execute("file.newSequence", json!({"name": "24 fps", "fps": 24, "video": 1, "audio": 1})).unwrap();
let leader_24 = ItemId(s.execute("file.newCountingLeader", json!({"seconds": 3})).unwrap()["item"].as_u64().unwrap());
s.execute("file.newSequence", json!({"name": "60 fps", "fps": 60, "video": 1, "audio": 1})).unwrap();
let leader_60 = ItemId(s.execute("file.newCountingLeader", json!({"seconds": 3})).unwrap()["item"].as_u64().unwrap());
assert_ne!(leader_24, leader_60);
s.execute("timeline.place", json!({"item": leader_60.0, "track": "V1", "audioTrack": "A1", "seconds": 0.0})).unwrap();
let before = s.media.full_res_source(&s.project, leader_60, &*s.services).unwrap().audio(0, 2_010, 48_000).unwrap();
assert!(before.channels.iter().all(|channel| channel[800..].iter().all(|sample| *sample == 0.0)));
let result = s.execute("edit.consolidateDuplicates", json!({})).unwrap();
assert_eq!(result["removed"], json!([]), "leaders with different source rates are not duplicates");
assert!(s.project.item(leader_60).is_some());
let sequence = s.active_sequence().unwrap();
assert_eq!(sequence.audio_tracks[0].items[0].item, leader_60);
let after = s.media.full_res_source(&s.project, leader_60, &*s.services).unwrap().audio(0, 2_010, 48_000).unwrap();
assert_eq!(after.channels, before.channels);
s.execute("project.select", json!({"items": [leader_60.0]})).unwrap();
let duplicate = ItemId(s.execute("edit.duplicate", json!({})).unwrap()["items"][0].as_u64().unwrap());
let result = s.execute("edit.consolidateDuplicates", json!({})).unwrap();
assert_eq!(result["removed"], json!([duplicate.0]), "equal-rate copies should still consolidate");
}
#[test]
fn new_sequence_from_clip_bin_from_selection_and_offline_file() {
let mut s = demo();
@@ -379,6 +406,24 @@ fn modify_audio_channels_and_breakout_to_mono() {
assert!(s.execute("clip.breakoutToMono", json!({})).is_err(), "no audio");
}
/// #1845: with no project item selected, a `channels`-less call (a stray `format` included)
/// used to fall into the timeline-clip branch anyway and reset `source_channels` to `[]` via
/// `unwrap_or_default()`, instead of being rejected. The map set by the previous `channels` call
/// must survive a later `format`-only call under the same (empty) project selection.
#[test]
fn audio_channels_rejects_a_channel_less_call_with_no_item_selected() {
let mut s = demo();
s.state.project_selection.clear();
let a1 = s.active_sequence().unwrap().audio_tracks[0].items[0].id;
s.state.selection = vec![a1];
s.execute("clip.audioChannels", json!({"channels": [1]})).unwrap();
assert_eq!(clip(&s, a1.0).source_channels, vec![1]);
assert!(s.execute("clip.audioChannels", json!({"format": null})).is_err(), "nothing to do: no item selected and no `channels`");
assert_eq!(clip(&s, a1.0).source_channels, vec![1], "the existing map must survive a rejected call");
assert!(s.execute("clip.audioChannels", json!({})).is_err(), "same with no params at all");
assert_eq!(clip(&s, a1.0).source_channels, vec![1]);
}
#[test]
fn modify_timecode_and_reset() {
let mut s = demo();
@@ -1082,3 +1127,37 @@ fn toggle_animation_creates_a_parameter_missing_from_an_older_instance() {
let rot = it.effect("motion").unwrap().param("rotation").unwrap();
assert!(rot.is_animated(), "the stopwatch is on");
}
/// Transform's Anchor Point and Position are centred in the clip's own picture (on apply and on reset), so Scale grows it
/// from its centre (they used to be centred in the sequence frame, off to the side of a smaller picture).
#[test]
fn applied_transform_centres_its_points_in_the_clip_picture() {
let mut s = demo();
let r = s.execute("file.newColorMatte", json!({"width": 960, "height": 1080, "color": "#ff0000"})).unwrap();
let matte = r["item"].as_u64().unwrap();
let r = s.execute("timeline.place", json!({"item": matte, "track": "V3", "seconds": 0.0})).unwrap();
let c = r["clips"][0].as_u64().unwrap();
s.execute("effects.apply", json!({"clips": [c], "effect": "transform"})).unwrap();
let centre = filmcraft_geom::Vec2::new(480.0, 540.0);
let point = |s: &Session, k: &str| clip(s, c).effect("transform").unwrap().param(k).unwrap().value.as_vec2().unwrap();
assert_eq!(point(&s, "anchor"), centre);
assert_eq!(point(&s, "position"), centre);
// Reset Effect and Reset Parameter centre them the same way
for k in ["anchor", "position"] {
s.execute("effects.setParam", json!({"clip": c, "effect": "transform", "param": k, "value": [10.0, 20.0]})).unwrap();
}
let idx = clip(&s, c).effects.iter().position(|e| e.effect == "transform").unwrap();
s.execute("effects.reset", json!({"clip": c, "index": idx})).unwrap();
assert_eq!(point(&s, "anchor"), centre);
assert_eq!(point(&s, "position"), centre);
s.execute("effects.setParam", json!({"clip": c, "effect": "transform", "param": "anchor", "value": [10.0, 20.0]})).unwrap();
s.execute("effects.resetParam", json!({"clip": c, "effect": "transform", "param": "anchor"})).unwrap();
assert_eq!(point(&s, "anchor"), centre);
// intrinsic effects stay centred in the sequence frame when applied to a smaller clip
let q = s.active_sequence().unwrap();
let frame_centre = filmcraft_geom::Vec2::new(f64::from(q.settings.width) / 2.0, f64::from(q.settings.height) / 2.0);
s.execute("effects.apply", json!({"clips": [c], "effect": "motion"})).unwrap();
for m in clip(&s, c).effects.iter().filter(|e| e.effect == "motion") {
assert_eq!(m.param("position").unwrap().value.as_vec2().unwrap(), frame_centre);
}
}
+201 -58
View File
@@ -352,10 +352,10 @@ pub(crate) fn item_p(p: &Value, k: &str) -> Option<ItemId> {
/// as `copied`.
fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
const CMD: &str = "timeline.move";
let mut listed: Vec<(ClipId, TrackId, Tick)> = Vec::new();
let mut new_moves = Vec::new();
let mut listed: Vec<(ClipId, TrackId, Tick, bool)> = Vec::new();
for m in p.get("moves").and_then(Value::as_array).into_iter().flatten() {
let track = if str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new")) {
let new_track = str_p(m, "track").is_some_and(|t| t.eq_ignore_ascii_case("new"));
let track = if new_track {
let clip = clip_p(m, "clip").ok_or_else(|| bad(CMD, "new track needs a clip"))?;
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let (source, _) = q.find_item(clip).ok_or(edit::EditError::NoItem(clip))?;
@@ -365,13 +365,12 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
if m.get("time").and_then(Value::as_i64).is_none() {
return Err(bad(CMD, "new track needs a time"));
}
new_moves.push((clip, source));
Some(source)
} else {
track_p(s, m, "track", CMD)?
};
if let (Some(clip), Some(track), Some(time)) = (clip_p(m, "clip"), track, m.get("time").and_then(Value::as_i64)) {
listed.push((clip, track, Tick(time)));
listed.push((clip, track, Tick(time), new_track));
}
}
if listed.is_empty() {
@@ -379,32 +378,32 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
}
let follow = bool_p(p, "linked").unwrap_or(s.state.linked_selection);
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
// (clip, destination track, new start, where a partner starts now). `time` is any i64: the
// sums are done in i128, where a few i64 values cannot overflow, and checked on the way back.
let mut plan: Vec<(ClipId, TrackId, i128, Option<i128>)> = Vec::new();
for &(clip, track, time) in &listed {
plan.push((clip, track, i128::from(time.0), None));
// (clip, destination track, new start, where a partner starts now, new track requested).
// `time` is any i64: sums use i128 and are checked on the way back.
let mut plan: Vec<(ClipId, TrackId, i128, Option<i128>, bool)> = Vec::new();
for &(clip, track, time, new_track) in &listed {
plan.push((clip, track, i128::from(time.0), None, new_track));
let Some((_, it)) = q.find_item(clip) else { continue }; // `edit::move_items` reports it
let Some(link) = it.link.filter(|_| follow) else { continue };
let offset = i128::from(time.0) - i128::from(it.start.0);
for t in q.all_tracks().filter(|t| !t.locked) {
for partner in t.items.iter().filter(|i| i.link == Some(link)) {
if !listed.iter().any(|m| m.0 == partner.id) && !plan.iter().any(|m| m.0 == partner.id) {
plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0))));
plan.push((partner.id, t.id, i128::from(partner.start.0) + offset, Some(i128::from(partner.start.0)), false));
}
}
}
}
let late = plan.iter().map(|m| -m.2).max().unwrap_or(0).max(0);
let mut moves: Vec<(ClipId, TrackId, Tick)> = Vec::with_capacity(plan.len());
for (clip, track, start, was) in plan {
let mut moves: Vec<(ClipId, TrackId, Tick, bool)> = Vec::with_capacity(plan.len());
for (clip, track, start, was, new_track) in plan {
// a partner that ends up where it is does not move (its listed clip only changed track):
// re-placing it would drop the transitions at its edges
if was == Some(start + late) {
continue;
}
let start = i64::try_from(start + late).ok().map(Tick).filter(|t| *t <= Tick::MAX).ok_or_else(|| bad(CMD, "`time` is out of range"))?;
moves.push((clip, track, start));
moves.push((clip, track, start, new_track));
}
let ins = bool_p(p, "insert").unwrap_or(false);
let copy = bool_p(p, "copy").unwrap_or(false);
@@ -417,7 +416,7 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
// Allocate destinations in the same transaction as the move: an invalid edit leaves
// neither empty tracks nor history entries. Each source track keeps its clips together.
let prepare = |q: &mut filmcraft_project::Sequence, ctx: &mut edit::EditCtx<'_>| -> Result<Vec<(ClipId, TrackId, Tick)>> {
let sources: Vec<_> = q.all_tracks().filter(|t| new_moves.iter().any(|m| m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect();
let sources: Vec<_> = q.all_tracks().filter(|t| moves.iter().any(|m| m.3 && m.1 == t.id)).map(|t| (t.id, t.kind, t.channels)).collect();
for kind in [TrackKind::Video, TrackKind::Audio] {
let added = sources.iter().filter(|s| s.1 == kind).count();
if added > 0 && q.tracks(kind).len().saturating_add(added) > crate::sequence_tools::MAX_TRACKS {
@@ -436,8 +435,9 @@ fn move_clips(s: &mut Session, p: &Value) -> Result<Value> {
}
Ok(moves
.iter()
.map(|&(clip, track, time)| {
let dest = new_moves.iter().find(|m| m.0 == clip).and_then(|m| destinations.iter().find(|d| d.0 == m.1)).map_or(track, |d| d.1);
.map(|&(clip, track, time, new_track)| {
// A copy can list the same source again with a different destination.
let dest = destinations.iter().find(|d| new_track && d.0 == track).map_or(track, |d| d.1);
(clip, dest, time)
})
.collect())
@@ -583,12 +583,17 @@ pub fn linked_roll_pairs(seq: &filmcraft_project::Sequence, left: ClipId, right:
pairs
}
/// Expand a clip selection with linked partners (when linked selection is on).
/// Keep each selected identity once, then expand linked partners when linked selection is on.
pub fn with_links(s: &Session, clips: &[ClipId]) -> Vec<ClipId> {
let Some(seq) = s.active_sequence() else { return clips.to_vec() };
let mut out = clips.to_vec();
let mut out = Vec::new();
for c in clips {
if !out.contains(c) {
out.push(*c);
}
}
let Some(seq) = s.active_sequence() else { return out };
if s.state.linked_selection {
let links: Vec<u64> = clips.iter().filter_map(|c| seq.find_item(*c).and_then(|(_, i)| i.link)).collect();
let links: Vec<u64> = out.iter().filter_map(|c| seq.find_item(*c).and_then(|(_, i)| i.link)).collect();
for t in seq.all_tracks() {
for i in &t.items {
if i.link.is_some_and(|l| links.contains(&l)) && !out.contains(&i.id) {
@@ -627,12 +632,24 @@ fn clamp_trim_linked(q: &filmcraft_project::Sequence, clips: &[ClipId], edge: Ed
/// trim drag shows this, so the edge stops at the end of the media or the neighbouring clip
/// instead of following the pointer past it (#653).
pub fn trim_delta(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick) -> Result<Tick> {
trim_delta_linked(s, clip, edge, mode, delta, true)
}
/// [`trim_delta`] for a trim that, with `linked` false (Alt held), leaves the clip's linked
/// partners alone (#3300).
pub fn trim_delta_linked(s: &Session, clip: ClipId, edge: Edge, mode: TrimMode, delta: Tick, linked: bool) -> Result<Tick> {
let q = s.active_sequence().ok_or(EngineError::NoSequence)?;
let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id);
let starts = |id: ItemId| crate::media_start(&s.project, id);
let mut next = s.project.next_id;
let ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() };
clamp_trim_linked(q, &with_links(s, &[clip]), edge, mode, delta, &ctx)
let clips = if linked { with_links(s, &[clip]) } else { vec![clip] };
clamp_trim_linked(q, &clips, edge, mode, delta, &ctx)
}
/// `clip` and, unless `p` has `"linked": false` (Alt held), its linked partners.
fn with_links_p(s: &Session, p: &Value, clip: ClipId) -> Vec<ClipId> {
if bool_p(p, "linked").unwrap_or(true) { with_links(s, &[clip]) } else { vec![clip] }
}
/// The delta a roll of every cut in `pairs` can apply together: `delta` clamped by each pair's
@@ -1177,12 +1194,14 @@ fn marker_list_mut(s: &mut Session) -> Result<(ItemId, Tick)> {
Ok((seq, s.playhead()))
}
/// Builds the command registry shared by the UI, CLI and control interfaces.
fn build() -> Vec<CommandSpec> {
let mut v = vec![
// ================= File =================
cmd!("file.newProject", "Project…", ["File", "New"], Some("Cmd+Alt+N"), r#"{"name":str}"#, always, |s, p| {
let name = str_p(p, "name").unwrap_or("Untitled").to_string();
s.project = std::sync::Arc::new(filmcraft_project::Project::new(&name));
s.document_epoch = s.document_epoch.wrapping_add(1);
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
@@ -1192,11 +1211,13 @@ fn build() -> Vec<CommandSpec> {
s.revision += 1;
s.saved_revision = s.revision;
s.events.push(crate::Event::ProjectChanged { revision: s.revision });
Ok(Value::Null)
// the project as `project_inspect` names it (#1545: an agent needs no extra round trip)
Ok(json!({"name": name, "revision": s.revision}))
}),
cmd!("file.openDemoProject", "Demo Project", ["File", "New"], None, "{}", always, |s, _| {
let (p, seq) = crate::demo::demo_project(&s.media);
s.project = std::sync::Arc::new(p);
s.document_epoch = s.document_epoch.wrapping_add(1);
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
@@ -1914,7 +1935,8 @@ fn build() -> Vec<CommandSpec> {
// Each clip flips on its own, as in Premiere (#484): with one enabled and one disabled clip
// selected, Enable swaps them rather than first making both the same.
cmd!("clip.enable", "Enable", ["Clip"], Some("Shift+E"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
let mut sel = with_links(s, &clips_p(s, p));
// An Alt selection is already exact (#3031); explicit targets keep the linked behavior.
let mut sel = if p.get("clips").is_some() || p.get("clip").is_some() { with_links(s, &clips_p(s, p)) } else { s.state.selection.clone() };
// a clip named twice must still flip once
sel.sort();
sel.dedup();
@@ -1930,11 +1952,24 @@ fn build() -> Vec<CommandSpec> {
}),
cmd!("clip.link", "Link", ["Clip"], Some("Cmd+L"), r#"{"clips":[id]?}"#, has_selection, |s, p| {
let sel = clips_p(s, p);
let linked = sel.iter().all(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some()));
// Unlink when any selected clip is linked, as the Timeline's context menu says (#1103):
// a title or a music clip among the selection must not turn Unlink into a re-link
let linked = sel.iter().any(|c| s.active_sequence().and_then(|q| q.find_item(*c)).is_some_and(|(_, i)| i.link.is_some()));
s.edit_sequence(if linked { "Unlink" } else { "Link" }, |q, ctx, _| {
if linked {
let mut links = Vec::new();
for c in &sel {
if let Some((_, i)) = q.find_item_mut(*c) {
links.extend(i.link.take());
}
}
// a partner left alone in its link (one half unlinked on its own) is unlinked
// too: a one-member link would read as "linked" with nothing to follow
for l in links {
let members: Vec<ClipId> = q.all_tracks().flat_map(|t| t.items.iter()).filter(|i| i.link == Some(l)).map(|i| i.id).collect();
if let [lonely] = members[..]
&& let Some((_, i)) = q.find_item_mut(lonely)
{
i.link = None;
}
}
@@ -2144,13 +2179,19 @@ fn build() -> Vec<CommandSpec> {
cmd!("sequence.lift", "Lift", ["Sequence"], Some(";"), "{}", has_in_out, |s, _| {
let r = in_out_range(s)?;
let tg = s.targeting().targeted;
s.edit_sequence("Lift", |q, ctx, _| Ok(edit::lift(q, &tg, r, ctx)))?;
Ok(Value::Null)
let cut = s.edit_sequence("Lift", |q, ctx, st| {
let cut = cut_range(q, &tg, r, ctx, st.copy_paste_sequence_markers);
edit::lift(q, &tg, r, ctx);
Ok(cut)
})?;
let copied = store_cut(s, cut);
Ok(json!({"copied": copied}))
}),
cmd!("sequence.extract", "Extract", ["Sequence"], Some("'"), "{}", has_in_out, |s, _| {
let r = in_out_range(s)?;
let tg = s.targeting().targeted;
s.edit_sequence("Extract", |q, ctx, st| {
let cut = s.edit_sequence("Extract", |q, ctx, st| {
let cut = cut_range(q, &tg, r, ctx, st.copy_paste_sequence_markers);
edit::extract(q, &tg, r, ctx);
if st.ripple_sequence_markers {
crate::sequence_tools::ripple_markers(&mut q.markers, r.end(), -r.duration);
@@ -2158,10 +2199,11 @@ fn build() -> Vec<CommandSpec> {
q.mark_in = None;
q.mark_out = None;
q.split = Default::default();
Ok(())
Ok(cut)
})?;
let copied = store_cut(s, cut);
s.set_playhead(r.start);
Ok(Value::Null)
Ok(json!({"copied": copied}))
}),
cmd!(
"sequence.applyVideoTransition",
@@ -2201,12 +2243,14 @@ fn build() -> Vec<CommandSpec> {
remove_transitions(s, &ids, "Clear Transition")
}),
cmd!("sequence.closeGap", "Close Gap", ["Sequence"], None, r#"{"track":"V1"|id?,"time":ticks?}"#, has_seq, |s, p| {
// Shortcuts and menus have no parameters. Resolve that form from the selected gap
// (#1135); explicit requests keep their existing track/time validation.
// Shortcuts and menus have no parameters. That form closes the selected gap (#1135),
// or with none selected every gap between In and Out, as Premiere's Sequence ▸ Close
// Gap does (#1986); explicit requests keep their existing track/time validation.
if p.as_object().is_some_and(|p| p.is_empty()) {
let gap = s.state.gap_selection.ok_or_else(|| bad("sequence.closeGap", "select a gap or provide `track`"))?;
ripple_delete_gap(s, gap)?;
return Ok(Value::Null);
return match s.state.gap_selection {
Some(gap) => ripple_delete_gap(s, gap).map(|_| Value::Null),
None => close_sequence_gaps(s),
};
}
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());
@@ -2749,7 +2793,7 @@ fn build() -> Vec<CommandSpec> {
"Trim Edit",
[],
None,
r#"{"clip":id,"edge":"in|out","mode":"regular|ripple","delta":ticks|"deltaFrames":i64}"#,
r#"{"clip":id,"edge":"in|out","mode":"regular|ripple","delta":ticks|"deltaFrames":i64,"linked":bool?}"#,
has_seq,
|s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.trim", "need `clip`"))?;
@@ -2761,7 +2805,7 @@ fn build() -> Vec<CommandSpec> {
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let clips = with_links(s, &[c]);
let clips = with_links_p(s, p, c);
let applied = s.edit_sequence(if mode == TrimMode::Ripple { "Ripple Trim" } else { "Trim" }, |q, ctx, st| {
if q.find_item(c).is_some_and(|(t, _)| q.track(t).is_some_and(|tr| tr.locked)) {
return Err(edit::EditError::Locked.into());
@@ -2884,7 +2928,7 @@ fn build() -> Vec<CommandSpec> {
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
// `followLinks: false` rolls only this pair: Trim mode already lists linked partners as edit points of their own
// `followLinks: false` rolls only this pair; the default clamps linked cuts together.
let follow = s.state.linked_selection && p.get("followLinks").and_then(Value::as_bool).unwrap_or(true);
let (x, n, linked) = s.edit_sequence("Rolling Edit", |q, ctx, _| {
let pairs = linked_roll_pairs(q, l, r, follow);
@@ -2935,7 +2979,7 @@ fn build() -> Vec<CommandSpec> {
})?;
Ok(Value::Null)
}),
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks}"#, has_seq, |s, p| {
cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks,"linked":bool?}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.rateStretch", "need `clip`"))?;
let edge = if str_p(p, "edge") == Some("in") { Edge::In } else { Edge::Out };
let d = p
@@ -2944,7 +2988,7 @@ fn build() -> Vec<CommandSpec> {
.map(Tick)
.or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))
.unwrap_or_default();
let clips = with_links(s, &[c]);
let clips = with_links_p(s, p, c);
let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| Ok(edit::rate_stretch_items(q, &clips, edge, d, ctx)?))?;
Ok(json!({"speed": sp}))
}),
@@ -3061,25 +3105,37 @@ fn build() -> Vec<CommandSpec> {
}
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, _, _| {
let mut n = 0;
// standard effects run on the clip's source picture, so "auto" points (the centre) are
// measured in it, not in the sequence frame
let source_sizes: std::collections::BTreeMap<ClipId, (u32, u32)> = s
.active_sequence()
.map(|q| clips.iter().filter_map(|c| q.find_item(*c).and_then(|(_, i)| s.project.source_size(i.item)).map(|sz| (*c, sz))).collect())
.unwrap_or_default();
// Answer `{"applied":[{"clip","index","effect"}]}`: where each instance landed, so an
// agent can address it (`effects.setParam`, `effects.remove`) without a `sequence_inspect`
// round trip, as `timeline.place` answers with its clips (#1545).
let applied = s.edit_sequence(&label, |q, _, _| {
let mut applied = Vec::new();
for t in q.all_tracks_mut() {
if (t.kind == TrackKind::Audio) != is_audio {
continue;
}
for i in t.items.iter_mut().filter(|i| clips.contains(&i.id)) {
let mut inst = def.instance();
resolve_auto_points(&mut inst, (fw, fh), (fw, fh));
let size = source_sizes.get(&i.id).copied().unwrap_or((fw, fh));
// intrinsic effects (Motion) are laid out in the sequence frame, as `reset_params` does
let frame = if def.intrinsic { (fw, fh) } else { size };
resolve_auto_points(&mut inst, frame, size);
// standard effects go before the intrinsic ones (render order)
let pos = i.effects.iter().position(|e| e.def().is_some_and(|d| d.intrinsic)).unwrap_or(i.effects.len());
let pos = if is_audio { i.effects.len() } else { pos };
i.effects.insert(pos, inst);
n += 1;
applied.push(json!({"clip": i.id.0, "index": pos, "effect": def.id}));
}
}
Ok(n)
Ok(applied)
})?;
Ok(Value::Null)
Ok(json!({"applied": applied}))
}),
cmd!("effects.remove", "Remove Effect", [], None, r#"{"clip":id,"index":n}"#, has_seq, |s, p| {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.remove", "need `clip`"))?;
@@ -3146,7 +3202,7 @@ fn build() -> Vec<CommandSpec> {
if bool_p(p, "begin").unwrap_or(false) {
s.history.merge_key = None;
}
let keyframes = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
let (keyframes, value) = s.edit_sequence_as("Change Effect Parameter", merge.as_deref(), |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
let mt = it.source_time_at(at.unwrap_or_else(|| ph.clamp(it.start, (it.end() - Tick(1)).max(it.start))));
let e = match &eff {
@@ -3200,6 +3256,8 @@ fn build() -> Vec<CommandSpec> {
};
(keyframes, v)
};
let accepted_value = param_value_to_json(&v);
if e.effect == "ultra_key"
&& pid == "setting"
&& let ParamValue::Choice(setting) = v
@@ -3214,10 +3272,10 @@ fn build() -> Vec<CommandSpec> {
}
}
}
Ok(keyframes)
Ok((keyframes, accepted_value))
})?;
// 0 keyframes: the value is static (`time` was not used)
Ok(json!({"keyframes": keyframes}))
Ok(json!({"keyframes": keyframes, "param": pid, "value": value}))
}
),
cmd!("effects.toggleAnimation", "Toggle Animation", [], None, r#"{"clip":id,"effect":str|index,"param":str,"mask":n?}"#, has_seq, |s, p| {
@@ -3263,12 +3321,13 @@ fn build() -> Vec<CommandSpec> {
let c = clip_p(p, "clip").ok_or_else(|| bad("effects.reset", "need `clip`"))?;
let idx = u64_p(p, "index").unwrap_or(0) as usize;
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let source = s.active_sequence().and_then(|q| q.find_item(c)).and_then(|(_, i)| s.project.source_size(i.item)).unwrap_or(frame);
let ph = s.playhead();
s.edit_sequence("Reset Effect", |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
let mt = it.source_time_at(ph.clamp(it.start, (it.end() - Tick(1)).max(it.start)));
if let Some(e) = it.effects.get_mut(idx) {
reset_params(e, None, frame, mt);
reset_params(e, None, frame, source, mt);
}
Ok(())
})?;
@@ -3279,6 +3338,7 @@ fn build() -> Vec<CommandSpec> {
let pid = str_p(p, "param").ok_or_else(|| bad("effects.resetParam", "need `param`"))?.to_string();
let eff = p.get("effect").cloned().unwrap_or(json!("motion"));
let frame = s.active_sequence().map(|q| (q.settings.width, q.settings.height)).unwrap_or((1920, 1080));
let source = s.active_sequence().and_then(|q| q.find_item(c)).and_then(|(_, i)| s.project.source_size(i.item)).unwrap_or(frame);
let ph = s.playhead();
s.edit_sequence("Reset Parameter", |q, _, _| {
let (_, it) = q.find_item_mut(c).ok_or(filmcraft_edit::EditError::NoItem(c))?;
@@ -3289,7 +3349,7 @@ fn build() -> Vec<CommandSpec> {
_ => None,
}
.ok_or_else(|| bad("effects.resetParam", "no such effect on clip"))?;
if !reset_params(e, Some(&pid), frame, mt) {
if !reset_params(e, Some(&pid), frame, source, mt) {
return Err(bad("effects.resetParam", format!("no param `{pid}`")));
}
Ok(())
@@ -3568,11 +3628,12 @@ fn build() -> Vec<CommandSpec> {
/// Put `e`'s parameters (all of them, or only `only`) back to their defaults, as Premiere's reset
/// buttons do: a parameter that is not animated gets its default as its static value, and an
/// animated one keeps its keyframes and gets the default as a keyframe at media time `mt`.
/// Masks are left alone. Returns whether any parameter was reset.
fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), mt: Tick) -> bool {
/// Masks are left alone. Returns whether any parameter was reset. "Auto" points are centred as
/// when the effect was added: a standard effect's in the clip's `source` picture it runs on.
fn reset_params(e: &mut filmcraft_project::EffectInstance, only: Option<&str>, frame: (u32, u32), source: (u32, u32), mt: Tick) -> bool {
let Some(d) = e.def() else { return false };
let mut fresh = d.instance();
resolve_auto_points(&mut fresh, frame, frame);
resolve_auto_points(&mut fresh, if d.intrinsic { frame } else { source }, source);
let mut any = false;
for (id, prm) in fresh.params {
if only.is_some_and(|o| o != id) {
@@ -3743,6 +3804,7 @@ fn set_work_area_point(s: &mut Session, p: &Value, is_in: bool) -> Result<Value>
Ok(json!({"start": start.0, "end": end.0}))
}
/// Decodes caller JSON using the stored parameter as a type template.
pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamValue> {
Some(match template {
ParamValue::Float(_) => ParamValue::Float(v.as_f64()?),
@@ -3761,7 +3823,7 @@ pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamVal
_ => return None,
},
ParamValue::Bool(_) => ParamValue::Bool(v.as_bool()?),
ParamValue::Choice(_) => ParamValue::Choice(v.as_u64()? as u32),
ParamValue::Choice(_) => ParamValue::Choice(u32::try_from(v.as_u64()?).ok()?),
ParamValue::Text(_) => ParamValue::Text(v.as_str()?.to_string()),
ParamValue::Path(_) => ParamValue::Path(crate::masks::path_from_json(v)?),
ParamValue::Curve(_) => {
@@ -3770,6 +3832,20 @@ pub(crate) fn json_to_param(template: &ParamValue, v: &Value) -> Option<ParamVal
})
}
/// Encode the accepted parameter value in the JSON shape returned to command callers.
fn param_value_to_json(value: &ParamValue) -> Value {
match value {
ParamValue::Float(value) => json!(value),
ParamValue::Vec2(value) => json!([value.x, value.y]),
ParamValue::Color(value) => json!(value),
ParamValue::Bool(value) => json!(value),
ParamValue::Choice(value) => json!(value),
ParamValue::Text(value) => json!(value),
ParamValue::Curve(value) => json!(value),
ParamValue::Path(value) => crate::masks::path_to_json(value),
}
}
fn copy_selection(s: &mut Session) {
let Some(q) = s.active_sequence() else { return };
let sel = with_links(s, &s.state.selection);
@@ -3791,9 +3867,53 @@ fn copy_selection(s: &mut Session) {
}
}
}
s.state.clipboard = clip;
s.state.clipboard_markers = markers;
s.state.caption_clipboard.clear();
set_clipboard(&mut s.state, (clip, markers));
}
/// Timeline clips (track kind, track index, item) and the sequence markers that go with them.
type Clipboard = (Vec<(TrackKind, usize, filmcraft_project::TrackItem)>, Vec<Marker>);
fn set_clipboard(st: &mut crate::EditorState, (clips, markers): Clipboard) {
st.clipboard = clips;
st.clipboard_markers = markers;
st.caption_clipboard.clear();
}
/// Put a Lift or Extract cut on the clipboard; returns how many clips it holds (0 leaves the
/// clipboard as it was).
fn store_cut(s: &mut Session, cut: Option<Clipboard>) -> usize {
let Some(cut) = cut else { return 0 };
let copied = cut.0.len();
set_clipboard(&mut s.state, cut);
copied
}
/// What Lift and Extract put on the clipboard, as in Premiere (#3019): the clips of the unlocked
/// `tracks` (the targeted ones) in `range`, cut at its edges and held the way Copy holds them.
/// Extract also cuts sync-locked tracks only to keep them in sync; that material is not copied.
/// Reads the sequence before the edit; None when the range has no clips on those tracks.
fn cut_range(q: &filmcraft_project::Sequence, tracks: &[TrackId], range: TimeRange, ctx: &mut edit::EditCtx, with_markers: bool) -> Option<Clipboard> {
let mut links = std::collections::HashMap::new();
let mut clips = Vec::new();
for (kind, list) in [(TrackKind::Video, &q.video_tracks), (TrackKind::Audio, &q.audio_tracks)] {
for (ti, t) in list.iter().enumerate().filter(|(_, t)| !t.locked && tracks.contains(&t.id)) {
let mut t = t.clone();
edit::split_track_at(&mut t, range.start, ctx, &mut links);
edit::split_track_at(&mut t, range.end(), ctx, &mut links);
clips.extend(t.items.into_iter().filter(|i| i.start >= range.start && i.end() <= range.end()).map(|i| (kind, ti, i)));
}
}
let min = clips.iter().map(|c| c.2.start).min()?;
let max = clips.iter().map(|c| c.2.end()).max()?;
for c in &mut clips {
c.2.start -= min;
}
let markers = if with_markers {
q.markers.iter().filter(|m| m.start >= min && m.start < max).map(|m| Marker { start: m.start - min, ..m.clone() }).collect()
} else {
Vec::new()
};
Some((clips, markers))
}
fn paste(s: &mut Session, insert: bool) -> Result<Value> {
@@ -4199,6 +4319,28 @@ fn ripple_delete_gap(s: &mut Session, g: crate::GapSelection) -> Result<Value> {
Ok(json!({"closed": g.range.duration.0}))
}
/// `sequence.closeGap` with no gap selected: every span where no track has anything, between In
/// and Out (the whole sequence without them), closes in one undo step (#1986).
fn close_sequence_gaps(s: &mut Session) -> Result<Value> {
let within = in_out_range(s)?;
let gaps = s.active_sequence().map(|q| crate::sequence_extras::sequence_gaps(q, Some(within))).unwrap_or_default();
let Some(first) = gaps.first().copied() else {
return Err(bad("sequence.closeGap", "no gaps to close"));
};
s.edit_sequence("Close Gap", |q, _, st| {
// a locked track would stay put while everything else moved past it
if q.all_tracks().any(|t| t.locked && t.items.iter().any(|i| i.start >= first.end())) {
return Err(edit::EditError::Locked.into());
}
for g in gaps.iter().rev() {
crate::sequence_extras::close_sequence_gap(q, *g, st.ripple_sequence_markers);
}
Ok(())
})?;
let closed = gaps.iter().fold(Tick::ZERO, |sum, g| sum + g.duration);
Ok(json!({"gaps": gaps.len(), "closed": closed.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| {
@@ -4521,6 +4663,7 @@ fn install_project(s: &mut Session, proj: filmcraft_project::Project, path: Opti
let first = proj.sequences().next().map(|i| i.id);
s.media.add_stored(&proj, path.as_deref());
s.project = std::sync::Arc::new(proj);
s.document_epoch = s.document_epoch.wrapping_add(1);
s.history = Default::default();
s.history.limit = 200;
s.state = crate::Session::default().state;
@@ -307,3 +307,26 @@ fn link_leaves_clips_without_a_partner_unlinked() {
assert_eq!(clip(&s, v1).unwrap().link, None, "v1 has no overlapping audio in the selection");
assert_eq!(clip(&s, a2).unwrap().link, None, "a2 has no overlapping video in the selection");
}
/// #1103: Unlink with a selection that also holds an unlinked clip (a title, a music clip) unlinks
/// the linked ones, as the Timeline's context menu offers, instead of re-linking them.
#[test]
fn unlink_with_an_unlinked_clip_in_the_selection_still_unlinks() {
let mut s = demo();
let (v, a) = (v1(&s), a1(&s));
let (v0, a0, v1, a1) = (v[0].id, a[0].id, v[1].id, a[1].id);
assert!(clip(&s, v0).unwrap().link.is_some() && clip(&s, v1).unwrap().link.is_some(), "the demo's clips start linked");
// make one clip unlinked on its own: its partner goes with it (no one-member link stays)
s.execute("clip.link", json!({"clips": [a1.0]})).unwrap();
assert_eq!(clip(&s, a1).unwrap().link, None);
assert_eq!(clip(&s, v1).unwrap().link, None, "the partner left alone is unlinked too");
s.execute("clip.link", json!({"clips": [v0.0, a0.0]})).unwrap();
s.execute("clip.link", json!({"clips": [v0.0, a0.0]})).unwrap();
assert!(clip(&s, v0).unwrap().link.is_some(), "v0 and a0 linked again");
// v0 + a0 linked, v1 not: Unlink
let r = s.execute("clip.link", json!({"clips": [v0.0, a0.0, v1.0]})).unwrap();
assert_eq!(r["linked"], false);
for c in [v0, a0, v1] {
assert_eq!(clip(&s, c).unwrap().link, None, "{c:?}");
}
}
+13
View File
@@ -862,3 +862,16 @@ fn caption_sidecars_keep_only_the_exported_interval() {
assert!(doc.cues.is_empty(), "half-open export excludes cues touching either boundary: {doc:?}");
}
}
/// #1216: `maxThreads` reaches the export settings from the command parameters (CLI, MCP, control
/// channel), as a flat parameter or inside `settings`.
#[test]
fn max_threads_reaches_the_export_settings() {
let session = Session::default();
let settings_of = |params: Value| crate::export_tools::settings_from_params(&session, &params, "file.exportMedia").map(|(_, s)| s);
assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "maxThreads": 2})).unwrap().max_threads, 2);
assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "settings": {"maxThreads": 3}})).unwrap().max_threads, 3);
assert_eq!(settings_of(json!({"format": "mjpeg", "path": "out.mov"})).unwrap().max_threads, 0, "all cores unless asked");
assert!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "maxThreads": -1})).is_err(), "a negative count is refused");
assert!(settings_of(json!({"format": "mjpeg", "path": "out.mov", "settings": {"maxThread": 2}})).is_err(), "a misspelt setting is refused, not ignored");
}
+3
View File
@@ -235,6 +235,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
settings.bitrate_kbps = v;
settings.adaptive_bitrate = None;
}
if let Some(v) = crate::commands::checked_u32_p(p, "maxThreads", cmd)? {
settings.max_threads = v;
}
if let Some(v) = crate::commands::checked_u32_p(p, "maxBitrateKbps", cmd)? {
settings.max_bitrate_kbps = Some(v);
}
+13
View File
@@ -94,6 +94,19 @@ fn project_is_named_after_its_file() {
let _ = std::fs::remove_dir_all(&d);
}
/// #1545: `file.newProject` answers with the project it made (name and revision, as
/// `project_inspect` reports them) instead of `null`.
#[test]
fn new_project_answers_with_its_name_and_revision() {
let mut s = Session::default();
let r = s.execute("file.newProject", json!({"name": "Trailer"})).unwrap();
assert_eq!(r["name"], "Trailer");
assert_eq!(r["revision"].as_u64(), Some(s.revision));
assert_eq!(s.project.name, "Trailer");
let r = s.execute("file.newProject", json!({})).unwrap();
assert_eq!(r["name"], "Untitled", "the default name");
}
/// Opening the demo after a saved project must not keep that path: Save would overwrite it.
#[test]
fn open_demo_project_clears_saved_path_so_save_does_not_overwrite() {
+73 -1
View File
@@ -211,7 +211,6 @@ fn close_gap_rejects_invalid_context_and_explicit_parameters_without_editing() {
two_clips(&mut s, [0, 96]);
let before = s.project.clone();
let undo = s.history.undo.len();
assert!(s.execute("sequence.closeGap", json!({})).is_err(), "a shortcut needs a selected gap");
s.execute("timeline.selectGap", json!({"track":"V1","frame":60})).unwrap();
for params in [json!({"track":null}), json!({"track":false}), json!({"track":"V99","frame":60}), json!({"time":1})] {
assert!(s.execute("sequence.closeGap", params).is_err(), "invalid explicit requests must not fall back to the selected gap");
@@ -232,3 +231,76 @@ fn close_gap_rejects_invalid_context_and_explicit_parameters_without_editing() {
assert!(std::sync::Arc::ptr_eq(&before, &s.project));
assert_eq!(s.history.undo.len(), undo);
}
/// 48-frame clips (pictures on V1, linked sounds on A1) placed at `frames`, in that order.
fn clips_at(s: &mut Session, frames: &[i64]) -> Vec<(ClipId, ClipId)> {
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..frames.len()).map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id)).collect()
}
/// #1986: Sequence ▸ Close Gap with no gap selected closes every gap in the sequence, on picture and
/// sound alike, in one undo step (Premiere's Close Gap).
#[test]
fn close_gap_without_a_selected_gap_closes_every_gap_in_one_undo_step() {
let mut s = Session::default();
let clips = clips_at(&mut s, &[24, 96, 200]);
let rate = s.sequence_rate();
let at = |f: i64| rate.tick_of(f);
let (zero, f48, f96) = (Tick::ZERO, at(48), at(96));
let before = serde_json::to_value(&s.project).unwrap();
let undo = s.history.undo.len();
let r = s.execute("sequence.closeGap", json!({})).unwrap();
assert_eq!(r["gaps"], json!(3), "the leading gap and the two between the clips: {r}");
for ((picture, sound), start) in clips.iter().zip([zero, f48, f96]) {
assert_eq!(range(&s, *picture).0, start);
assert_eq!(range(&s, *sound).0, start, "sound stays with its picture");
}
assert_eq!(s.history.undo.len(), undo + 1, "one undo step");
s.undo();
assert_eq!(serde_json::to_value(&s.project).unwrap(), before);
}
/// With In and Out set, only the gaps that lie between them close.
#[test]
fn close_gap_without_a_selected_gap_only_closes_gaps_between_in_and_out() {
let mut s = Session::default();
let clips = clips_at(&mut s, &[0, 96, 200]);
let rate = s.sequence_rate();
let at = |f: i64| rate.tick_of(f);
s.execute("markers.markIn", json!({"time": at(140).0})).unwrap();
s.execute("markers.markOut", json!({"time": at(260).0})).unwrap();
let r = s.execute("sequence.closeGap", json!({})).unwrap();
assert_eq!(r["gaps"], json!(1), "{r}");
assert_eq!(range(&s, clips[0].0).0, Tick::ZERO);
assert_eq!(range(&s, clips[1].0).0, at(96), "the gap before In stays");
assert_eq!(range(&s, clips[2].0).0, at(144), "the gap between In and Out closed");
}
/// Nothing to close, or a locked track that would be left behind: an error and no edit.
#[test]
fn close_gap_without_a_selected_gap_refuses_without_editing() {
let mut s = Session::default();
clips_at(&mut s, &[0, 48]);
let before = s.project.clone();
let undo = s.history.undo.len();
assert!(s.execute("sequence.closeGap", json!({})).is_err(), "no gaps");
assert!(std::sync::Arc::ptr_eq(&before, &s.project));
assert_eq!(s.history.undo.len(), undo);
let mut s = Session::default();
clips_at(&mut s, &[0, 96]);
s.execute("timeline.setTrack", json!({"track":"A1","locked":true})).unwrap();
let before = s.project.clone();
let undo = s.history.undo.len();
assert!(s.execute("sequence.closeGap", json!({})).is_err(), "A1 is locked and has a clip after the gap");
assert!(std::sync::Arc::ptr_eq(&before, &s.project));
assert_eq!(s.history.undo.len(), undo);
}
+9 -1
View File
@@ -8,7 +8,9 @@
//! at the playhead.
use filmcraft_geom::Vec2;
use filmcraft_project::graphic::{self, LayerContent, SHAPE_OPTS, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer};
use filmcraft_project::graphic::{
self, LayerContent, SHAPE_OPTS, ensure_layer_uids, eval_layer, layer_display_name, layer_indices, new_shape_layer, new_text_layer,
};
use filmcraft_project::{ClipId, EffectInstance, ItemId, ItemKind, Label, Param, ParamValue, Sequence, TrackKind};
use filmcraft_render::graphic_clip::{item_layer_specs, layer_local_bounds, layer_quad, text_layout};
use filmcraft_time::{Tick, TimeRange};
@@ -138,6 +140,9 @@ pub(crate) fn place_video_clip(
let mut ti = pr.make_track_item(src, TrackKind::Video, t, TimeRange::new(Tick::ZERO, dur), rate).ok_or_else(|| bad(cmd, "bad item"))?;
ti.name = name;
ti.effects.extend(extra);
// Every new graphic layer gets a stable uid; a new clip holds layers from `extra` plus
// any source-graphic layers, so do it after both are in place (duplicates are repaired).
ensure_layer_uids(&mut ti.effects);
for e in &mut ti.effects {
filmcraft_project::resolve_auto_points(e, (w, h), src_size);
}
@@ -179,6 +184,9 @@ fn add_layer(s: &mut Session, clip: ClipId, mut layer: filmcraft_project::Effect
// new layers go in front: after the last graphic layer
let pos = layer_indices(&it.effects).last().map_or(it.effects.len(), |l| l + 1);
it.effects.insert(pos, layer);
// Assign a uid to the new layer (and lazily upgrade any uid-less legacy layers) inside
// this undo step, so undo/redo keeps the same identities.
ensure_layer_uids(&mut it.effects);
let n = layer_indices(&it.effects).len() - 1;
st.selection = vec![clip];
st.graphic_layers = vec![n];
+25
View File
@@ -95,6 +95,31 @@ fn graphic_placement_takes_a_track_and_a_time() {
}
}
/// Every graphic layer gets a stable uid when it is created, both in a new clip and when added to
/// an existing one. Two byte-identical layers with different text must not share an identity.
#[test]
fn new_graphic_layers_get_unique_stable_uids() {
let mut s = demo();
let clip = ClipId(s.execute("graphics.newText", json!({"text": "one", "position": [960, 540], "size": 100})).unwrap()["clip"].as_u64().unwrap());
s.execute("graphics.newText", json!({"text": "two", "position": [960, 540], "size": 100, "clip": clip.0})).unwrap();
s.execute("graphics.newShape", json!({"shape": "rectangle", "clip": clip.0})).unwrap();
let uids = |s: &mut Session| -> Vec<u64> {
s.execute("graphics.list", json!({"clip": clip.0})).unwrap()["layers"].as_array().unwrap().iter().map(|l| l["uid"].as_u64().unwrap()).collect()
};
let first = uids(&mut s);
assert_eq!(first.len(), 3);
assert!(first.iter().all(|uid| *uid != 0), "every created layer is assigned a uid: {first:?}");
assert_eq!(first.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 3, "uids are unique: {first:?}");
// A real reorder keeps the same per-layer identity, and undo/redo restores it unchanged.
s.execute("graphics.arrangeLayer", json!({"clip": clip.0, "layer": 0, "to": "front"})).unwrap();
let reordered = uids(&mut s);
assert_eq!(reordered, vec![first[1], first[2], first[0]]);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(uids(&mut s), first, "undo restores the same identities, not fresh ones");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(uids(&mut s), reordered);
}
#[test]
fn new_text_makes_a_graphic_clip_above_the_footage() {
let mut s = demo();
+4
View File
@@ -226,7 +226,11 @@ fn nudge_track(s: &mut Session, up: bool) -> Result<Value> {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let mut moves = Vec::new();
let mut blocked = 0usize;
let mut seen = std::collections::HashSet::new();
for c in &s.state.selection {
if !seen.insert(*c) {
continue;
}
let Some((tid, it)) = seq.find_item(*c) else { continue };
let Some(tr) = seq.track(tid) else { continue };
let list = seq.tracks(tr.kind);
+7
View File
@@ -431,6 +431,10 @@ pub struct Session {
pub project: Arc<Project>,
pub history: History,
pub revision: u64,
/// Bumped whenever the session installs a different project document (`file.newProject`,
/// open/import/recovery/template). Frontends use it to discard UI state (text edits, IME
/// owners) that belongs to the replaced document even if item ids are reused.
pub document_epoch: u64,
pub saved_revision: u64,
pub path: Option<String>,
pub state: EditorState,
@@ -549,6 +553,7 @@ impl Session {
project: Arc::new(Project::new("Untitled")),
history: History { limit: 200, ..Default::default() },
revision: 1,
document_epoch: 0,
saved_revision: 1,
path: None,
state: EditorState {
@@ -1297,6 +1302,8 @@ mod keyboard_tests;
#[cfg(test)]
mod linked_speed_tests;
#[cfg(test)]
mod linked_trim_tests;
#[cfg(test)]
mod locked_clip_edits_tests;
#[cfg(test)]
mod masks_tests;
+59
View File
@@ -0,0 +1,59 @@
//! Trimming and rate-stretching one side of a linked clip (#3300): `"linked": false` (Alt held
//! in the Timeline) edits only the given clip, while the default still edits its partners too.
use crate::Session;
use filmcraft_project::ClipId;
use filmcraft_time::Tick;
use serde_json::json;
fn linked_pair() -> (Session, ClipId, ClipId) {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
s.execute("file.newSequence", json!({"name": "Linked trim", "video": 1, "audio": 1})).unwrap();
let item = s.project.items.values().find(|i| i.has_video() && i.has_audio() && i.as_media().is_some()).unwrap().id;
s.execute("timeline.place", json!({"item": item.0, "frame": 0, "duration": s.sequence_rate().tick_of(48).0})).unwrap();
let q = s.active_sequence().unwrap();
let (video, sound) = (q.video_tracks[0].items[0].id, q.audio_tracks[0].items[0].id);
assert!(q.find_item(video).unwrap().1.link.is_some(), "the pair is linked");
(s, video, sound)
}
fn state(s: &Session, id: ClipId) -> (Tick, Tick, Tick) {
let (_, it) = s.active_sequence().unwrap().find_item(id).unwrap();
(it.start, it.duration, it.source_in)
}
#[test]
fn unlinked_trim_leaves_the_partner_alone() {
for mode in ["regular", "ripple"] {
for edge in ["in", "out"] {
let (mut s, video, sound) = linked_pair();
let (before_v, before_a) = (state(&s, video), state(&s, sound));
let delta = if edge == "in" { 10 } else { -10 };
s.execute("timeline.trim", json!({"clip": video.0, "edge": edge, "mode": mode, "deltaFrames": delta, "linked": false})).unwrap();
assert_eq!(state(&s, video).1, before_v.1 - s.sequence_rate().tick_of(10), "{mode} {edge}: the video is trimmed");
assert_eq!(state(&s, sound), before_a, "{mode} {edge}: the linked audio keeps its range");
assert!(s.state.linked_selection, "the global toggle stays on");
// without the flag, the partner follows as before
let (mut s, video, sound) = linked_pair();
s.execute("timeline.trim", json!({"clip": video.0, "edge": edge, "mode": mode, "deltaFrames": delta})).unwrap();
assert_eq!(state(&s, video).1, before_v.1 - s.sequence_rate().tick_of(10));
assert_eq!(state(&s, sound).1, before_a.1 - s.sequence_rate().tick_of(10), "{mode} {edge}: the linked audio follows");
}
}
}
#[test]
fn unlinked_rate_stretch_leaves_the_partner_alone() {
let (mut s, video, sound) = linked_pair();
let (before_v, before_a) = (state(&s, video), state(&s, sound));
s.execute("timeline.rateStretch", json!({"clip": video.0, "edge": "out", "deltaFrames": 12, "linked": false})).unwrap();
assert_eq!(state(&s, video).1, before_v.1 + s.sequence_rate().tick_of(12));
assert_eq!(state(&s, sound), before_a, "the linked audio keeps its range");
let (mut s, video, sound) = linked_pair();
s.execute("timeline.rateStretch", json!({"clip": video.0, "edge": "out", "deltaFrames": 12})).unwrap();
assert_eq!(state(&s, video).1, before_v.1 + s.sequence_rate().tick_of(12));
assert_eq!(state(&s, sound).1, before_a.1 + s.sequence_rate().tick_of(12), "the linked audio follows");
}
+31 -1
View File
@@ -51,7 +51,7 @@ pub(crate) fn commands() -> Vec<CommandSpec> {
spec(
"masks.moveVertex",
"Move Mask Vertex",
r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n,"handle":"point"|"in"|"out"?,"to":[x,y]?|"delta":[dx,dy]?,"breakHandles":bool?,"merge":key?}"#,
r#"{"clip":id?,"effect":index|id?,"mask":n?,"vertex":n|"vertices":[n],"handle":"point"|"in"|"out"?,"to":[x,y]?|"delta":[dx,dy]?,"breakHandles":bool?,"merge":key?}"#,
move_vertex,
),
spec("masks.translate", "Move Mask", r#"{"clip":id?,"effect":index|id?,"mask":n?,"delta":[dx,dy],"merge":key?}"#, translate),
@@ -324,6 +324,9 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
}
fn move_vertex(s: &mut Session, p: &Value) -> Result<Value> {
if p.get("vertices").is_some_and(|v| !v.is_null()) {
return move_vertices(s, p);
}
let vi = u64_p(p, "vertex").ok_or_else(|| bad("masks.moveVertex", "need `vertex`"))? as usize;
let handle = str_p(p, "handle").unwrap_or("point").to_string();
let to = p.get("to").and_then(pt);
@@ -362,6 +365,33 @@ fn move_vertex(s: &mut Session, p: &Value) -> Result<Value> {
Ok(Value::Null)
}
/// `masks.moveVertex` with `vertices`: the listed points move by `delta` together, in one edit
/// (the multi-vertex drag on the Program monitor). Any index past the path moves nothing.
fn move_vertices(s: &mut Session, p: &Value) -> Result<Value> {
const ID: &str = "masks.moveVertex";
let list = p.get("vertices").and_then(Value::as_array).ok_or_else(|| bad(ID, "`vertices` is a list of vertex indices"))?;
let mut only = 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::<Result<Vec<_>>>()?;
only.sort_unstable();
only.dedup();
if str_p(p, "handle").is_some_and(|h| h != "point") || p.get("to").is_some() {
return Err(bad(ID, "`vertices` moves points by `delta`"));
}
let d = p.get("delta").and_then(pt).ok_or_else(|| bad(ID, "need `delta`"))?;
edit_mask(s, p, "Move Mask Vertex", move |m, t| {
let mut path = m.path_at(t);
for &vi in &only {
let v = path.vertices.get_mut(vi).ok_or_else(|| bad(ID, format!("no vertex {vi}")))?;
v.p = v.p + d;
}
set_path_at(m, t, path);
Ok(())
})?;
Ok(Value::Null)
}
fn translate(s: &mut Session, p: &Value) -> Result<Value> {
let d = p.get("delta").and_then(pt).ok_or_else(|| bad("masks.translate", "need `delta`"))?;
edit_mask(s, p, "Move Mask", move |m, t| {
+36
View File
@@ -399,3 +399,39 @@ fn path_json_round_trip_keeps_tangents() {
// the serialized project form still parses
assert_eq!(crate::masks::path_from_json(&serde_json::to_value(&path).unwrap()), Some(path));
}
/// `masks.moveVertex` with `vertices` moves several points by the same delta in one edit (the
/// Program monitor's multi-vertex drag, #3150); a bad index leaves every vertex where it was.
#[test]
fn move_vertex_moves_a_list_of_vertices_together() {
let (mut s, _) = demo();
s.execute("masks.add", json!({"effect": "opacity", "shape": "polygon", "center": [400, 400], "size": [200, 200]})).unwrap();
let pts = |s: &mut Session| -> Vec<(f64, f64)> {
let l = s.execute("masks.list", json!({})).unwrap();
l["masks"][0]["path"]["vertices"].as_array().unwrap().iter().map(|v| (v["p"][0].as_f64().unwrap(), v["p"][1].as_f64().unwrap())).collect()
};
let before = pts(&mut s);
let n = s.history.undo.len();
// two frames of one drag, a vertex listed twice moves once
for _ in 0..2 {
s.execute("masks.moveVertex", json!({"vertices": [0, 2, 2], "delta": [10, -5], "merge": "vertex-1"})).unwrap();
}
assert_eq!(s.history.undo.len(), n + 1, "one undo step for the drag");
let after = pts(&mut s);
for i in [0, 2] {
assert_eq!(after[i], (before[i].0 + 20.0, before[i].1 - 10.0), "vertex {i}");
}
for i in [1, 3] {
assert_eq!(after[i], before[i], "vertex {i} stays");
}
// an index past the last vertex moves nothing
let e = s.execute("masks.moveVertex", json!({"vertices": [1, 9], "delta": [10, 0]})).unwrap_err().to_string();
assert!(e.contains("no vertex 9"), "{e}");
assert_eq!(pts(&mut s), after);
// a list moves points, by a delta
assert!(s.execute("masks.moveVertex", json!({"vertices": [1], "to": [0, 0]})).is_err());
assert!(s.execute("masks.moveVertex", json!({"vertices": [1], "handle": "in", "delta": [1, 0]})).is_err());
assert!(s.execute("masks.moveVertex", json!({"vertices": "1", "delta": [1, 0]})).is_err());
s.undo();
assert_eq!(pts(&mut s), before);
}
+33 -33
View File
@@ -17,7 +17,7 @@
use std::collections::{BTreeSet, HashMap};
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex, RwLock};
use std::sync::{Arc, Mutex, PoisonError, RwLock};
use filmcraft_frame::{AudioBuffer, VideoFrame};
use filmcraft_media::generators::GeneratorSource;
@@ -147,24 +147,24 @@ fn file_name(path: &str) -> String {
impl MediaPool {
pub fn register_opener(&self, o: Opener) {
self.openers.write().unwrap_or_else(|e| e.into_inner()).push(o);
self.openers.write().unwrap_or_else(PoisonError::into_inner).push(o);
}
/// Cache a source for an item, valid whatever the item's reference (generators).
pub fn insert(&self, item: ItemId, src: SharedSource) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (String::new(), src));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (String::new(), src));
}
/// Cache a source for an item under its [`media_key`].
pub fn insert_keyed(&self, item: ItemId, key: String, src: SharedSource) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src));
}
/// Cache a source opened from `path` for an item.
pub fn insert_file(&self, item: ItemId, path: &str, src: SharedSource) {
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.offline.write().unwrap_or_else(PoisonError::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));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (format!("file:{path}"), src));
}
/// `src` (opened from `path` for `item`) showing the unreadable slate for the frames it
@@ -175,9 +175,9 @@ impl MediaPool {
}
pub fn remove(&self, item: ItemId) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.proxies.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.sources.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
self.proxies.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
}
/// Record the media and proxy paths of `p`, loaded from the document at `document` (None: one
@@ -194,7 +194,7 @@ impl MediaPool {
/// [`Self::add_stored`] for single paths.
pub fn add_stored_paths(&self, paths: impl IntoIterator<Item = String>, document: Option<&str>) {
let document = document.filter(|d| !d.is_empty()).map(PathBuf::from);
let mut stored = self.stored.write().unwrap_or_else(|e| e.into_inner());
let mut stored = self.stored.write().unwrap_or_else(PoisonError::into_inner);
for p in paths {
stored.entry(p).or_insert_with(|| document.clone());
}
@@ -202,12 +202,12 @@ impl MediaPool {
/// Forget the stored paths (a new project).
pub fn clear_stored(&self) {
self.stored.write().unwrap_or_else(|e| e.into_inner()).clear();
self.stored.write().unwrap_or_else(PoisonError::into_inner).clear();
}
/// The user picked `path` in this session (import, Link Media): open it as given.
pub fn allow(&self, path: &str) {
self.stored.write().unwrap_or_else(|e| e.into_inner()).remove(path);
self.stored.write().unwrap_or_else(PoisonError::into_inner).remove(path);
}
/// The file to open for `path`, a media or proxy path of the project: a stored path as
@@ -217,7 +217,7 @@ impl MediaPool {
if cfg!(target_arch = "wasm32") {
return Ok(path.to_string());
}
match self.stored.read().unwrap_or_else(|e| e.into_inner()).get(path) {
match self.stored.read().unwrap_or_else(PoisonError::into_inner).get(path) {
Some(document) => crate::doc_path::resolve(path, document.as_deref()),
None => Ok(path.to_string()),
}
@@ -230,18 +230,18 @@ impl MediaPool {
/// Forget every opened source (files are re-read on next use).
pub fn clear(&self) {
self.sources.write().unwrap_or_else(|e| e.into_inner()).clear();
self.proxies.write().unwrap_or_else(|e| e.into_inner()).clear();
self.offline.write().unwrap_or_else(|e| e.into_inner()).clear();
self.sources.write().unwrap_or_else(PoisonError::into_inner).clear();
self.proxies.write().unwrap_or_else(PoisonError::into_inner).clear();
self.offline.write().unwrap_or_else(PoisonError::into_inner).clear();
}
pub fn cached(&self, item: ItemId) -> Option<SharedSource> {
self.sources.read().unwrap_or_else(|e| e.into_inner()).get(&item).map(|(_, s)| s.clone())
self.sources.read().unwrap_or_else(PoisonError::into_inner).get(&item).map(|(_, s)| s.clone())
}
/// Sources opened so far (originals and proxies; `perf.stats`).
pub fn open_sources(&self) -> usize {
self.sources.read().unwrap_or_else(|e| e.into_inner()).len() + self.proxies.read().unwrap_or_else(|e| e.into_inner()).len()
self.sources.read().unwrap_or_else(PoisonError::into_inner).len() + self.proxies.read().unwrap_or_else(PoisonError::into_inner).len()
}
pub fn use_proxies(&self) -> bool {
@@ -267,12 +267,12 @@ impl MediaPool {
/// Why an item last rendered offline (None = online or not opened yet).
pub fn offline_status(&self, item: ItemId) -> Option<OfflineStatus> {
self.offline.read().unwrap_or_else(|e| e.into_inner()).get(&item).cloned()
self.offline.read().unwrap_or_else(PoisonError::into_inner).get(&item).cloned()
}
/// Open a file through the registered openers.
pub fn open_bytes(&self, name: &str, bytes: Arc<[u8]>) -> Result<SharedSource, MediaError> {
let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone();
let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone();
filmcraft_media::open_bytes(name, bytes, &openers)
}
@@ -281,7 +281,7 @@ impl MediaPool {
pub fn open_file(&self, path: &str, services: &dyn Services) -> Result<SharedSource, MediaError> {
let io = |e: std::io::Error| if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(e.to_string()) } else { MediaError::Io(e.to_string()) };
if let Some(r) = services.reader(path) {
let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone();
let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone();
return filmcraft_media::reader::open_reader(&file_name(path), r.map_err(io)?, &filmcraft_codecs::reader_openers(), &openers);
}
let bytes = services.read_file(path).map_err(io)?;
@@ -294,7 +294,7 @@ impl MediaPool {
pub fn probe_file(&self, path: &str, services: &dyn Services) -> Result<SharedSource, MediaError> {
let Some(r) = services.reader(path) else { return self.open_file(path, services) };
let r = r.map_err(|e| if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(e.to_string()) } else { MediaError::Io(e.to_string()) })?;
let openers = self.openers.read().unwrap_or_else(|e| e.into_inner()).clone();
let openers = self.openers.read().unwrap_or_else(PoisonError::into_inner).clone();
filmcraft_media::reader::open_reader_within(&file_name(path), r, &filmcraft_codecs::reader_openers(), &openers, PROBE_WHOLE_FILE_MAX)
}
@@ -319,7 +319,7 @@ impl MediaPool {
return Some(s);
}
let key = media_key(m);
if let Some((k, s)) = self.sources.read().unwrap_or_else(|e| e.into_inner()).get(&item)
if let Some((k, s)) = self.sources.read().unwrap_or_else(PoisonError::into_inner).get(&item)
&& (k.is_empty() || *k == key)
{
return Some(s.clone());
@@ -342,7 +342,7 @@ impl MediaPool {
}
}) {
Ok(s) => {
self.offline.write().unwrap_or_else(|e| e.into_inner()).remove(&item);
self.offline.write().unwrap_or_else(PoisonError::into_inner).remove(&item);
self.unsupported_as_slate(item, path, s)
}
Err(_) if filmcraft_media::pending::is_set() => {
@@ -357,17 +357,17 @@ impl MediaPool {
}
},
};
self.sources.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src.clone()));
self.sources.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src.clone()));
Some(src)
}
fn set_offline(&self, item: ItemId, reason: OfflineReason, path: &str, error: &str) {
self.offline.write().unwrap_or_else(|e| e.into_inner()).insert(item, OfflineStatus { reason, path: path.to_string(), error: error.to_string() });
self.offline.write().unwrap_or_else(PoisonError::into_inner).insert(item, OfflineStatus { reason, path: path.to_string(), error: error.to_string() });
}
fn proxy_source(&self, item: ItemId, path: &str, m: &MediaClip, services: &dyn Services) -> Option<SharedSource> {
let key = format!("proxy:{path}");
if let Some((k, s)) = self.proxies.read().unwrap_or_else(|e| e.into_inner()).get(&item)
if let Some((k, s)) = self.proxies.read().unwrap_or_else(PoisonError::into_inner).get(&item)
&& *k == key
{
return Some(s.clone());
@@ -387,7 +387,7 @@ impl MediaPool {
None
};
let src: SharedSource = Arc::new(ProxySource { proxy: s, info: m.info.clone(), audio_from });
self.proxies.write().unwrap_or_else(|e| e.into_inner()).insert(item, (key, src.clone()));
self.proxies.write().unwrap_or_else(PoisonError::into_inner).insert(item, (key, src.clone()));
Some(src)
}
Err(e) => {
@@ -432,7 +432,7 @@ impl PoolProvider {
/// item order (subclips count as their media). Empty when not recording.
pub fn offline_used(&self) -> Vec<OfflineMedia> {
let Some(used) = &self.used else { return Vec::new() };
let used = used.lock().unwrap_or_else(|e| e.into_inner()).clone();
let used = used.lock().unwrap_or_else(PoisonError::into_inner).clone();
let media: BTreeSet<ItemId> = used.into_iter().filter_map(|i| self.project.resolve_media(i).map(|(id, _, _)| id)).collect();
media
.into_iter()
@@ -453,7 +453,7 @@ impl PoolProvider {
impl filmcraft_render::SourceProvider for PoolProvider {
fn source(&self, item: ItemId) -> Option<SharedSource> {
if let Some(used) = &self.used {
used.lock().unwrap_or_else(|e| e.into_inner()).insert(item);
used.lock().unwrap_or_else(PoisonError::into_inner).insert(item);
}
if self.full_res { self.pool.full_res_source(&self.project, item, &*self.services) } else { self.pool.source_for(&self.project, item, &*self.services) }
}
@@ -485,7 +485,7 @@ impl MediaSource for SlateSource {
let v = self.info.video.as_ref().ok_or(MediaError::NoStream("video"))?;
let s = req.scale.clamp(1.0 / 64.0, 1.0);
let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2));
let mut last = self.last.lock().unwrap_or_else(|e| e.into_inner());
let mut last = self.last.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(f) = last.as_ref()
&& f.width == w
&& f.height == h
@@ -521,7 +521,7 @@ impl MediaSource for UnsupportedAsSlate {
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
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(|| {
self.offline.write().unwrap_or_else(PoisonError::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 }
});
@@ -560,7 +560,7 @@ impl MediaSource for DamagedAsSlate {
}
fn video_frame(&self, req: FrameRequest) -> filmcraft_media::Result<Arc<VideoFrame>> {
let r = self.inner.video_frame(req);
let mut failures = self.failures.lock().unwrap_or_else(|e| e.into_inner());
let mut failures = self.failures.lock().unwrap_or_else(PoisonError::into_inner);
match r {
Ok(f) => {
failures.remove(&self.item);
+1 -1
View File
@@ -98,7 +98,7 @@ fn cache_key(n: &Narration) -> String {
fn tts_err(cmd: &str, e: tts::TtsError) -> EngineError {
match e {
tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech ▸ Download natural voices (tts.downloadVoices)"),
tts::TtsError::NotInstalled => bad(cmd, "the natural voices are not downloaded yet: Text to Speech > Download natural voices (tts.downloadVoices)"),
e => bad(cmd, e.to_string()),
}
}
+5 -2
View File
@@ -219,17 +219,20 @@ impl PreviewStore {
self.bump();
}
/// Move to `dir`, taking this store's preview files along (first save of a project).
/// Move to `dir`, taking this store's preview files along (first save of a project). The folder is
/// created only when there are files to take along: a render creates it when it needs it (#2343).
pub fn move_to(&self, dir: PathBuf) {
let old = self.dir();
if old.as_deref() == Some(dir.as_path()) {
return;
}
let files = self.files.read().unwrap_or_else(|e| e.into_inner()).clone();
if let Some(old) = &old
&& !files.is_empty()
&& (old.starts_with(crate::temp_dir()) || self.temp_root().is_some_and(|r| old.starts_with(r)))
{
let _ = std::fs::create_dir_all(&dir);
for name in self.files.read().unwrap_or_else(|e| e.into_inner()).iter() {
for name in &files {
let (a, b) = (old.join(name), dir.join(name));
if !b.exists() && std::fs::rename(&a, &b).is_err() {
let _ = std::fs::copy(&a, &b).map(|_| std::fs::remove_file(&a));
+21
View File
@@ -224,6 +224,27 @@ fn previews_survive_save_and_open() {
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn saving_without_previews_creates_no_previews_folder() {
// #2343: a project with nothing rendered leaves only the file it was asked to write
let mut s = Session::default();
let folder = tmp("save-empty");
std::fs::create_dir_all(&folder).unwrap();
let path = folder.join("Film.fcproj").to_string_lossy().to_string();
s.execute("file.save", json!({"path": path})).unwrap();
assert_eq!(s.previews.dir().unwrap(), folder.join("FilmCraft Previews").join("Film"));
assert!(!folder.join("FilmCraft Previews").exists());
// the first render creates it
let (mut r, _) = session("save-empty-render");
r.execute("file.save", json!({"path": path})).unwrap();
assert!(!folder.join("FilmCraft Previews").exists());
let v = r.execute("sequence.renderEffectsInToOut", json!({"wait": true})).unwrap();
job_ok(&r, &v);
assert!(folder.join("FilmCraft Previews").join("Film").is_dir());
assert_eq!(states(&r), vec!["green", "none"]);
let _ = std::fs::remove_dir_all(&folder);
}
#[test]
fn render_selection_and_audio_previews() {
let (mut s, a) = session("sel");
+2 -1
View File
@@ -678,6 +678,7 @@ fn new_from_template(s: &mut Session, p: &Value) -> Result<Value> {
proj.name = name.clone();
proj.root.name = name;
s.project = std::sync::Arc::new(proj);
s.document_epoch = s.document_epoch.wrapping_add(1);
s.history = Default::default();
s.history.limit = 200;
s.state = Session::default().state;
@@ -1062,7 +1063,7 @@ pub(crate) fn warn_untrusted_scratch(s: &mut Session) {
let list = ignored.join(", ");
log::warn!("project scratch disks outside the project folder not used: {list}");
s.toast(format!(
"Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings ▸ Scratch Disks to use it."
"Scratch Disks: not using {list} (outside the project folder and not chosen on this computer). Auto-saves, previews and captured media go to the default location; choose the folder in Project Settings > Scratch Disks to use it."
));
}
+10 -4
View File
@@ -97,7 +97,13 @@ pub fn transcode(
preset: &Preset,
progress: &Progress,
) -> std::result::Result<Report, String> {
transcode_with_file(clip, name, src, range, out, None, preset, progress)
transcode_with_file(clip, name, src, range, Dest { path: out, file: None }, preset, progress)
}
/// Output path, plus the already-open file to write into when the caller holds one.
struct Dest<'a> {
path: &'a str,
file: Option<Arc<std::fs::File>>,
}
fn transcode_with_file(
@@ -105,11 +111,11 @@ fn transcode_with_file(
name: &str,
src: SharedSource,
range: Option<TimeRange>,
out: &str,
output_file: Option<Arc<std::fs::File>>,
dest: Dest<'_>,
preset: &Preset,
progress: &Progress,
) -> std::result::Result<Report, String> {
let Dest { path: out, file: output_file } = dest;
let info = &clip.info;
let rate = clip.frame_rate();
let mut st = SequenceSettings::default();
@@ -280,7 +286,7 @@ fn start_job(s: &mut Session, label: String, work: Vec<(ItemId, ReservedOutput,
}
*prog.status.lock().unwrap_or_else(|e| e.into_inner()) = format!("{name} ({} of {n})", k + 1);
let out = output.path().to_string_lossy().into_owned();
match transcode_with_file(&clip, &name, src, None, &out, output.file(), pr, &prog) {
match transcode_with_file(&clip, &name, src, None, Dest { path: &out, file: output.file() }, pr, &prog) {
Ok(r) => {
bytes += r.bytes;
output.preserve();
+2 -2
View File
@@ -752,11 +752,11 @@ pub fn on_open(s: &mut Session) -> usize {
list.iter().filter(|m| m.status == "missing").filter_map(|m| s.media.follow(&m.path).err().map(|why| format!("{} ({why})", m.path))).collect();
if !refused.is_empty() {
log::warn!("media paths stored in the project not opened: {}", refused.join(", "));
s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File ▸ Link Media… reconnects them", refused.len()));
s.toast(format!("Not opening {} media file(s) the project names on another computer or device; File > Link Media… reconnects them", refused.len()));
}
s.offline.prompt = !s.offline.missing.is_empty();
if !s.offline.missing.is_empty() {
s.toast(format!("{} media file(s) are missing; File ▸ Link Media… reconnects them", s.offline.missing.len()));
s.toast(format!("{} media file(s) are missing; File > Link Media… reconnects them", s.offline.missing.len()));
}
list.len()
}
+39 -21
View File
@@ -413,27 +413,8 @@ pub fn simplify_sequence(proj: &filmcraft_project::Project, q: &mut Sequence, o:
// close gaps: spans where no track has anything
let mut closed = Tick::ZERO;
if o.close_gaps {
let mut spans: Vec<TimeRange> = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.range())).collect();
spans.extend(q.caption_tracks.iter().flat_map(|t| t.captions.iter().map(|c| c.range())));
spans.sort_by_key(|r| r.start);
let mut gaps: Vec<TimeRange> = Vec::new();
let mut cursor = Tick::ZERO;
for r in &spans {
if r.start > cursor {
gaps.push(TimeRange::from_bounds(cursor, r.start));
}
cursor = cursor.max(r.end());
}
for g in gaps.iter().rev() {
for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) {
edit::shift_track_from(t, g.end(), Tick::ZERO - g.duration);
}
for t in &mut q.caption_tracks {
for c in t.captions.iter_mut().filter(|c| c.start >= g.end()) {
c.start -= g.duration;
}
}
crate::sequence_tools::ripple_markers(&mut q.markers, g.end(), Tick::ZERO - g.duration);
for g in sequence_gaps(q, None).iter().rev() {
close_sequence_gap(q, *g, true);
closed += g.duration;
}
}
@@ -456,6 +437,43 @@ pub fn simplify_sequence(proj: &filmcraft_project::Project, q: &mut Sequence, o:
json!({"removedClips": removed_clips, "removedEffects": removed_effects, "movedClips": moved, "closedGaps": closed.0, "removedTracks": removed_tracks})
}
/// Spans where no track has anything (clips or captions), in time order: the gaps before, between
/// and inside the material, never the open time after the last clip. With `within`, only the gaps
/// that lie entirely inside it (Close Gap between In and Out).
pub(crate) fn sequence_gaps(q: &Sequence, within: Option<TimeRange>) -> Vec<TimeRange> {
let mut spans: Vec<TimeRange> = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.range())).collect();
spans.extend(q.caption_tracks.iter().flat_map(|t| t.captions.iter().map(|c| c.range())));
spans.sort_by_key(|r| r.start);
let mut gaps: Vec<TimeRange> = Vec::new();
let mut cursor = Tick::ZERO;
for r in &spans {
if r.start > cursor {
gaps.push(TimeRange::from_bounds(cursor, r.start));
}
cursor = cursor.max(r.end());
}
if let Some(w) = within {
gaps.retain(|g| g.start >= w.start && g.end() <= w.end());
}
gaps
}
/// Ripple one of [`sequence_gaps`] closed: everything after it, on every track, moves left by its
/// length (it is empty on all of them, so nothing can collide). Close later gaps first.
pub(crate) fn close_sequence_gap(q: &mut Sequence, g: TimeRange, markers: bool) {
for t in q.video_tracks.iter_mut().chain(q.audio_tracks.iter_mut()) {
edit::shift_track_from(t, g.end(), Tick::ZERO - g.duration);
}
for t in &mut q.caption_tracks {
for c in t.captions.iter_mut().filter(|c| c.start >= g.end()) {
c.start -= g.duration;
}
}
if markers {
crate::sequence_tools::ripple_markers(&mut q.markers, g.end(), Tick::ZERO - g.duration);
}
}
fn simplify(s: &mut Session, p: &Value) -> Result<Value> {
let o = SimplifyOptions::from_params(p)?;
let seq_id = s.state.active_sequence.ok_or(EngineError::NoSequence)?;
+61
View File
@@ -836,3 +836,64 @@ fn move_to_new_track_adds_it_in_one_undo_step() {
assert_eq!(*s.active_sequence().unwrap(), before);
assert_eq!(s.history.undo.len(), undo);
}
/// Lift and Extract put what they take off the timeline on the clipboard, cut at the In and Out
/// points, from the targeted tracks only, with the sequence markers in the range when Copy Paste
/// Includes Sequence Markers is on (#3019).
#[test]
fn lift_and_extract_put_the_range_on_the_clipboard() {
for cmd in ["sequence.lift", "sequence.extract"] {
let (mut s, item) = fresh();
place(&mut s, item, "V1", 0, 0, 48);
place(&mut s, item, "V2", 0, 0, 48);
for t in ["V2", "A2"] {
s.execute("timeline.setTargeting", json!({"track": t, "targeted": false})).unwrap();
}
s.execute("markers.copyPasteIncludesSequenceMarkers", json!({"on": true})).unwrap();
s.execute("markers.add", json!({"frame": 15, "name": "inside"})).unwrap();
s.execute("markers.add", json!({"frame": 30, "name": "after"})).unwrap();
s.execute("markers.markIn", json!({"frame": 12})).unwrap();
s.execute("markers.markOut", json!({"frame": 23})).unwrap();
assert_eq!(s.execute(cmd, json!({})).unwrap()["copied"], json!(2), "{cmd}: the V1 and A1 pieces");
s.execute("playhead.set", json!({"frame": 100})).unwrap();
s.execute("edit.paste", json!({})).unwrap_or_else(|e| panic!("{cmd} then edit.paste: {e:?}"));
let q = s.active_sequence().unwrap();
let at = f(&s, 100);
let pasted: Vec<_> = [&q.video_tracks[0], &q.audio_tracks[0]]
.iter()
.map(|t| t.items.iter().find(|i| i.start == at).unwrap_or_else(|| panic!("{cmd}: nothing pasted on {}", t.name)))
.collect();
for p in &pasted {
assert_eq!((p.duration, p.source_in), (f(&s, 12), f(&s, 12)), "{cmd}: the pasted piece is frames 12-23 of the media");
}
assert!(pasted[0].link.is_some() && pasted[0].link == pasted[1].link, "{cmd}: the pasted video and audio stay linked");
for t in [&q.video_tracks[1], &q.audio_tracks[1]] {
assert!(t.items.iter().all(|i| i.start != at), "{cmd}: nothing from the untargeted {} is pasted", t.name);
}
let pasted_markers: Vec<_> = q.markers.iter().filter(|m| m.start >= at).map(|m| (m.start, m.name.as_str())).collect();
assert_eq!(pasted_markers, vec![(f(&s, 103), "inside")], "{cmd}: only the marker inside the range comes along");
}
}
/// Over a range with no clips on the targeted tracks, Lift and Extract copy nothing and Paste still
/// brings back what was copied before (#3019).
#[test]
fn lift_and_extract_over_no_clips_keep_the_clipboard() {
for cmd in ["sequence.lift", "sequence.extract"] {
let (mut s, item) = fresh();
let clips = place(&mut s, item, "V1", 0, 0, 48);
s.execute("timeline.select", json!({"clips": clips})).unwrap();
s.execute("edit.copy", json!({})).unwrap();
for t in ["V1", "A1"] {
s.execute("timeline.setTargeting", json!({"track": t, "targeted": false})).unwrap();
}
s.execute("markers.markIn", json!({"frame": 12})).unwrap();
s.execute("markers.markOut", json!({"frame": 23})).unwrap();
assert_eq!(s.execute(cmd, json!({})).unwrap()["copied"], json!(0), "{cmd}");
s.execute("playhead.set", json!({"frame": 100})).unwrap();
s.execute("edit.paste", json!({})).unwrap_or_else(|e| panic!("{cmd} then edit.paste: {e:?}"));
let q = s.active_sequence().unwrap();
let pasted = q.video_tracks[0].items.iter().find(|i| i.start == f(&s, 100)).map(|i| i.duration);
assert_eq!(pasted, Some(f(&s, 48)), "{cmd}: the earlier copy is pasted");
}
}
+60 -3
View File
@@ -349,11 +349,11 @@ fn set_param_writes_a_keyframe_when_asked_or_animated() {
let mut first = set(60.0, 0.5);
first["keyframe"] = json!(true);
let r = s.execute("effects.setParam", first).unwrap();
assert_eq!(r["keyframes"], 1, "{r}");
assert_eq!(r, json!({"keyframes": 1, "param": "scale", "value": 60.0}));
assert_eq!(scale(&s).0, 1);
// animated now, so a plain set at another time is a second keyframe
let r = s.execute("effects.setParam", set(120.0, 2.5)).unwrap();
assert_eq!(r["keyframes"], 2, "{r}");
assert_eq!(r, json!({"keyframes": 2, "param": "scale", "value": 120.0}));
let (n, _, a, b) = scale(&s);
assert_eq!((n, a, b), (2, 60.0, 120.0));
// a keyframe that is already there is replaced, not doubled
@@ -381,6 +381,38 @@ fn set_param_writes_a_keyframe_when_asked_or_animated() {
assert_eq!(s.execute("effects.setParam", odd).unwrap()["keyframes"], 2);
}
/// The response reflects the stored point when extra input coordinates are ignored.
#[test]
fn set_param_reports_the_stored_vector_value() {
let mut s = demo();
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
let result = s.execute("effects.setParam", json!({"clip": c, "effect": "motion", "param": "position", "value": [11.0, 22.0, 33.0]})).unwrap();
assert_eq!(result, json!({"keyframes": 0, "param": "position", "value": [11.0, 22.0]}));
let sequence = s.execute("sequence.inspect", json!({})).unwrap();
let effects = sequence["video"][0]["items"][0]["effects"].as_array().unwrap();
let motion = effects.iter().find(|e| e["effect"] == "motion").unwrap();
assert_eq!(motion["params"]["position"]["value"], "Vec2(Vec2 { x: 11.0, y: 22.0 })");
}
/// A static edit succeeds without creating keyframes; its response identifies the accepted value.
#[test]
fn set_param_reports_static_value_and_parameter() {
let mut s = demo();
let c = s.active_sequence().unwrap().video_tracks[0].items[0].id.0;
for (effect, param, value, stored, inspected) in
[("opacity", "blend", json!("Screen"), json!(8), "Choice(8)"), ("motion", "scale", json!(80.0), json!(80.0), "Float(80.0)")]
{
let result = s.execute("effects.setParam", json!({"clip": c, "effect": effect, "param": param, "value": value})).unwrap();
assert_eq!(result, json!({"keyframes": 0, "param": param, "value": stored}));
let sequence = s.execute("sequence.inspect", json!({})).unwrap();
let effects = sequence["video"][0]["items"][0]["effects"].as_array().unwrap();
let effect = effects.iter().find(|e| e["effect"] == effect).unwrap();
assert_eq!(effect["params"][param]["value"], inspected);
assert_eq!(effect["params"][param]["keyframes"], 0);
}
}
/// A keyframe set with a `time` outside the clip stays at that time (#1055), before the start as
/// after the end: a ramp that spans a cut shows its true slice, and a clip-end keyframe is not
/// overwritten by a later one. Only the playhead fallback is pinned to the clip.
@@ -457,7 +489,7 @@ fn set_param_takes_a_choice_by_name() {
// a name that is not an option is an error that lists the options and changes nothing
let e = s.execute("effects.setParam", set(json!("Glow"))).unwrap_err().to_string();
assert!(e.contains("Screen") && e.contains("Overlay"), "{e}");
for bad in [json!(""), json!(null), json!([8]), json!({"name": "Screen"}), json!(-1), json!(1.5)] {
for bad in [json!(""), json!(null), json!([8]), json!({"name": "Screen"}), json!(-1), json!(1.5), json!(u64::from(u32::MAX) + 1)] {
assert!(s.execute("effects.setParam", set(bad.clone())).is_err(), "{bad}");
}
assert_eq!(blend(&s), ParamValue::Choice(12));
@@ -1087,6 +1119,31 @@ fn enable_flips_each_selected_clip() {
assert_eq!((enabled(&s, a), enabled(&s, b)), (true, true));
}
/// #3031: with linked selection on, Enable on an Alt-selected audio clip flips only that clip,
/// while a linked selection still flips both sides.
#[test]
fn enable_on_an_individual_selection_leaves_the_linked_partner() {
let mut s = demo();
assert!(s.state.linked_selection);
let enabled = |s: &Session, c: ClipId| s.active_sequence().unwrap().find_item(c).unwrap().1.enabled;
let video = ClipId(v1(&s)[0].0);
let audio = {
let q = s.active_sequence().unwrap();
let link = q.find_item(video).unwrap().1.link.unwrap();
q.all_tracks().flat_map(|t| t.items.iter()).find(|i| i.link == Some(link) && i.id != video).unwrap().id
};
s.execute("timeline.select", json!({"clips": [audio.0], "linked": false})).unwrap();
assert_eq!(s.state.selection, vec![audio]);
s.execute("clip.enable", json!({})).unwrap();
assert_eq!((enabled(&s, video), enabled(&s, audio)), (true, false), "only the selected audio is disabled");
s.execute("clip.enable", json!({})).unwrap();
assert_eq!((enabled(&s, video), enabled(&s, audio)), (true, true));
// a linked selection still flips both sides
s.execute("timeline.select", json!({"clips": [audio.0], "linked": true})).unwrap();
s.execute("clip.enable", json!({})).unwrap();
assert_eq!((enabled(&s, video), enabled(&s, audio)), (false, false));
}
/// #580: Escape takes back what a gesture changed, merged or not: the project returns to the mark,
/// neither undo nor redo keeps the changes, and older steps stay.
#[test]
+314
View File
@@ -36,6 +36,320 @@ fn pair_in_an_empty_sequence(s: &mut Session, seconds: f64) -> (TrackId, TrackIt
(q.video_tracks[0].id, v, q.audio_tracks[0].id, a)
}
fn assert_selection_edit_matches_control(s: &mut Session, selection: Vec<ClipId>, command: &str, params: serde_json::Value) {
let before = s.project.clone();
let undo = s.history.undo.len();
let mut control = Session { project: s.project.clone(), state: s.state.clone(), history: s.history.clone(), media: s.media.clone(), ..Session::default() };
control.state.selection = selection;
let expected = control.execute(command, params.clone()).unwrap();
let actual = s.execute(command, params).unwrap();
assert_eq!(actual, expected, "{command} returns the unique-selection result");
assert_eq!(s.project, control.project, "{command} produces the complete unique-selection project");
assert_eq!(s.history.undo.len(), undo + 1, "the edit is one undo step");
assert_eq!(s.history.undo, control.history.undo);
assert_eq!(s.history.redo, control.history.redo);
assert_eq!(s.history.merge_key, control.history.merge_key);
let q = s.active_sequence().unwrap();
let ids: Vec<ClipId> = q.all_tracks().flat_map(|t| t.items.iter().map(|i| i.id)).collect();
assert_eq!(ids.iter().copied().collect::<std::collections::HashSet<_>>().len(), ids.len(), "no identity was duplicated");
q.check().unwrap();
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
control.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project, before, "undo restores the entire pre-edit project");
assert_eq!(s.project, control.project);
assert_eq!(s.history.undo, control.history.undo);
assert_eq!(s.history.redo, control.history.redo);
s.active_sequence().unwrap().check().unwrap();
s.execute("edit.redo", json!({})).unwrap();
control.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project, after, "redo restores the entire edited project");
assert_eq!(s.project, control.project);
assert_eq!(s.history.undo, control.history.undo);
assert_eq!(s.history.redo, control.history.redo);
s.active_sequence().unwrap().check().unwrap();
}
#[test]
fn duplicate_selection_inputs_keep_first_occurrence_and_toggle_once() {
for linked in [false, true] {
let mut s = demo();
let (_, v, _, a) = pair_in_an_empty_sequence(&mut s, 0.0);
s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap();
let before = s.project.clone();
let history = s.history.clone();
let ids = json!([a.id.0, a.id.0, v.id.0, a.id.0, v.id.0]);
let result = s.execute("timeline.select", json!({"clips": ids})).unwrap();
assert_eq!(s.state.selection, vec![a.id, v.id], "explicit order precedes linked expansion");
assert_eq!(result["selection"], json!([a.id.0, v.id.0]));
s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0], "add": true})).unwrap();
assert_eq!(s.state.selection, vec![a.id, v.id], "adding an identity twice does not reorder the selection");
s.execute("timeline.select", json!({"clips": ids, "toggle": true})).unwrap();
assert!(s.state.selection.is_empty(), "each selected identity toggles off once");
s.execute("timeline.select", json!({"clips": ids, "toggle": true})).unwrap();
assert_eq!(s.state.selection, vec![a.id, v.id], "each unselected identity toggles on once");
s.execute("timeline.select", json!({"clips": []})).unwrap();
s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0], "add": true})).unwrap();
assert_eq!(s.state.selection, if linked { vec![v.id, a.id] } else { vec![v.id] });
assert_eq!(s.project, before, "selection admission does not edit the project");
assert_eq!(s.history.undo, history.undo);
assert_eq!(s.history.redo, history.redo);
s.active_sequence().unwrap().check().unwrap();
}
}
#[test]
fn duplicate_selection_expansion_is_unique_without_an_active_sequence() {
let clips = [ClipId(9), ClipId(3), ClipId(9), ClipId(3), ClipId(5)];
for linked in [false, true] {
let mut s = Session::default();
s.state.linked_selection = linked;
for active in [None, Some(filmcraft_project::ItemId(u64::MAX))] {
s.state.active_sequence = active;
let before = serde_json::to_value(&s.state).unwrap();
assert_eq!(crate::commands::with_links(&s, &clips), vec![ClipId(9), ClipId(3), ClipId(5)]);
assert_eq!(serde_json::to_value(&s.state).unwrap(), before);
}
}
}
#[test]
fn duplicate_selection_public_admission_keeps_nudges_equivalent_to_unique_selection() {
for (command, linked) in [("timeline.nudgeRight", false), ("timeline.nudgeRight", true), ("timeline.nudgeUp", false)] {
let mut s = demo();
let (_, v, _, a) = pair_in_an_empty_sequence(&mut s, 60.0);
s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap();
s.execute("timeline.select", json!({"clips": [v.id.0, v.id.0]})).unwrap();
let selection = if linked { vec![v.id, a.id] } else { vec![v.id] };
assert_eq!(s.state.selection, selection);
assert_selection_edit_matches_control(&mut s, selection.clone(), command, json!({}));
assert_eq!(s.state.selection, selection);
}
}
#[test]
fn duplicate_selection_already_in_session_keeps_nudges_equivalent_to_unique_selection() {
for linked in [false, true] {
for command in ["timeline.nudgeRight", "timeline.nudgeUp"] {
let mut s = demo();
let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0);
s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap();
s.state.selection = vec![v.id, v.id, v.id];
assert_selection_edit_matches_control(&mut s, vec![v.id], command, json!({}));
let q = s.active_sequence().unwrap();
if command == "timeline.nudgeUp" {
assert_eq!(state(&s, v.id), (q.video_tracks[1].id, v.start));
assert_eq!(state(&s, a.id), (a_track, a.start), "vertical nudges remain selected-only even with linked selection on");
} else {
assert_eq!(state(&s, v.id).1, v.start + s.sequence_rate().tick_of(1));
assert_eq!(state(&s, a.id).1, a.start + if linked { s.sequence_rate().tick_of(1) } else { Tick::ZERO });
}
}
}
}
#[test]
fn duplicate_selection_delete_matches_unique_selection_with_and_without_links() {
for linked in [false, true] {
for command in ["edit.clear", "edit.rippleDelete"] {
let mut s = demo();
let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 60.0);
s.execute("sequence.linkedSelection", json!({"on": linked})).unwrap();
if !linked {
s.execute("timeline.setTrack", json!({"track": a_track.0, "syncLock": false})).unwrap();
}
// Clear and Ripple Delete act on the selection as it is: selecting the clip takes its
// partner along only with Linked Selection on
s.execute("timeline.select", json!({"clips": [v.id.0]})).unwrap();
let unique = s.state.selection.clone();
s.state.selection = unique.iter().chain(&unique).copied().collect();
assert_selection_edit_matches_control(&mut s, unique, command, json!({}));
assert!(s.active_sequence().unwrap().find_item(v.id).is_none());
assert_eq!(s.active_sequence().unwrap().find_item(a.id).is_none(), linked);
assert!(s.state.selection.is_empty());
}
}
}
#[test]
fn duplicate_move_targets_are_rejected_and_retry_is_undoable() {
for insert in [false, true] {
let mut s = demo();
let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0);
let before = s.project.clone();
let undo = s.history.undo.len();
let at = s.sequence_rate().tick_of(48).0;
let result = s.execute(
"timeline.move",
json!({"moves": [{"clip": v.id.0, "track": "V1", "time": at}, {"clip": v.id.0, "track": "V2", "time": at}], "insert": insert, "linked": false}),
);
assert!(result.is_err(), "duplicate move entries were accepted: {result:?}");
assert_eq!(s.project, before);
assert_eq!(s.history.undo.len(), undo);
s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "V2", "time": at}], "insert": insert, "linked": false})).unwrap();
let after = s.project.clone();
assert_eq!(state(&s, v.id).1.0, at);
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project, after);
}
}
fn reject_duplicate_new_track_move(s: &mut Session, clip: ClipId, insert: bool, linked: bool, new_first: bool) {
s.state.selection = vec![clip];
let before = s.project.clone();
let selection = s.state.selection.clone();
let editor_state = serde_json::to_value(&s.state).unwrap();
let history = s.history.clone();
let journal = s.journal.clone();
let at = s.sequence_rate().tick_of(48).0;
let tracks = if new_first { ["new", "V2"] } else { ["V2", "new"] };
let result = s.execute(
"timeline.move",
json!({"moves": [{"clip": clip.0, "track": tracks[0], "time": at}, {"clip": clip.0, "track": tracks[1], "time": at}], "insert": insert, "linked": linked}),
);
assert!(matches!(result, Err(EngineError::Edit(filmcraft_edit::EditError::Other(_)))), "duplicate move entries must be rejected: {result:?}");
assert_eq!(s.project, before, "the project and its id allocator stay unchanged");
assert_eq!(s.state.selection, selection);
assert_eq!(serde_json::to_value(&s.state).unwrap(), editor_state);
assert_eq!(s.history.undo.len(), history.undo.len(), "no oldest undo entry is evicted");
assert_eq!(s.history.redo.len(), history.redo.len(), "no existing redo entry is lost");
assert_eq!(s.history.undo, history.undo);
assert_eq!(s.history.redo, history.redo);
assert_eq!(s.history.merge_key, history.merge_key);
assert_eq!(s.history.limit, history.limit);
assert_eq!(s.journal, journal, "the failed command is not journaled");
}
#[test]
fn duplicate_new_track_move_preserves_redo_and_retry_is_undoable() {
for insert in [false, true] {
for linked in [false, true] {
for new_first in [false, true] {
let mut s = demo();
let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0);
s.execute("timeline.setTrack", json!({"track": "V2", "locked": true})).unwrap();
let redo_project = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert!(!s.history.redo.is_empty());
reject_duplicate_new_track_move(&mut s, v.id, insert, linked, new_first);
let before = s.project.clone();
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project, redo_project, "the previously available redo still works");
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project, before);
let at = s.sequence_rate().tick_of(48).0;
s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "new", "time": at}], "insert": insert, "linked": linked})).unwrap();
let after = s.project.clone();
assert_eq!(state(&s, v.id).1.0, at);
assert_eq!(s.active_sequence().unwrap().video_tracks.len(), 3);
s.active_sequence().unwrap().check().unwrap();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project, before, "one undo removes the new track and restores the move");
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project, after);
}
}
}
}
#[test]
fn duplicate_new_track_move_preserves_full_undo_history() {
for insert in [false, true] {
for linked in [false, true] {
for new_first in [false, true] {
let mut s = demo();
let (_, v, _, _) = pair_in_an_empty_sequence(&mut s, 0.0);
assert_eq!(s.history.limit, 200);
while s.history.undo.len() < s.history.limit {
let step = s.history.undo.len();
s.edit_merged("History step", &format!("history-{step}"), |project, _| {
project.root.name = format!("History {step}");
Ok(())
})
.unwrap();
}
assert_eq!(s.history.undo.len(), s.history.limit);
assert!(s.history.merge_key.is_some());
reject_duplicate_new_track_move(&mut s, v.id, insert, linked, new_first);
let oldest = s.history.undo.first().unwrap().1.clone();
for _ in 0..s.history.limit {
s.execute("edit.undo", json!({})).unwrap();
}
assert_eq!(s.project, oldest, "every retained undo step remains usable");
}
}
}
}
#[test]
fn unique_new_track_moves_keep_linked_partner_behavior() {
for insert in [false, true] {
for linked in [false, true] {
let mut s = demo();
let (_, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0);
let before = s.project.clone();
let undo = s.history.undo.len();
let to = s.sequence_rate().tick_of(48);
let result =
s.execute("timeline.move", json!({"moves": [{"clip": v.id.0, "track": "new", "time": to.0}], "insert": insert, "linked": linked})).unwrap();
let sequence = s.active_sequence().unwrap();
assert_eq!(sequence.video_tracks.len(), 3);
assert_eq!(state(&s, v.id), (sequence.video_tracks[2].id, to));
assert_eq!(state(&s, a.id), (a_track, if linked { to } else { a.start }));
assert_eq!(result["moved"], if linked { json!([v.id.0, a.id.0]) } else { json!([v.id.0]) });
assert_eq!(s.history.undo.len(), undo + 1);
sequence.check().unwrap();
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project, after);
}
}
}
#[test]
fn repeated_new_track_copy_destinations_get_distinct_ids() {
for insert in [false, true] {
for linked in [false, true] {
for new_first in [false, true] {
let mut s = demo();
let (v_track, v, a_track, a) = pair_in_an_empty_sequence(&mut s, 0.0);
let before = s.project.clone();
let undo = s.history.undo.len();
let to = s.active_sequence().unwrap().duration() + s.sequence_rate().tick_of(48);
let tracks = if new_first { ["new", "V2"] } else { ["V2", "new"] };
let result = s
.execute(
"timeline.move",
json!({"moves": [{"clip": v.id.0, "track": tracks[0], "time": to.0}, {"clip": v.id.0, "track": tracks[1], "time": to.0}], "insert": insert, "linked": linked, "copy": true}),
)
.unwrap();
let copies: Vec<ClipId> = result["copied"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())).collect();
assert_eq!(copies.len(), if linked { 3 } else { 2 });
assert_eq!(copies.iter().copied().collect::<std::collections::HashSet<_>>().len(), copies.len());
assert!(!copies.contains(&v.id) && !copies.contains(&a.id));
assert_eq!((state(&s, v.id), state(&s, a.id)), ((v_track, v.start), (a_track, a.start)));
assert_eq!(s.state.selection, copies);
let sequence = s.active_sequence().unwrap();
assert_eq!(sequence.video_tracks.len(), 3);
assert!(copies.iter().all(|c| sequence.find_item(*c).is_some()), "every reported copy exists");
assert!(copies.iter().any(|c| state(&s, *c) == (sequence.video_tracks[2].id, to)));
assert!(copies.iter().any(|c| state(&s, *c) == (sequence.video_tracks[1].id, to)));
assert_eq!(s.history.undo.len(), undo + 1);
sequence.check().unwrap();
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(s.project, before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(s.project, after);
}
}
}
}
/// `timeline.move` of one clip left its linked sound behind although Linked Selection was on.
#[test]
fn move_takes_linked_partners_along() {
+49 -12
View File
@@ -3,7 +3,10 @@
//! An edit point is one edge of a clip plus a trim kind. Trim and Ripple act on that edge; Roll acts
//! on the edge shared with the adjacent clip on the same track. Trimming delegates to the
//! `timeline.trim` / `timeline.roll` commands so linked partners and sync locks behave the same as
//! with the mouse.
//! with the mouse. Linked Selection adds unlocked partner edges to both explicit and nearest-edge
//! selections. Shift-click toggles the linked group. A roll follows only linked cuts at the same
//! time, preserving split edits; regular/ripple trims follow the corresponding In or Out edges.
//! Each linked group is clamped and applied once, including when both partners were selected.
//!
//! **Trim Monitor** ([`monitor_info`]): while edit points are selected the Program monitor shows the
//! outgoing clip's last frame and the incoming clip's first frame, with the Out/In shift counters
@@ -87,6 +90,29 @@ fn kind_p(p: &Value) -> Option<TrimKind> {
serde_json::from_value(p.get("kind")?.clone()).ok()
}
/// The edges that the timeline command will edit together. Rolls follow only matching linked
/// cuts; regular/ripple trims follow the same edge of linked clips, including split edits.
fn linked_points(s: &Session, ep: EditPoint) -> Vec<EditPoint> {
let Some(seq) = s.active_sequence() else { return vec![ep] };
let clips = if ep.kind == TrimKind::Roll {
let Some((left, right)) = roll_pair(seq, &ep) else { return vec![ep] };
crate::commands::linked_roll_pairs(seq, left, right, s.state.linked_selection).into_iter().map(|(l, r)| if ep.out { l } else { r }).collect()
} else {
crate::commands::with_links(s, &[ep.clip])
};
clips
.into_iter()
.filter(|c| *c == ep.clip || seq.find_item(*c).is_some_and(|(t, _)| seq.track(t).is_some_and(|tr| !tr.locked)))
.map(|clip| EditPoint { clip, ..ep })
.collect()
}
/// Either side of a rolling cut denotes the same edit, even when selected separately.
fn same_edge(seq: &Sequence, a: &EditPoint, b: &EditPoint) -> bool {
(a.clip == b.clip && a.out == b.out)
|| (a.kind == TrimKind::Roll && b.kind == TrimKind::Roll && roll_pair(seq, a).is_some_and(|p| Some(p) == roll_pair(seq, b)))
}
/// `trim.selectEditPoint {clip, edge: "in"|"out", kind, add?}`
pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
let clip = p.get("clip").and_then(Value::as_u64).map(ClipId).ok_or_else(|| EngineError::Other("need `clip`".into()))?;
@@ -94,14 +120,17 @@ pub fn select(s: &mut Session, p: &Value) -> Result<Value> {
let kind = kind_p(p).unwrap_or(TrimKind::Trim);
s.active_sequence().and_then(|q| q.find_item(clip)).ok_or_else(|| EngineError::Other(format!("no clip {}", clip.0)))?;
let ep = EditPoint { clip, out, kind };
let points = linked_points(s, ep);
if p.get("add").and_then(Value::as_bool).unwrap_or(false) {
if let Some(i) = s.state.edit_points.iter().position(|e| e.clip == clip && e.out == out) {
s.state.edit_points.remove(i);
} else {
s.state.edit_points.push(ep);
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
let remove = s.state.edit_points.iter().any(|e| same_edge(seq, e, &ep));
let mut selected: Vec<_> = s.state.edit_points.iter().copied().filter(|e| !points.iter().any(|p| same_edge(seq, e, p))).collect();
if !remove {
selected.extend(points);
}
s.state.edit_points = selected;
} else {
s.state.edit_points = vec![ep];
s.state.edit_points = points;
}
s.state.selection.clear();
s.state.gap_selection = None;
@@ -125,7 +154,7 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result<Value> {
let targeted = s.state.active_sequence.and_then(|id| s.state.targeting.get(&id)).map(|t| t.targeted.clone()).unwrap_or_default();
let mut points = Vec::new();
for tr in seq.video_tracks.iter().chain(seq.audio_tracks.iter()) {
if !targeted.is_empty() && !targeted.contains(&tr.id) {
if tr.locked || (!targeted.is_empty() && !targeted.contains(&tr.id)) {
continue;
}
// nearest edge on this track
@@ -147,11 +176,11 @@ pub fn select_nearest(s: &mut Session, p: &Value) -> Result<Value> {
}
}
if let Some((_, ep)) = best {
// one roll point per edge: skip the partner's mirror of an edge already chosen
if kind == TrimKind::Roll && points.iter().any(|e: &EditPoint| roll_pair(seq, e) == roll_pair(seq, &ep)) {
continue;
for point in linked_points(s, ep) {
if !points.iter().any(|e| same_edge(seq, e, &point)) {
points.push(point);
}
}
points.push(ep);
}
}
s.state.edit_points = points;
@@ -204,19 +233,27 @@ fn trim_all_raw(s: &mut Session, delta_for: &dyn Fn(&Sequence, &EditPoint) -> Op
}
let pts = s.state.edit_points.clone();
let mut applied = Vec::new();
let mut edited = Vec::new();
for ep in &pts {
let seq = s.active_sequence().ok_or(EngineError::NoSequence)?;
if edited.iter().any(|e| same_edge(seq, e, ep)) {
continue;
}
let Some(delta) = delta_for(seq, ep) else { continue };
// Each timeline command clamps and edits its linked group together. Do not dispatch it
// again for the group's other selected edges (which would also ripple later clips twice).
let group = linked_points(s, *ep);
let r = match ep.kind {
TrimKind::Roll => {
let Some((l, r)) = roll_pair(seq, ep) else { continue };
s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0, "followLinks": false}))?
s.execute("timeline.roll", json!({"left": l.0, "right": r.0, "delta": delta.0}))?
}
k => s.execute(
"timeline.trim",
json!({"clip": ep.clip.0, "edge": if ep.out { "out" } else { "in" }, "mode": if k == TrimKind::Ripple { "ripple" } else { "regular" }, "delta": delta.0}),
)?,
};
edited.extend(group);
applied.push(r.get("delta").cloned().unwrap_or(Value::Null));
}
Ok(applied)
+181 -1
View File
@@ -3,6 +3,185 @@
use super::*;
use serde_json::json;
/// Two adjacent linked A/V clips with handles on both sides of their cut.
fn linked_cut() -> (Session, [(ClipId, ClipId); 2]) {
let mut s = demo();
s.execute("file.newSequence", json!({"name": "Linked trim", "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()).unwrap().id;
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();
let clips = [0, 1].map(|n| (q.video_tracks[0].items[n].id, q.audio_tracks[0].items[n].id));
(s, clips)
}
/// #3085: selecting either side of a picture cut also selects its linked sound edge.
#[test]
fn linked_edit_point_selection_and_shift_toggle() {
for kind in ["trim", "ripple", "roll"] {
for edge in ["in", "out"] {
let (mut s, clips) = linked_cut();
let (video, audio) = clips[usize::from(edge == "in")];
let p = json!({"clip": video.0, "edge": edge, "kind": kind});
s.execute("trim.selectEditPoint", p.clone()).unwrap();
assert_eq!(s.state.edit_points.iter().map(|e| e.clip).collect::<Vec<_>>(), vec![video, audio], "{kind} {edge}");
s.execute("trim.selectEditPoint", json!({"clip": audio.0, "edge": edge, "kind": kind, "add": true})).unwrap();
assert!(s.state.edit_points.is_empty(), "Shift-click removes the linked group");
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
s.execute("trim.selectEditPoint", p).unwrap();
assert_eq!(s.state.edit_points.len(), 1);
}
}
}
/// #3085: Trim Monitor rolls picture and sound once, with one undo/redo step.
#[test]
fn trim_mode_roll_moves_linked_audio_once() {
for edge in ["in", "out"] {
let (mut s, clips) = linked_cut();
let selected = clips[usize::from(edge == "in")].0;
s.execute("trim.selectEditPoint", json!({"clip": selected.0, "edge": edge, "kind": "roll"})).unwrap();
let before = s.project.clone();
let undo = s.history.undo.len();
s.execute("trim.nudge", json!({"frames": 3})).unwrap();
let q = s.active_sequence().unwrap();
for clip in [clips[0].0, clips[0].1] {
assert_eq!(q.find_item(clip).unwrap().1.end(), s.sequence_rate().tick_of(51));
}
for clip in [clips[1].0, clips[1].1] {
assert_eq!(q.find_item(clip).unwrap().1.start, s.sequence_rate().tick_of(51));
}
assert_eq!(s.history.undo.len(), undo + 1);
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
}
/// Explicitly selected partners must not run the same linked trim twice, including ripple shifts.
#[test]
fn selected_linked_regular_and_ripple_edges_move_once() {
for kind in ["trim", "ripple"] {
for out in [false, true] {
let (mut s, clips) = linked_cut();
let pair = clips[usize::from(!out)];
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
for (n, clip) in [pair.0, pair.1].into_iter().enumerate() {
s.execute("trim.selectEditPoint", json!({"clip": clip.0, "edge": if out { "out" } else { "in" }, "kind": kind, "add": n != 0})).unwrap();
}
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
let before = s.project.clone();
s.execute("trim.nudge", json!({"frames": if out { -3 } else { 3 }})).unwrap();
let q = s.active_sequence().unwrap();
let f = |n| s.sequence_rate().tick_of(n);
for clip in [pair.0, pair.1] {
let it = q.find_item(clip).unwrap().1;
let expected = if out {
(0, 45)
} else if kind == "ripple" {
(48, 93)
} else {
(51, 96)
};
assert_eq!((it.start, it.end()), (f(expected.0), f(expected.1)), "{kind} out={out}");
}
if out {
for clip in [clips[1].0, clips[1].1] {
assert_eq!(q.find_item(clip).unwrap().1.start, f(if kind == "ripple" { 45 } else { 48 }), "{kind}");
}
}
let after = s.project.clone();
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
s.execute("edit.redo", json!({})).unwrap();
assert_eq!(*s.project, *after);
}
}
}
/// Nearest-edge selection follows links even when only the video track is targeted, without
/// duplicating the sound when it is targeted too. Dynamic trimming uses the same grouped edit.
#[test]
fn nearest_linked_roll_and_dynamic_trim() {
let (mut s, clips) = linked_cut();
let rate = s.sequence_rate();
s.execute("timeline.setTargeting", json!({"track": "A1", "targeted": false})).unwrap();
s.execute("playhead.set", json!({"frame": 48})).unwrap();
s.execute("trim.selectNearest", json!({"kind": "roll"})).unwrap();
assert_eq!(s.state.edit_points.len(), 2);
assert!(s.state.edit_points.iter().any(|e| e.clip == clips[0].1));
s.execute("timeline.setTargeting", json!({"track": "A1", "targeted": true})).unwrap();
s.execute("trim.selectNearest", json!({"kind": "roll"})).unwrap();
assert_eq!(s.state.edit_points.len(), 2);
let before = s.project.clone();
s.execute("trim.shuttle", json!({"direction": "forward", "clock": 0.0})).unwrap();
s.execute("trim.shuttleStop", json!({"clock": 0.25})).unwrap();
let expected = rate.tick_of(48 + frames(&s, 0.25));
for clip in [clips[0].0, clips[0].1] {
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.end(), expected);
}
s.execute("edit.undo", json!({})).unwrap();
assert_eq!(*s.project, *before);
}
/// The shorter audio handle limits the picture too; separately applying the two cuts desyncs them.
#[test]
fn linked_trim_roll_uses_the_shortest_handle() {
let (mut s, clips) = linked_cut();
let rate = s.sequence_rate();
s.edit_sequence("Short audio handle", |q, _, _| {
q.find_item_mut(clips[1].1).unwrap().1.source_in = rate.tick_of(2);
Ok(())
})
.unwrap();
s.execute("trim.selectEditPoint", json!({"clip": clips[0].0.0, "edge": "out", "kind": "roll"})).unwrap();
s.execute("trim.nudge", json!({"frames": -10})).unwrap();
for clip in [clips[0].0, clips[0].1] {
assert_eq!(s.active_sequence().unwrap().find_item(clip).unwrap().1.end(), rate.tick_of(46));
}
}
/// Locked partners are neither selected nor changed; directly trimming a locked primary fails.
#[test]
fn linked_trim_respects_locked_tracks() {
for kind in ["trim", "ripple", "roll"] {
let (mut s, clips) = linked_cut();
s.execute("timeline.setTrack", json!({"track": "A1", "locked": true})).unwrap();
s.execute("trim.selectEditPoint", json!({"clip": clips[0].0.0, "edge": "out", "kind": kind})).unwrap();
assert_eq!(s.state.edit_points.len(), 1);
let audio = s.active_sequence().unwrap().audio_tracks[0].clone();
s.execute("trim.nudge", json!({"frames": -3})).unwrap();
assert_eq!(s.active_sequence().unwrap().audio_tracks[0], audio);
s.execute("trim.selectEditPoint", json!({"clip": clips[0].1.0, "edge": "out", "kind": kind})).unwrap();
let before = s.project.clone();
assert!(s.execute("trim.nudge", json!({"frames": -3})).is_err());
assert_eq!(*s.project, *before);
}
}
/// A split audio cut stays separate from a picture roll; linked selection off also edits one cut.
#[test]
fn split_or_disabled_linked_roll_leaves_audio_alone() {
for split in [false, true] {
let (mut s, clips) = linked_cut();
s.execute("sequence.linkedSelection", json!({"on": false})).unwrap();
if split {
s.execute("timeline.roll", json!({"left": clips[0].1.0, "right": clips[1].1.0, "deltaFrames": -4})).unwrap();
s.execute("sequence.linkedSelection", json!({"on": true})).unwrap();
}
let audio = s.active_sequence().unwrap().audio_tracks[0].clone();
s.execute("trim.selectEditPoint", json!({"clip": clips[0].0.0, "edge": "out", "kind": "roll"})).unwrap();
assert_eq!(s.state.edit_points.len(), 1);
s.execute("trim.nudge", json!({"frames": 3})).unwrap();
assert_eq!(s.active_sequence().unwrap().audio_tracks[0], audio);
assert_eq!(s.active_sequence().unwrap().find_item(clips[0].0).unwrap().1.end(), s.sequence_rate().tick_of(51));
}
}
fn demo() -> Session {
let mut s = Session::default();
s.execute("file.openDemoProject", json!({})).unwrap();
@@ -206,9 +385,10 @@ fn trim_monitor_sides_and_shift_counters() {
assert_eq!(s.execute("trim.monitor", json!({})).unwrap()["inShift"], json!(0));
// Apply Default Transitions to Selection at the edit point
let r = s.execute("trim.applyDefaultTransition", json!({})).unwrap();
assert_eq!(r["transitions"].as_array().unwrap().len(), 1, "{r}");
assert_eq!(r["transitions"].as_array().unwrap().len(), 2, "picture and linked sound: {r}");
let seq = s.execute("sequence.inspect", json!({})).unwrap();
assert!(!seq["video"][0]["transitions"].as_array().unwrap().is_empty());
assert!(!seq["audio"][0]["transitions"].as_array().unwrap().is_empty());
}
#[test]
+35
View File
@@ -96,6 +96,41 @@ fn every_video_effect_applies_by_id_and_name_and_renders() {
}
}
/// #1545: `effects.apply` reports each instance it added (clip, index, effect id), like
/// `timeline.place` reports its clips, so agents need no `sequence_inspect` round trip.
#[test]
fn effects_apply_reports_where_each_instance_landed() {
let mut s = demo();
let clip = first_v1_clip(&s);
let r = s.execute("effects.apply", json!({"clips": [clip], "effect": "gaussian_blur"})).unwrap();
let applied = r["applied"].as_array().unwrap();
assert_eq!(applied.len(), 1);
assert_eq!(applied[0]["clip"].as_u64(), Some(clip));
assert_eq!(applied[0]["effect"], "gaussian_blur");
let idx = applied[0]["index"].as_u64().unwrap() as usize;
let it = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.clone();
assert_eq!(it.effects.get(idx).map(|e| e.effect.as_str()), Some("gaussian_blur"), "index addresses the new instance");
// the index is usable straight away
s.execute("effects.remove", json!({"clip": clip, "index": idx})).unwrap();
assert!(s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(clip)).unwrap().1.effects.iter().all(|e| e.effect != "gaussian_blur"));
// several clips: one entry per instance, each with its own clip id
let clips: Vec<u64> = s.active_sequence().unwrap().video_tracks[0].items.iter().map(|c| c.id.0).collect();
assert!(clips.len() >= 2, "demo project has several V1 clips");
let r = s.execute("effects.apply", json!({"clips": clips, "effect": "invert"})).unwrap();
let got: Vec<u64> = r["applied"].as_array().unwrap().iter().map(|a| a["clip"].as_u64().unwrap()).collect();
assert_eq!(got, clips);
// audio effects report the (retargeted) audio clip, not the video clip that was passed
let video = first_v1_clip(&s);
let r = s.execute("effects.apply", json!({"clips": [video], "effect": "parametric_eq"})).unwrap();
let a = &r["applied"].as_array().unwrap()[0];
let target = a["clip"].as_u64().unwrap();
let (track, item) = s.active_sequence().unwrap().find_item(filmcraft_project::ClipId(target)).unwrap();
assert_eq!(s.active_sequence().unwrap().track(track).unwrap().kind, filmcraft_project::TrackKind::Audio);
assert_eq!(item.effects.get(a["index"].as_u64().unwrap() as usize).map(|e| e.effect.as_str()), Some("parametric_eq"));
}
#[test]
fn new_effect_params_are_settable_and_keyframable() {
let mut s = demo();
+74
View File
@@ -308,3 +308,77 @@ fn a_panic_while_rendering_ahead_is_an_error() {
assert!(matches!(r, Err(ExportError::Encode(_))), "{:?}", r.map(|_| ()));
});
}
// ------------------------------------------------------------------ max_threads
/// A provider that notes how many threads the pool it is asked on has, and which threads ask.
struct Noting<'a> {
inner: &'a SourceMap,
most: AtomicUsize,
threads: std::sync::Mutex<std::collections::HashSet<std::thread::ThreadId>>,
}
impl<'a> Noting<'a> {
fn new(inner: &'a SourceMap) -> Self {
Self { inner, most: AtomicUsize::new(0), threads: Default::default() }
}
}
impl SourceProvider for Noting<'_> {
fn source(&self, item: ItemId) -> Option<SharedSource> {
self.most.fetch_max(rayon::current_num_threads(), Ordering::SeqCst);
self.threads.lock().unwrap().insert(std::thread::current().id());
self.inner.source(item)
}
}
/// Export the test project from a pool of `outer` threads with `max_threads` set, and report the
/// biggest pool a frame was asked on and how many different threads asked.
fn threads_used(outer: usize, max_threads: u32, name: &str) -> (usize, usize) {
let (p, seq, m) = project();
let noting = Noting::new(&m);
let settings = ExportSettings { format: Format::Mjpeg, path: tmp(name), include_audio: false, max_threads, ..Default::default() };
pool(outer).install(|| export(&p, seq, &settings, &noting, &Progress::default())).unwrap();
(noting.most.load(Ordering::SeqCst), noting.threads.lock().unwrap().len())
}
#[test]
fn max_threads_caps_the_threads_an_export_runs_on() {
// uncapped, it renders on the pool it is called in
let (most, threads) = threads_used(8, 0, "cap-0.mov");
assert_eq!(most, 8);
assert!(threads > 3, "{threads} threads asked for frames: the uncapped export should have used several");
// capped, on a pool of that size
for cap in [1u32, 3] {
let (most, threads) = threads_used(8, cap, &format!("cap-{cap}.mov"));
assert_eq!(most, cap as usize, "cap {cap}");
assert!(threads <= cap as usize, "cap {cap}: {threads} threads asked for frames");
}
}
#[test]
fn max_threads_never_adds_threads() {
// a cap at or above the pool the export is called in changes nothing
assert_eq!(threads_used(4, 4, "cap-same.mov").0, 4);
assert_eq!(threads_used(4, 100, "cap-large.mov").0, 4);
}
#[test]
fn max_threads_does_not_change_the_file() {
for (format, ext) in [(Format::ProRes, "mov"), (Format::H264, "mp4"), (Format::Mjpeg, "mov")] {
let settings = |name: &str, max_threads: u32| ExportSettings { format, path: tmp(&format!("{name}.{ext}")), max_threads, ..Default::default() };
let reference = export_with_cores(5, &settings(&format!("{}-uncapped", format.id()), 0));
for cap in [1u32, 3] {
let capped = export_with_cores(5, &settings(&format!("{}-cap{cap}", format.id()), cap));
assert!(reference == capped, "{}: a cap of {cap} thread(s) gives a different file", format.label());
}
}
}
#[test]
fn max_threads_is_a_setting_of_the_json_interface() {
let s: ExportSettings = serde_json::from_str(r#"{"format":"mjpeg","maxThreads":2}"#).unwrap();
assert_eq!(s.max_threads, 2);
assert_eq!(ExportSettings::default().max_threads, 0, "all cores unless asked");
assert_eq!(serde_json::to_value(&s).unwrap()["maxThreads"], serde_json::json!(2));
}
+92 -51
View File
@@ -35,7 +35,7 @@ pub use pipeline::limit_rgba8;
pub use presets::{ExportPreset, builtin_presets};
pub use settings::*;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::sync::{Arc, Mutex, OnceLock, PoisonError, RwLock};
use filmcraft_isobmff::SampleEntry;
use filmcraft_project::{ItemId, Project};
@@ -325,6 +325,13 @@ pub struct ExportSettings {
/// `progress.total`, `finished` and the final status; this call only adds to `done`.
#[serde(default)]
pub part_of_batch: bool,
/// Most CPU threads the export may use (0 = all of them, as before). Rendering, the software
/// encoders and the rest of the export's parallel work run on a pool of this size; a number
/// above what the machine has (or above the pool the export already runs in) changes nothing.
/// The output does not depend on it. Hardware encoders and the decoders' own threads are not
/// counted.
#[serde(default)]
pub max_threads: u32,
/// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ).
#[serde(default)]
pub prores_profile: String,
@@ -571,6 +578,7 @@ impl Default for ExportSettings {
bitrate_kbps: 20_000,
burn_captions: false,
part_of_batch: false,
max_threads: 0,
prores_profile: String::new(),
dnx_profile: String::new(),
apv_profile: String::new(),
@@ -615,6 +623,42 @@ fn default_crf() -> f32 {
}
impl ExportSettings {
fn validate_audio_settings(&self) -> Result<()> {
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
}
if self.audio.float && self.audio.bits != 32 {
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
}
let sample_format = self.audio.sample_format();
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
}
Ok(())
}
fn validate_bitrate_mode(&self) -> Result<()> {
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()));
}
if self.bitrate_mode == BitrateMode::Crf {
if self.format == Format::Hevc {
return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if self.format == Format::Av1 {
return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) {
return Err(ExportError::Unsupported("CRF must be between 0 and 51".into()));
}
}
Ok(())
}
/// Reject settings the encoders cannot honour.
pub fn validate(&self) -> Result<()> {
if let Some(range) = self.range {
@@ -636,40 +680,14 @@ impl ExportSettings {
{
return Err(ExportError::Unsupported("output frame rate must be positive and at most 1000 fps".into()));
}
if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) {
return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into()));
}
if self.audio.float && self.audio.bits != 32 {
return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into()));
}
let sample_format = self.audio.sample_format();
if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) {
let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() };
return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label())));
}
self.validate_audio_settings()?;
if self.field_order != FieldOrder::Progressive && self.has_video() {
return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label())));
}
if self.effects.image_overlay.enabled && self.effects.image_overlay.path.trim().is_empty() {
return Err(ExportError::Unsupported("image overlay: no image file chosen".into()));
}
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()));
}
if self.bitrate_mode == BitrateMode::Crf {
if self.format == Format::Hevc {
return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if self.format == Format::Av1 {
return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into()));
}
if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) {
return Err(ExportError::Unsupported("CRF must be between 0 and 51".into()));
}
}
self.validate_bitrate_mode()?;
Ok(())
}
}
@@ -705,7 +723,7 @@ impl Progress {
/// [`Progress::eta`] at a given time (tests, and hosts with a clock of their own).
pub fn eta_at(&self, now: web_time::Instant) -> Option<std::time::Duration> {
let (done, total) = (self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed));
let mut pace = self.pace.lock().unwrap_or_else(|e| e.into_inner());
let mut pace = self.pace.lock().unwrap_or_else(PoisonError::into_inner);
pace.observe(now, done);
if self.finished.load(Ordering::Relaxed) || done >= total {
return None;
@@ -713,7 +731,7 @@ impl Progress {
pace.eta(total - done)
}
fn set_status(&self, s: impl Into<String>) {
*self.status.lock().unwrap_or_else(|e| e.into_inner()) = s.into();
*self.status.lock().unwrap_or_else(PoisonError::into_inner) = s.into();
}
}
@@ -910,7 +928,7 @@ pub fn timed<T>(stage: Stage, f: impl FnOnce() -> T) -> T {
/// Register a video encoder factory (tried before the built-in ones and those registered earlier).
/// Registering the same factory twice is harmless.
pub fn register_encoder(f: EncoderFactory) {
let mut g = video_factories().write().unwrap_or_else(|e| e.into_inner());
let mut g = video_factories().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) {
g.insert(0, f);
}
@@ -918,7 +936,7 @@ pub fn register_encoder(f: EncoderFactory) {
/// Whether `f` is among the registered video encoder factories (startup diagnostics, tests).
pub fn encoder_registered(f: EncoderFactory) -> bool {
video_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
video_factories().read().unwrap_or_else(PoisonError::into_inner).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
}
/// Renders one export frame on something other than the CPU reference renderer — today the GPU
@@ -948,7 +966,7 @@ fn frame_renderer_factories() -> &'static RwLock<Vec<FrameRendererFactory>> {
/// Register a frame-renderer factory (tried before those registered earlier). Registering the
/// same factory twice is harmless.
pub fn register_frame_renderer(f: FrameRendererFactory) {
let mut g = frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner());
let mut g = frame_renderer_factories().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) {
g.insert(0, f);
}
@@ -956,21 +974,21 @@ pub fn register_frame_renderer(f: FrameRendererFactory) {
/// Whether `f` is among the registered frame-renderer factories (startup diagnostics, tests).
pub fn frame_renderer_registered(f: FrameRendererFactory) -> bool {
frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
frame_renderer_factories().read().unwrap_or_else(PoisonError::into_inner).iter().any(|x| std::ptr::fn_addr_eq(*x, f))
}
/// Clear the registered frame-renderer factories. Test-only: registration is a process-wide
/// startup step and tests that register their own must not see each other's.
#[cfg(test)]
pub(crate) fn reset_frame_renderers_for_tests() {
frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner()).clear();
frame_renderer_factories().write().unwrap_or_else(PoisonError::into_inner).clear();
GPU_FRAMES.store(0, Ordering::Relaxed);
GPU_FALLBACKS.store(0, Ordering::Relaxed);
}
/// Pick the first registered factory that can build a renderer for this host.
pub(crate) fn build_frame_renderer() -> Option<Box<dyn FrameRenderer>> {
let g = frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner());
let g = frame_renderer_factories().read().unwrap_or_else(PoisonError::into_inner);
g.iter().filter_map(|f| f()).next()
}
@@ -1014,7 +1032,7 @@ pub fn gpu_render_stats() -> GpuRenderStats {
GpuRenderStats { frames: GPU_FRAMES.load(Ordering::Relaxed), fallbacks: GPU_FALLBACKS.load(Ordering::Relaxed) }
}
pub fn register_audio_encoder(f: AudioEncoderFactory) {
audio_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f);
audio_factories().write().unwrap_or_else(PoisonError::into_inner).insert(0, f);
}
type FormatProbe = (Format, fn() -> bool);
@@ -1028,7 +1046,7 @@ fn format_probes() -> &'static RwLock<Vec<FormatProbe>> {
/// works on this machine: `probe` runs when [`available`] asks (it should cache its answer).
/// Registering the same probe twice is harmless.
pub fn register_format_probe(format: Format, probe: fn() -> bool) {
let mut g = format_probes().write().unwrap_or_else(|e| e.into_inner());
let mut g = format_probes().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) {
g.push((format, probe));
}
@@ -1044,7 +1062,7 @@ fn hdr_probes() -> &'static RwLock<Vec<FormatProbe>> {
/// one: its encoders are hardware-only and not all of them take 10-bit pictures, so an HDR sequence
/// exports as tone-mapped SDR unless a probe says otherwise. Registering the same probe twice is harmless.
pub fn register_hdr_probe(format: Format, probe: fn() -> bool) {
let mut g = hdr_probes().write().unwrap_or_else(|e| e.into_inner());
let mut g = hdr_probes().write().unwrap_or_else(PoisonError::into_inner);
if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) {
g.push((format, probe));
}
@@ -1053,7 +1071,7 @@ pub fn register_hdr_probe(format: Format, probe: fn() -> bool) {
/// Whether a registered probe says the encoder of `format` writes HDR here (false without one).
pub fn hdr_available(format: Format) -> bool {
// the probes run outside the lock: one may take a while
let probes: Vec<fn() -> bool> = hdr_probes().read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect();
let probes: Vec<fn() -> bool> = hdr_probes().read().unwrap_or_else(PoisonError::into_inner).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect();
probes.into_iter().any(|p| p())
}
@@ -1063,7 +1081,7 @@ pub fn available(format: Format) -> bool {
if !Format::ALL.contains(&format) {
return false;
}
format.has_builtin_encoder() || format_probes().read().unwrap_or_else(|e| e.into_inner()).iter().any(|(f, probe)| *f == format && probe())
format.has_builtin_encoder() || format_probes().read().unwrap_or_else(PoisonError::into_inner).iter().any(|(f, probe)| *f == format && probe())
}
struct MjpegEncoder {
@@ -1789,7 +1807,7 @@ struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(buf);
self.0.lock().unwrap_or_else(PoisonError::into_inner).extend_from_slice(buf);
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
@@ -1799,12 +1817,11 @@ impl Write for SharedBuf {
/// Encode straight sRGB RGBA8 pixels as a PNG file (thumbnails, previews).
pub fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
encode_still(Format::PngSequence, rgba, w, h, false)
encode_still(Format::PngSequence, rgba, w, h, true)
}
/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha
/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written
/// as RGB.
/// Encode one still of an image sequence (also Export Frame). `alpha` keeps the alpha channel in
/// PNG, TIFF, Targa and OpenEXR, 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<u8>, w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality)
@@ -1834,9 +1851,13 @@ pub fn encode_still_with(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: b
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, exr),
Format::PngSequence => {
Format::PngSequence if alpha => {
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)?
}
Format::PngSequence => {
let rgb = raster::rgb(&rgba, w, h)?;
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgb, w, h, image::ExtendedColorType::Rgb8).map_err(enc)?
}
Format::TiffSequence | Format::BmpSequence => {
let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame size".into()))?;
let f = if format == Format::TiffSequence { image::ImageFormat::Tiff } else { image::ImageFormat::Bmp };
@@ -1851,8 +1872,28 @@ pub fn encode_still_with(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: b
Ok(out.into_inner())
}
/// Run an export (blocking; call from a worker thread).
/// A pool of `max_threads` threads to run an export on, or `None` when it should run where it is:
/// no cap (0), a cap that is not below the pool the caller is in already, or no threads to spare
/// (the browser).
fn capped_pool(max_threads: u32) -> Option<rayon::ThreadPool> {
let cap = max_threads as usize;
if cap == 0 || cap >= rayon::current_num_threads() || cfg!(target_arch = "wasm32") {
return None;
}
rayon::ThreadPoolBuilder::new().num_threads(cap).thread_name(|i| format!("filmcraft-export-{i}")).build().ok()
}
/// Run an export (blocking; call from a worker thread). With `settings.max_threads` set it runs on
/// a pool of that many threads, so its rendering and software encoding use at most that many cores.
pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result<Report> {
match capped_pool(settings.max_threads) {
Some(pool) => pool.install(|| export_here(project, seq, settings, sources, progress)),
None => export_here(project, seq, settings, sources, progress),
}
}
/// [`export`] on the threads it is called from.
fn export_here(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result<Report> {
settings.validate()?;
if stepped(settings.format) {
let mut ex = Exporter::new(project.clone(), seq, settings, progress)?;
@@ -1896,7 +1937,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
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;
*progress.loudness.lock().unwrap_or_else(PoisonError::into_inner) = a.loudness;
let mut out = Out::create(settings)?;
let io = |e: std::io::Error| ExportError::Io(e.to_string());
out.write_all(&head).map_err(io)?;
@@ -1994,7 +2035,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
if let Some(enc) = gif {
let buf = gif_buf.clone();
drop(enc);
let data = std::mem::take(&mut *buf.0.lock().unwrap_or_else(|e| e.into_inner()));
let data = std::mem::take(&mut *buf.0.lock().unwrap_or_else(PoisonError::into_inner));
total = write_output(settings, &settings.path, data)?;
}
(total, count)
+12 -12
View File
@@ -26,18 +26,6 @@ fn bounded_overlay_dimensions(w: usize, h: usize) -> Result<()> {
Ok(())
}
#[cfg(test)]
mod overlay_budget_tests {
use super::*;
#[test]
fn overlay_pixel_budget_is_checked_without_overflow() {
assert!(bounded_overlay_dimensions(4096, 4096).is_ok());
assert!(bounded_overlay_dimensions(4097, 4096).is_err());
assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err());
}
}
/// Where the rendered picture lands in the output frame.
#[derive(Clone, Copy, Debug, PartialEq)]
pub(crate) struct Geometry {
@@ -534,3 +522,15 @@ fn limit_px(c: &mut [f32; 3], lo: f32, hi: f32) {
}
c.iter_mut().for_each(|v| *v = v.clamp(0.0, hi.max(lo)));
}
#[cfg(test)]
mod overlay_budget_tests {
use super::*;
#[test]
fn overlay_pixel_budget_is_checked_without_overflow() {
assert!(bounded_overlay_dimensions(4096, 4096).is_ok());
assert!(bounded_overlay_dimensions(4097, 4096).is_err());
assert!(bounded_overlay_dimensions(usize::MAX, 2).is_err());
}
}
+1 -1
View File
@@ -624,7 +624,7 @@ impl ExportSettings {
px * fps * bpp
}
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::PngSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 0.45 * 8.0 * fps,
Format::BmpSequence => px * 3.0 * 8.0 * fps,
Format::TiffSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps,
Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps,
+24 -3
View File
@@ -238,7 +238,8 @@ fn openexr_sequences_compress() {
}
}
/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160).
/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160)
/// and writes RGB files without an alpha channel (#1863).
#[test]
fn image_sequences_keep_alpha_when_asked() {
let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None);
@@ -248,16 +249,36 @@ fn image_sequences_keep_alpha_when_asked() {
let mut s = ExportSettings { format: fmt, path: dir.path(&format!("a.{ext}")), alpha, ..Default::default() };
s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(2)));
export(&p, seq, &s, &m, &Progress::default()).unwrap();
let img = image::open(dir.0.join(format!("a000.{ext}"))).unwrap();
let file = dir.0.join(format!("a000.{ext}"));
if fmt == Format::PngSequence {
let colour_type = std::fs::read(&file).unwrap()[25];
assert_eq!(colour_type, if alpha { 6 } else { 2 }, "PNG colour type, alpha={alpha}");
}
let img = image::open(&file).unwrap();
let px = img.to_rgba8().get_pixel(32, 18).0;
assert_eq!(img.color().has_alpha(), alpha, "{fmt:?} alpha channel written, alpha={alpha}");
if alpha {
assert!(img.color().has_alpha(), "{fmt:?} must be written with an alpha channel");
assert!((100..=160).contains(&px[3]) && px[2] > 200 && px[0] < 15, "{fmt:?} straight alpha: {px:?}");
} else {
assert!(px[3] == 255 && px[2] > 100 && px[2] < 240 && px[0] < 15, "{fmt:?} flattened over black: {px:?}");
}
}
}
// the PNG size estimate counts the channels actually written, as TIFF's does
let rate = FrameRate::FPS_24;
let png = |alpha| ExportSettings { format: Format::PngSequence, alpha, ..Default::default() }.estimate_bytes(320, 180, rate, 48_000, rate.tick_of(1));
assert_eq!(png(false) * 4, png(true) * 3, "PNG estimate: {} RGB vs {} RGBA", png(false), png(true));
}
/// Thumbnails (`encode_png`) and an 8-bit PNG Export Frame keep the alpha they are given: only the
/// sequence's `alpha` switch drops it (#1863).
#[test]
fn png_stills_keep_their_alpha_channel() {
let half = filmcraft_render::Image::filled(4, 2, [0.0, 0.0, 0.5, 0.5]);
let thumb = encode_png(half.to_rgba8(), 4, 2).unwrap();
assert_eq!(thumb[25], 6, "encode_png colour type");
let frame = still::encode(&half, still::StillFormat::Png, 8).unwrap();
assert_eq!(frame[25], 6, "Export Frame PNG colour type");
}
#[test]
+2 -2
View File
@@ -77,8 +77,8 @@ pub fn encode(image: &Image, format: StillFormat, depth: u8) -> Result<Vec<u8>>
StillFormat::Tiff => Format::TiffSequence,
_ => Format::BmpSequence,
};
// 8-bit TIFF stays RGB, as Export Frame wrote it before; 16-bit output keeps alpha.
return crate::encode_still(f, rgba, w, h, false);
// 8-bit TIFF and BMP stay RGB, as Export Frame wrote them before; PNG and 16-bit output keep alpha.
return crate::encode_still(f, rgba, w, h, format == StillFormat::Png);
}
let capacity = samples.checked_mul(2).ok_or_else(bad)?;
let mut bytes = Vec::with_capacity(capacity);
+29
View File
@@ -216,6 +216,9 @@ pub fn decode(bytes: &[u8]) -> Result<Loaded, FormatError> {
/// Refuse only values that would overflow or exhaust memory later (`Sequence::check_bounds`);
/// structural rules such as overlaps are left to the editor, so projects that open today still open.
fn validate_loaded(mut loaded: Loaded) -> Result<Loaded, FormatError> {
// Graphic layers written before they carried a uid get a stable identity now, before any
// undo history exists. The field is optional in the schema, so this is not a schema upgrade.
loaded.project.ensure_graphic_layer_uids();
// Embedded LUT text is rendered on the hot path. Drop oversize entries at load time so a
// hostile project cannot retain an unrenderable multi-megabyte source or repeatedly offer it
// to renderers. Clips that refer to one simply have no LUT, like a deleted library entry.
@@ -437,6 +440,32 @@ mod tests {
assert_eq!(loaded.project.sequence(seq).unwrap().video_tracks[0].items.len(), 3);
}
/// Opening a project written before graphic layers had uids assigns stable, unique identities
/// without a schema upgrade. The old file still opens, and its two byte-identical layers (same
/// geometry, different text) can no longer be confused after a reorder.
#[test]
fn opening_a_legacy_project_assigns_unique_graphic_layer_uids() {
use filmcraft_project::graphic::{layer_indices, new_text_layer};
use filmcraft_project::{ItemKind, SequenceSettings, TrackKind};
use filmcraft_time::{FrameRate, Tick, TimeRange};
let mut p = Project::new("Legacy graphics");
let rate = FrameRate::FPS_25;
let seq = p.new_sequence("Sequence 01", SequenceSettings::default(), 1, 1, None);
let graphic = p.add_item("Graphic", filmcraft_project::Label::Rose, ItemKind::Graphic { width: 1920, height: 1080, rate }, None);
let mut item = p.make_track_item(graphic, TrackKind::Video, Tick::ZERO, TimeRange::new(Tick::ZERO, rate.tick_of(100)), rate).unwrap();
item.effects = vec![new_text_layer("one", Default::default(), 100.0), new_text_layer("two", Default::default(), 100.0)];
assert!(item.effects.iter().all(|e| filmcraft_project::gtemplate::layer_uid(e) == 0));
p.sequence_mut(seq).unwrap().video_tracks[0].items = vec![item];
let loaded = decode(&encode(&p, false)).expect("an old project still opens");
let item = &loaded.project.sequence(seq).unwrap().video_tracks[0].items[0];
let uids: Vec<u64> = layer_indices(&item.effects).into_iter().map(|i| filmcraft_project::gtemplate::layer_uid(&item.effects[i])).collect();
assert_eq!(uids.len(), 2);
assert!(uids.iter().all(|uid| *uid != 0), "opening assigned uids: {uids:?}");
assert_ne!(uids[0], uids[1], "identical geometry and different text still get distinct identities");
let again = decode(&encode(&loaded.project, false)).expect("the upgraded project round-trips");
assert_eq!(again.project, loaded.project, "assigned uids are stable across saves");
}
#[test]
fn roundtrip_current() {
let p = Project::new("Round Trip");
+10 -3
View File
@@ -442,6 +442,13 @@ impl VideoFrame {
let cw = w.div_ceil(1 << sx);
let bits = *bits;
let amax = ((1u32 << bits) - 1) as f32;
// Per-code tables (exactly the values `normalize_*` returns) instead of two
// divisions per sample; a code past the table (a corrupt plane) is computed.
let codes = if bits <= 12 { 1usize << bits } else { 0 };
let ytab: Vec<f32> = (0..codes as u32).map(|v| normalize_y(v, bits, info.range)).collect();
let ctab: Vec<f32> = (0..codes as u32).map(|v| normalize_c(v, bits, info.range)).collect();
let ny = |v: u16| ytab.get(usize::from(v)).copied().unwrap_or_else(|| normalize_y(u32::from(v), bits, info.range));
let nc = |v: u16| ctab.get(usize::from(v)).copied().unwrap_or_else(|| normalize_c(u32::from(v), bits, info.range));
out.par_chunks_mut(r.w * 4).enumerate().for_each(|(ry, row)| {
let oy = r.y + ry;
for rx in 0..r.w {
@@ -453,9 +460,9 @@ impl VideoFrame {
for dx in 0..n {
let x = (ox * n + dx).min(w - 1);
let cx = x >> sx;
let yy = normalize_y(planes[0][y * w + x] as u32, bits, info.range);
let u = normalize_c(planes[1][cy * cw + cx] as u32, bits, info.range);
let v = normalize_c(planes[2][cy * cw + cx] as u32, bits, info.range);
let yy = ny(planes[0][y * w + x]);
let u = nc(planes[1][cy * cw + cx]);
let v = nc(planes[2][cy * cw + cx]);
let rgb = ycbcr_to_rgb(yy, u, v, info.matrix);
let a = alpha.as_ref().map_or(1.0, |al| al[y * w + x] as f32 / amax);
acc[0] += q(rgb[0]) * a;
+1 -1
View File
@@ -24,5 +24,5 @@ filmcraft-media = { workspace = true }
filmcraft-project = { workspace = true }
filmcraft-render = { workspace = true }
filmcraft-gpu = { workspace = true }
pollster = "0.4"
pollster = "1"
wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] }
+2 -2
View File
@@ -18,11 +18,11 @@ filmcraft-render = { workspace = true }
filmcraft-time = { workspace = true }
wgpu = { version = "30", default-features = false, features = ["wgsl"] }
log = { workspace = true }
pollster = "0.4"
pollster = "1"
rayon = { workspace = true }
[dev-dependencies]
pollster = "0.4"
pollster = "1"
filmcraft-export = { workspace = true }
image = { version = "0.25", default-features = false, features = ["png"] }
wgpu = { version = "30", features = ["wgsl", "metal", "vulkan", "dx12", "gles"] }
+2 -1
View File
@@ -151,7 +151,8 @@ fn gpu_blend_modes_match_cpu() {
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
)
.unwrap();
let demo = Arc::new(VideoFrame::rgba8(320, 180, pixels));
let base = ramp_layer(200, 180, 3);
let top = ramp_layer(160, 120, 0);
+4 -2
View File
@@ -116,7 +116,8 @@ fn gpu_matches_cpu_plan() {
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
)
.unwrap();
let rgba = Arc::new(VideoFrame::rgba8(320, 180, pixels));
let (w, h) = (320usize, 180usize);
let plan = FramePlan::Layers {
@@ -464,7 +465,8 @@ fn gpu_draws_yuv_alpha_plane_like_the_cpu() {
1.0,
24,
filmcraft_time::FrameRate::FPS_24,
);
)
.unwrap();
let background = Arc::new(VideoFrame::rgba8(320, 180, pixels));
for bits in [8, 10, 12] {
let mut c = GpuCompositor::new(&dev, &q);
+3 -3
View File
@@ -232,7 +232,7 @@ fn filter_vertical<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y: u
let mut w = [0u64; N];
for (i, w) in w.iter_mut().enumerate() {
let o = (y + i) * stride + x - 4;
let Some(&b) = pix.get(o..).and_then(|p| p.first_chunk::<8>()) else {
let Some(&b) = pix.get(o..).and_then(<[u8]>::first_chunk::<8>) else {
return;
};
*w = u64::from_le_bytes(b);
@@ -259,7 +259,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
let base = (y - 4) * stride + x;
let mut s = [[0i16; N]; 8];
for (k, row) in s.iter_mut().enumerate() {
let Some(src) = pix.get(base + k * stride..).and_then(|p| p.first_chunk::<N>()) else {
let Some(src) = pix.get(base + k * stride..).and_then(<[u8]>::first_chunk::<N>) else {
return;
};
for i in 0..N {
@@ -269,7 +269,7 @@ fn filter_horizontal<const N: usize>(pix: &mut [u8], stride: usize, x: usize, y:
filter_lanes(&mut s, ep, chroma);
let (k0, k1) = if chroma { (3, 5) } else { (1, 7) };
for (k, row) in s.iter().enumerate().take(k1).skip(k0) {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(|p| p.first_chunk_mut::<N>()) else {
let Some(dst) = pix.get_mut(base + k * stride..).and_then(<[u8]>::first_chunk_mut::<N>) else {
return;
};
for i in 0..N {
+3 -3
View File
@@ -888,7 +888,7 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (raster & 3) * 4;
let py = self.mb_y * 16 + (raster >> 2) * 4;
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
let Some(blk) = self.s.coef.get_mut(raster * 16..).and_then(<[i32]>::first_chunk_mut::<16>) else {
return;
};
transform::idct4_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
@@ -902,7 +902,7 @@ impl<'a> SliceDecoder<'a> {
let stride = self.pic.planes.width;
let px = self.mb_x * 16 + (b8 & 1) * 8;
let py = self.mb_y * 16 + (b8 >> 1) * 8;
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(|c| c.first_chunk_mut::<64>()) else {
let Some(blk) = self.s.coef.get_mut(b8 * 64..).and_then(<[i32]>::first_chunk_mut::<64>) else {
return;
};
transform::idct8_add(blk, &mut self.pic.planes.y[py * stride + px..], stride);
@@ -921,7 +921,7 @@ impl<'a> SliceDecoder<'a> {
}
let px = self.mb_x * 8 + (b & 1) * 4;
let py = self.mb_y * 8 + (b >> 1) * 4;
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(|c| c.first_chunk_mut::<16>()) else {
let Some(blk) = self.s.coef_c[c].get_mut(b * 16..).and_then(<[i32]>::first_chunk_mut::<16>) else {
continue;
};
let plane = if c == 0 { &mut self.pic.planes.cb } else { &mut self.pic.planes.cr };
+39 -31
View File
@@ -103,40 +103,48 @@ fn sweep() {
for n in depth.truncations(seed.len()) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
header_flips(&depth, &seed, name);
random_rounds(&depth, &seed, name);
}
}
/// SPS/PPS/first slice header region: every byte to a few values, every bit.
fn header_flips(depth: &Depth, seed: &[u8], name: &str) {
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
let mut d = seed.to_vec();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.to_vec();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
}
fn random_rounds(depth: &Depth, seed: &[u8], name: &str) {
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.to_vec();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
}
}
+2 -2
View File
@@ -56,7 +56,7 @@ fn filt_w<'a, P: Px + 'a, const T: usize, const W: usize>(tap: impl Fn(usize) ->
let mut acc = [0i32; W];
for k in 0..T {
let c = f[k] as i32;
let Some(p) = tap(k).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = tap(k).and_then(<[P]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
@@ -80,7 +80,7 @@ fn filt16_w<'a, P: Px + 'a, const T: usize, const W: usize>(tap: impl Fn(usize)
let mut acc = [0i16; W];
for k in 0..T {
let c = f[k] as i16;
let Some(p) = tap(k).and_then(|s| s.first_chunk::<W>()) else {
let Some(p) = tap(k).and_then(<[P]>::first_chunk::<W>) else {
return;
};
for j in 0..W {
+39 -31
View File
@@ -104,40 +104,48 @@ fn sweep() {
for n in depth.truncations(seed.len()) {
run_all(&seed[..n], &format!("{name} truncate {n}"));
}
// SPS/PPS/first slice header region: every byte to a few values, every bit
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
let mut d = seed.clone();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
header_flips(&depth, &seed, name);
random_rounds(&depth, &seed, name);
}
}
/// SPS/PPS/first slice header region: every byte to a few values, every bit.
fn header_flips(depth: &Depth, seed: &[u8], name: &str) {
for p in 0..seed.len().min(120) {
for (i, v) in VALUES.into_iter().enumerate() {
if !depth.value(p, i) {
continue;
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.clone();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
let mut d = seed.to_vec();
d[p] = v;
run_all(&d, &format!("{name} byte {p}={v:#x}"));
}
for b in 0..8 {
if !depth.bit(p, b) {
continue;
}
let mut d = seed.to_vec();
d[p] ^= 1 << b;
run_all(&d, &format!("{name} flip {p}.{b}"));
}
}
}
fn random_rounds(depth: &Depth, seed: &[u8], name: &str) {
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.to_vec();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
let mut rng = Rng(0x9e3779b97f4a7c15 ^ name.len() as u64);
// Skipped rounds still draw their mutations, so round i matches round i of a full run.
for i in 0..depth.rounds {
let mut d = seed.clone();
for _ in 0..1 + rng.below(6) {
let p = rng.below(d.len());
if rng.below(2) == 0 {
d[p] ^= 1 << rng.below(8);
} else {
d[p] = rng.next() as u8;
}
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
if depth.round(i) {
run_all(&d, &format!("{name} random {i}"));
}
}
}
+5 -1
View File
@@ -76,7 +76,7 @@ none of the exports brings one back. Each such clip is named in the export `Repo
| Format | Written as | On import into FilmCraft |
|---|---|---|
| FCP7 XML | a clip item without media (its layers as FilmCraft filter ids) | not read back; the import report names the skipped clip item |
| FCPXML | a clip without media | not read back |
| FCPXML | a gap on V1; left out on other tracks (a gap exists only in the primary storyline) | not read back |
| OTIO | a clip with a `MissingReference` (effects in the `filmcraft` metadata) | offline media |
| EDL | an ordinary event with the reel the reel mode gives | an ordinary clip |
| AAF | a gap | nothing |
@@ -85,6 +85,10 @@ OMF carries no video at all.
## Tests
- `src/otio.rs` (unit tests): OTIO repeats a nested sequence for every use, so compound clips that each hold
several uses of the next are exponential (a 3 KB FCPXML nested 12 deep made the export run for minutes and grow to
gigabytes). After 25 000 clips written inside nested sequences the rest are gaps, with one warning; an ordinary
project is far below that and unchanged.
- `tests/it/aaf_omf.rs`: AAF round trips at 25, 29.97 DF, 23.976 and 59.94 DF (clips, gaps,
two-sided and one-sided transitions, links, markers, start timecodes, constant and keyframed
gain, media markers and timecode); 512-byte sectors; breakout to mono; embedded trimmed audio
+37 -23
View File
@@ -887,6 +887,10 @@ impl Exp<'_, '_> {
if merged_audio.contains(&it.id) {
continue;
}
if let Some(what) = self.unsupported(it, rate) {
self.report.warn(format!("{what}: it is left out and is not read back on import"));
continue;
}
let Some(pi) = els.iter().position(|e| e.start <= it.start && it.start < e.end) else {
self.report.warn("a connected clip could not be attached and was dropped");
continue;
@@ -954,30 +958,29 @@ impl Exp<'_, '_> {
)
}
/// How the report names a clip FCPXML cannot carry (a generator, a title, an adjustment layer); `None` for any
/// other clip. Such a clip has no asset to reference, and an empty `ref` is not a valid IDREF: Final Cut Pro
/// rejects the whole file. A `<gap>` exists only in a spine, so a connected one is left out instead.
fn unsupported(&self, it: &TrackItem, rate: FrameRate) -> Option<String> {
if let Some(g) = generator_of(self.p, base_item(self.p, it.item)) {
return Some(format!("generator \"{}\" is not supported in FCPXML", g.label()));
}
crate::common::uncarried_clip(self.p, it, rate).map(|clip| format!("{clip} is not supported in FCPXML"))
}
/// A clip element at `offset` (in its parent's local time).
#[allow(clippy::too_many_arguments)]
fn clip_el(&mut self, it: &TrackItem, audio: Option<&TrackItem>, kind: TrackKind, offset: Tick, lane: Option<i32>, seq: &Sequence, primary: bool) -> El {
let rate = seq.settings.frame_rate;
let base = base_item(self.p, it.item);
let is_seq = matches!(self.p.item(base).map(|i| &i.kind), Some(ItemKind::Sequence(_)));
let generator = generator_of(self.p, base).cloned();
let lane_attr = lane.map(|l| format!(" lane=\"{l}\"")).unwrap_or_default();
let name = crate::xml::escape_attr(&it.name);
if let Some(g) = generator {
self.report.warn(format!("generator \"{}\" is not supported in FCPXML; written as a gap", g.label()));
return El {
start: it.start,
end: it.end(),
local: offset,
xml_open: format!(
"<gap name=\"{name}\"{lane_attr} offset=\"{}\" start=\"{}\" duration=\"{}\"",
fmt_time(offset, rate),
fmt_time(offset, rate),
fmt_time(it.duration, rate)
),
children: vec![],
close: "gap".into(),
};
// In the primary storyline, a clip FCPXML cannot carry becomes a gap that keeps the timing
// (connected ones never get here: see `unsupported`).
if let Some(what) = self.unsupported(it, rate) {
self.report.warn(format!("{what}: it is written as a gap and is not read back on import"));
return gap_el(it, &name, offset, rate);
}
// Local timeline of the element: origin + source time (retimed clips use a timeMap).
let (tag, r, origin) = if is_seq {
@@ -990,13 +993,7 @@ impl Exp<'_, '_> {
let origin = self.p.sequence(base).map(|s| s.settings.frame_rate.tick_of(s.start_timecode)).unwrap_or(Tick::ZERO);
("ref-clip", r, origin)
} else {
let r = match crate::common::uncarried_clip(self.p, it, rate) {
Some(clip) => {
self.report.warn(format!("{clip} is not supported in FCPXML: it is written as a clip without media and is not read back on import"));
String::new()
}
None => self.asset(base, rate),
};
let r = self.asset(base, rate);
let m = item_media(self.p, base);
let mrate = m.and_then(|m| m.info.video.as_ref().map(|v| v.frame_rate)).unwrap_or(rate);
let st = m.and_then(|m| m.info.start_timecode).map(|f| mrate.tick_of(f)).unwrap_or(Tick::ZERO);
@@ -1154,6 +1151,23 @@ impl Exp<'_, '_> {
}
}
/// A primary-storyline `<gap>` standing in for a clip FCPXML cannot carry; it keeps the clip's place and length.
fn gap_el(it: &TrackItem, name: &str, offset: Tick, rate: FrameRate) -> El {
El {
start: it.start,
end: it.end(),
local: offset,
xml_open: format!(
"<gap name=\"{name}\" offset=\"{}\" start=\"{}\" duration=\"{}\"",
fmt_time(offset, rate),
fmt_time(offset, rate),
fmt_time(it.duration, rate)
),
children: vec![],
close: "gap".into(),
}
}
fn wrap(tag: &str, attrs: &str, params: &[String]) -> String {
if params.is_empty() { format!("<{tag}{attrs}/>") } else { format!("<{tag}{attrs}>\n{}\n</{tag}>", params.join("\n")) }
}
+79 -1
View File
@@ -113,17 +113,27 @@ fn color_label(s: &str) -> Label {
// Export
// ---------------------------------------------------------------------------------------------
/// Most clips an export writes inside nested sequences. OTIO has no references: every use of a nested
/// sequence is written in full, so a compound clip that holds three uses of another, nested ten deep,
/// is 3^10 copies, and a 3 KB document made the export run for minutes and grow to gigabytes. Each
/// clip is about 10 KB of JSON, so this bounds the extra output near 250 MB; real projects use a
/// few thousand. Past it, further nested sequences are written as gaps, with a warning.
const MAX_NESTED_ITEMS: usize = 25_000;
struct Exp<'a, 'r> {
p: &'a Project,
opts: &'a ExportOptions,
report: &'r mut Report,
stack: Vec<ItemId>,
/// Clips written inside nested sequences so far, and the limit for it (see [`MAX_NESTED_ITEMS`]).
nested_items: usize,
nested_limit: usize,
}
pub(crate) fn export(p: &Project, seq_id: ItemId, opts: &ExportOptions, report: &mut Report) -> Result<String> {
let seq = p.sequence(seq_id).ok_or(Error::NoSequence(seq_id))?;
let rate = seq.settings.frame_rate;
let mut x = Exp { p, opts, report, stack: vec![seq_id] };
let mut x = Exp { p, opts, report, stack: vec![seq_id], nested_items: 0, nested_limit: MAX_NESTED_ITEMS };
let name = opts.name.clone().unwrap_or_else(|| p.item(seq_id).map(|i| i.name.clone()).unwrap_or_default());
let stack = x.stack_json(seq_id, &name, None);
let tl = json!({
@@ -247,6 +257,9 @@ impl Exp<'_, '_> {
}
fn item_json(&mut self, it: &TrackItem, kind: TrackKind, rate: FrameRate) -> Value {
if self.stack.len() > 1 {
self.nested_items = self.nested_items.saturating_add(1);
}
let base = base_item(self.p, it.item);
let mut effects = Vec::new();
if let Some(h) = it.frame_hold {
@@ -269,6 +282,17 @@ impl Exp<'_, '_> {
self.report.warn("recursive nested sequence skipped");
return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}});
}
if self.nested_items >= self.nested_limit {
// once: later nested sequences hit this too, and the message would repeat
if self.nested_items < usize::MAX {
self.nested_items = usize::MAX;
self.report.warn(format!(
"more than {} clips written inside nested sequences: the rest are gaps (OTIO repeats a nested sequence for every use)",
self.nested_limit
));
}
return json!({"OTIO_SCHEMA": "Gap.1", "name": "", "source_range": tr(Tick::ZERO, it.duration, rate), "effects": [], "markers": [], "enabled": true, "metadata": {}});
}
self.stack.push(base);
let mut s = self.stack_json(base, &it.name, Some((it.source_in, it.duration, rate)));
self.stack.pop();
@@ -823,4 +847,58 @@ mod tests {
let back = read_rt(&rt(t, FrameRate::FPS_24)).unwrap();
assert!((back - t).abs() <= Tick(1));
}
/// A tree of compound clips: `uses` uses of the next level in each, `levels` media levels deep, leaf clips in the last.
fn nested_tree(levels: usize, uses: usize) -> String {
let mut xml = String::from(
r#"<?xml version="1.0"?><fcpxml version="1.10"><resources><format id="f1" frameDuration="100/2500s" width="1920" height="1080"/>
<asset id="a1" name="c" start="0s" duration="10s" hasVideo="1" format="f1"><media-rep kind="original-media" src="file:///tmp/c.mov"/></asset>"#,
);
for level in 0..levels {
let inner: String = (0..uses)
.map(|i| {
if level + 1 < levels {
format!(r#"<ref-clip ref="m{}" offset="{i}s" duration="1s"/>"#, level + 1)
} else {
format!(r#"<asset-clip ref="a1" offset="{i}s" duration="1s"/>"#)
}
})
.collect();
xml.push_str(&format!(r#"<media id="m{level}" name="L{level}"><sequence format="f1" tcStart="0s"><spine>{inner}</spine></sequence></media>"#));
}
xml.push_str(r#"</resources><library><event name="E"><project name="P"><sequence format="f1" tcStart="0s"><spine><ref-clip ref="m0" offset="0s" duration="1s"/></spine></sequence></project></event></library></fcpxml>"#);
xml
}
/// OTIO repeats a nested sequence for every use, so 10 uses nested 3 deep is 111 copies and a 3 KB
/// document nested 12 deep made the export run for minutes and grow to gigabytes. Past the limit on clips
/// written inside nested sequences the rest are gaps, with one warning. The test lowers the limit so it
/// stays tiny: without the check all 112 stacks are written.
#[test]
fn nested_copies_stop_at_the_limit() {
let (imported, _) = crate::import(nested_tree(3, 10).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds");
let seq = imported.sequences[0];
let opts = ExportOptions::default();
let write = |limit: usize| {
let mut report = Report::default();
let mut x = Exp { p: &imported.project, opts: &opts, report: &mut report, stack: vec![seq], nested_items: 0, nested_limit: limit };
let stacks = x.stack_json(seq, "t", None).to_string().matches("\"Stack.1\"").count();
(stacks, report)
};
let (all, report) = write(usize::MAX - 1);
assert_eq!(all, 112);
assert!(!report.mentions("nested sequences"), "{report}");
let (capped, report) = write(200);
assert!(capped < 60, "{capped} stacks");
assert!(report.mentions("clips written inside nested sequences"), "{report}");
assert_eq!(report.warnings().filter(|w| w.message.contains("inside nested sequences")).map(|w| w.count).sum::<u32>(), 1);
}
/// An ordinary project with nested sequences is far below the real limit and exports untouched.
#[test]
fn ordinary_nesting_is_not_capped() {
let (imported, _) = crate::import(nested_tree(3, 4).as_bytes(), crate::Format::Fcpxml, None).expect("import succeeds");
let (_, report) = crate::export(&imported.project, imported.sequences[0], crate::Format::Otio, &ExportOptions::default()).expect("export succeeds");
assert!(!report.mentions("inside nested sequences"), "{report}");
}
}
@@ -144,3 +144,19 @@ fn fcp7_import_reports_a_clip_item_whose_file_is_not_defined() {
let (_, report) = import(&bytes, Format::Fcp7Xml, None).unwrap();
assert!(!report.mentions("does not define"), "{report}");
}
/// An empty `ref` is not a valid IDREF: Final Cut Pro rejects the whole file. The adjustment layer
/// on V1 becomes a gap in the primary storyline; the connected title is left out (a `<gap>` can
/// only sit in a spine). The media clip still references its asset.
#[test]
fn fcpxml_has_no_empty_ref_for_a_title() {
let (p, s) = project_with_a_title();
let (bytes, report) = export_report(&p, s, Format::Fcpxml);
let xml = String::from_utf8(bytes).unwrap();
assert!(!xml.contains("ref=\"\""), "{xml}");
assert!(!xml.contains("Opening"), "{xml}");
assert_eq!(xml.matches("<gap name=\"Grade\" offset=").count(), 1, "{xml}");
assert!(xml.contains("<asset-clip ref=\"r"), "{xml}");
assert!(report.mentions(&format!("{TITLE} is not supported in FCPXML: it is left out")), "{report}");
assert!(report.mentions(&format!("{LAYER} is not supported in FCPXML: it is written as a gap")), "{report}");
}
+1 -1
View File
@@ -12,7 +12,7 @@ fn main() {
let rows = gens.len().div_ceil(cols);
let mut sheet = image::RgbaImage::new(w * cols as u32, h * rows as u32);
for (i, g) in gens.iter().enumerate() {
let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976);
let px = render(g, w, h, 3.3, 79, FrameRate::FPS_23_976).unwrap();
let img = image::RgbaImage::from_raw(w, h, px).unwrap();
image::imageops::overlay(&mut sheet, &img, ((i % cols) as u32 * w) as i64, ((i / cols) as u32 * h) as i64);
}
+143 -12
View File
@@ -3,6 +3,9 @@
//!
//! Every generator is a pure function of (settings, media time, output scale): deterministic and
//! resolution-independent, so low-resolution playback renders directly at the reduced size.
//!
//! Counting Leader beeps last one source frame at each second mark. Use exact audio sample
//! positions and the rational source frame rate for the gate, without rounded timestamp boundaries.
use std::f32::consts::{PI, TAU};
use std::sync::Arc;
@@ -16,6 +19,10 @@ use serde::{Deserialize, Serialize};
use crate::digits::{draw_text, text_width};
use crate::{AudioStreamInfo, FrameCache, FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result, VideoStreamInfo};
/// Generator frames are RGBA8 and cached. Keep one project-requested frame within the media
/// working-set budget before allocating it.
const MAX_GENERATOR_PIXELS: u64 = 1 << 25;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum DemoScene {
Aurora,
@@ -150,7 +157,7 @@ impl MediaSource for GeneratorSource {
let (w, h) = (((v.width as f32 * s).round() as u32).max(2), ((v.height as f32 * s).round() as u32).max(2));
let t = rate.tick_of(frame).seconds() as f32;
self.cache.get_or_insert_with((frame, bucket), || {
let px = render(&self.generator, w, h, t, frame, rate);
let px = render(&self.generator, w, h, t, frame, rate)?;
Ok(Arc::new(VideoFrame::rgba8(w, h, px).with_pts(rate.tick_of(frame))))
})
}
@@ -162,13 +169,14 @@ impl MediaSource for GeneratorSource {
let mut out = AudioBuffer::silence(sample_rate, 2, frames);
let sr = sample_rate as f32;
let total = self.info.duration.to_units_floor(sample_rate as i64);
let frame_rate = self.info.frame_rate().sane();
for i in 0..frames {
let n = start + i as i64;
if n < 0 || n >= total {
continue;
}
let t = n as f32 / sr;
let (l, r) = audio_sample(&self.generator, t, n);
let (l, r) = audio_sample(&self.generator, t, n, sample_rate, frame_rate);
out.channels[0][i] = l;
out.channels[1][i] = r;
}
@@ -188,7 +196,7 @@ fn hash(n: i64) -> f32 {
(x >> 40) as f32 / (1u64 << 24) as f32 * 2.0 - 1.0
}
fn audio_sample(g: &Generator, t: f32, n: i64) -> (f32, f32) {
fn audio_sample(g: &Generator, t: f32, n: i64, sample_rate: u32, frame_rate: FrameRate) -> (f32, f32) {
match g {
Generator::BarsAndTone => {
let s = (TAU * 1000.0 * t).sin() * db(-20.0);
@@ -200,8 +208,9 @@ fn audio_sample(g: &Generator, t: f32, n: i64) -> (f32, f32) {
}
Generator::CountingLeader => {
// 1 kHz beep for one frame at each second mark (the "2-pop" style beep).
let frac = t.fract();
let s = if frac < 1.0 / 24.0 { (TAU * 1000.0 * t).sin() * db(-20.0) } else { 0.0 };
let in_frame = sample_rate != 0
&& i128::from(n.rem_euclid(i64::from(sample_rate))) * i128::from(frame_rate.num) < i128::from(sample_rate) * i128::from(frame_rate.den);
let s = if in_frame { (TAU * 1000.0 * t).sin() * db(-20.0) } else { 0.0 };
(s, s)
}
Generator::Demo(scene) => demo_audio(*scene, t, n),
@@ -286,9 +295,16 @@ fn to8(c: [f32; 3]) -> [u8; 4] {
}
/// Render a generator frame as straight-alpha sRGB RGBA8.
pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Vec<u8> {
pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate) -> Result<Vec<u8>> {
let pixels = u64::from(w).checked_mul(u64::from(h)).ok_or_else(|| MediaError::Decode("generator frame dimensions overflow".into()))?;
if pixels > MAX_GENERATOR_PIXELS {
return Err(MediaError::Decode(format!("generator frame has {pixels} pixels; limit is {MAX_GENERATOR_PIXELS}")));
}
let (wu, hu) = (w as usize, h as usize);
let mut px = vec![0u8; wu * hu * 4];
let len = wu.checked_mul(hu).and_then(|n| n.checked_mul(4)).ok_or_else(|| MediaError::Decode("generator frame byte count overflows".into()))?;
let mut px = Vec::new();
px.try_reserve_exact(len).map_err(|_| MediaError::Decode("cannot allocate generator frame".into()))?;
px.resize(len, 0);
match g {
Generator::BarsAndTone => bars(&mut px, wu, hu),
Generator::ColorMatte { color } => {
@@ -310,7 +326,7 @@ pub fn render(g: &Generator, w: u32, h: u32, t: f32, frame: i64, rate: FrameRate
});
}
}
px
Ok(px)
}
fn bars(px: &mut [u8], w: usize, h: usize) {
@@ -553,13 +569,14 @@ mod tests {
let g = GeneratorSource::demo(DemoScene::OceanSunset);
let a = g.video_frame(FrameRequest { time: Tick(TICKS_PER_SECOND), scale: 0.25 }).unwrap();
assert_eq!((a.width, a.height), (480, 270));
let b = render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976);
let b =
render(&Generator::Demo(DemoScene::OceanSunset), 480, 270, FrameRate::FPS_23_976.tick_of(23).seconds() as f32, 23, FrameRate::FPS_23_976).unwrap();
assert_eq!(a.to_rgba8(), b);
}
#[test]
fn bars_values() {
let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25);
let px = render(&Generator::BarsAndTone, 800, 600, 0.0, 0, FrameRate::FPS_25).unwrap();
// first bar after the side panel is 75% white
let i = (10 * 800 + 150) * 4;
assert_eq!(&px[i..i + 4], &[191, 191, 191, 255]);
@@ -569,13 +586,127 @@ mod tests {
assert!((peak - db(-20.0)).abs() < 1e-3);
}
#[test]
fn counting_leader_beeps_last_exactly_one_source_frame() {
let sample_rate = 48_000;
for rate in [FrameRate::FPS_24, FrameRate::FPS_25, FrameRate::FPS_30, FrameRate::FPS_50, FrameRate::FPS_60, FrameRate::FPS_23_976, FrameRate::FPS_29_97]
{
let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND));
let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, rate, Tick(3 * TICKS_PER_SECOND));
let frame_samples = (sample_rate as u64 * rate.den as u64).div_ceil(rate.num as u64) as usize;
for start in [0, sample_rate as i64, 2 * sample_rate as i64] {
let audio = source.audio(start, frame_samples + 8, sample_rate).unwrap();
let expected = carrier.audio(start, frame_samples, sample_rate).unwrap();
for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) {
assert!(carrier_channel.iter().any(|s| s.abs() > 0.05), "{rate:?}, start {start}: carrier should be audible");
assert_eq!(
&channel[..frame_samples],
carrier_channel.as_slice(),
"{rate:?}, start {start}: in-frame beep differs from the ungated carrier"
);
assert!(channel[frame_samples..].iter().all(|s| *s == 0.0), "{rate:?}, start {start}: beep extends beyond one frame");
}
}
}
}
#[test]
fn counting_leader_custom_rate_stops_at_exact_sample_boundary() {
let sample_rate = 41_000;
let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, FrameRate { num: 41, den: 1 }, Tick(3 * TICKS_PER_SECOND));
let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, FrameRate { num: 41, den: 1 }, Tick(3 * TICKS_PER_SECOND));
for start in [0, 41_000, 82_000] {
let audio = source.audio(start, 1_008, sample_rate).unwrap();
let expected = carrier.audio(start, 1_000, sample_rate).unwrap();
for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) {
assert_eq!(&channel[..1_000], carrier_channel.as_slice(), "custom-rate beep stops before the frame boundary");
assert!(channel[1_000..].iter().all(|s| *s == 0.0), "sample exactly at frame boundary must be silent");
}
}
}
#[test]
fn counting_leader_gate_is_independent_of_request_boundaries() {
for sample_rate in [41_000u32, 44_100, 48_000] {
for rate in [FrameRate::FPS_24, FrameRate::FPS_60, FrameRate::FPS_23_976, FrameRate::FPS_29_97, FrameRate { num: 41, den: 1 }] {
let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND));
let frame_samples = (u64::from(sample_rate) * rate.den as u64).div_ceil(rate.num as u64) as usize;
let start = i64::from(sample_rate) - 3;
let audio = source.audio(start, frame_samples + 8, sample_rate).unwrap();
for channel in &audio.channels {
assert!(channel[..3].iter().all(|s| *s == 0.0), "{rate:?}, {sample_rate}: previous second should be silent");
assert!(channel[3..3 + frame_samples].iter().any(|s| s.abs() > 0.05), "{rate:?}, {sample_rate}: missing beep");
assert!(channel[3 + frame_samples..].iter().all(|s| *s == 0.0), "{rate:?}, {sample_rate}: beep exceeds frame");
}
let first = source.audio(start, 5, sample_rate).unwrap();
let second = source.audio(start + 5, frame_samples + 3, sample_rate).unwrap();
for c in 0..2 {
let split: Vec<_> = first.channels[c].iter().chain(&second.channels[c]).copied().collect();
assert_eq!(split, audio.channels[c], "{rate:?}, {sample_rate}: split requests change samples");
}
let end = source.audio(i64::from(sample_rate) + frame_samples as i64 - 2, 6, sample_rate).unwrap();
assert!(end.channels.iter().all(|channel| channel[2..].iter().all(|s| *s == 0.0)), "{rate:?}, {sample_rate}: end-crossing block leaks");
}
}
}
#[test]
fn counting_leader_malformed_rates_use_default_frame_gate() {
let sample_rate = 48_000u32;
let default_rate = FrameRate::default();
let frame_samples = (u64::from(sample_rate) * default_rate.den as u64).div_ceil(default_rate.num as u64);
let start = frame_samples as i64 - 2;
let carrier = GeneratorSource::new(Generator::Tone { hz: 1000.0, db: -20.0 }, 64, 36, default_rate, Tick(3 * TICKS_PER_SECOND));
let expected = carrier.audio(start, 2, sample_rate).unwrap();
assert!(expected.channels.iter().all(|channel| channel.iter().any(|sample| sample.abs() > 0.05)));
for rate in [FrameRate { num: 41, den: 0 }, FrameRate { num: 0, den: 0 }, FrameRate { num: i64::MIN, den: 1 }, FrameRate { num: 1, den: i64::MIN }] {
let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND));
let audio = source.audio(start, 8, sample_rate).unwrap();
for (channel, carrier_channel) in audio.channels.iter().zip(&expected.channels) {
assert_eq!(&channel[..2], carrier_channel.as_slice(), "{rate:?}: fallback beep stops before the frame boundary");
assert!(channel[2..].iter().all(|sample| *sample == 0.0), "{rate:?}: fallback gate exceeds one frame");
}
let fallback = GeneratorSource::new(Generator::CountingLeader, 64, 36, default_rate, Tick(3 * TICKS_PER_SECOND));
assert_eq!(audio.channels, fallback.audio(start, 8, sample_rate).unwrap().channels, "{rate:?}: changed fallback");
}
}
#[test]
fn counting_leader_gate_handles_zero_and_hostile_rates() {
let invalid = [FrameRate { num: 0, den: 0 }, FrameRate { num: i64::MIN, den: 1 }, FrameRate { num: 1, den: i64::MIN }, FrameRate { num: 41, den: 0 }];
let rates =
invalid.into_iter().chain([FrameRate { num: 1, den: i64::MAX }, FrameRate { num: i64::MAX, den: 1 }, FrameRate { num: i64::MAX, den: i64::MAX }]);
for rate in rates {
let source = GeneratorSource::new(Generator::CountingLeader, 64, 36, rate, Tick(3 * TICKS_PER_SECOND));
for sample_rate in [0, 1, 48_000, u32::MAX] {
for start in [-2, 0, 1] {
let audio = std::panic::catch_unwind(|| source.audio(start, 8, sample_rate)).unwrap().unwrap();
assert!(audio.channels.iter().flatten().all(|s| s.is_finite()), "{rate:?}, {sample_rate}: non-finite audio");
if sample_rate == 0 {
assert!(audio.channels.iter().flatten().all(|s| *s == 0.0));
}
if invalid.contains(&rate) {
let fallback = GeneratorSource::new(Generator::CountingLeader, 64, 36, FrameRate::default(), Tick(3 * TICKS_PER_SECOND));
assert_eq!(audio.channels, fallback.audio(start, 8, sample_rate).unwrap().channels, "{rate:?}: changed fallback");
}
}
}
}
}
#[test]
fn all_scenes_render() {
for s in DemoScene::ALL {
let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24);
let px = render(&Generator::Demo(s), 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap();
assert!(px.as_chunks::<4>().0.iter().any(|p| p[0] > 20 || p[1] > 20 || p[2] > 20));
}
let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24);
let px = render(&Generator::CountingLeader, 64, 36, 1.5, 36, FrameRate::FPS_24).unwrap();
assert_eq!(px.len(), 64 * 36 * 4);
}
#[test]
fn oversized_generator_frames_are_rejected_before_allocation() {
let err = render(&Generator::BlackVideo, 32_768, 32_768, 0.0, 0, FrameRate::FPS_24).unwrap_err();
assert!(err.to_string().contains("limit"), "{err}");
}
}
+64
View File
@@ -53,6 +53,59 @@ pub enum MediaError {
pub type Result<T> = std::result::Result<T, MediaError>;
/// Up to 384 kHz audio (surpassing professional 192 kHz formats).
pub const MAX_AUDIO_SAMPLE_RATE: u32 = 384_000;
/// Up to 64 discrete channels (exceeding standard 32-channel immersive layouts).
pub const MAX_AUDIO_CHANNELS: u32 = 64;
/// Bounded planar audio buffer: 64 channels * 192 kHz * 10 seconds of f32 is ~492 MiB.
pub const MAX_AUDIO_BUFFER_BYTES: usize = 512 * 1024 * 1024;
/// Bound container-advertised audio stream dimensions before they are trusted by the app or
/// allocator.
///
/// Out-of-range values are rejected rather than clamped: publishing a lower sample rate without
/// resampling the samples would make the stream play at the wrong speed, and a clamped channel
/// count would misdescribe the interleaving.
pub fn bounded_audio_dimensions(sample_rate: u32, channels: u32) -> Result<(u32, u32)> {
if sample_rate == 0 || sample_rate > MAX_AUDIO_SAMPLE_RATE {
return Err(MediaError::Decode(format!("unsupported audio sample rate {sample_rate}")));
}
if channels == 0 || channels > MAX_AUDIO_CHANNELS {
return Err(MediaError::Decode(format!("unsupported audio channel count {channels}")));
}
Ok((sample_rate, channels))
}
/// Check an adapter-owned planar `f32` audio allocation before constructing it.
pub fn validate_audio_buffer_shape(channels: usize, frames: usize) -> Result<()> {
if channels == 0 || channels > MAX_AUDIO_CHANNELS as usize {
return Err(MediaError::Decode(format!("unsupported audio channel count {channels}")));
}
let bytes = channels
.checked_mul(frames)
.and_then(|samples| samples.checked_mul(std::mem::size_of::<f32>()))
.ok_or_else(|| MediaError::Decode("audio buffer dimensions overflow".into()))?;
if bytes > MAX_AUDIO_BUFFER_BYTES {
return Err(MediaError::Decode(format!("audio buffer of {bytes} bytes exceeds the {} byte limit", MAX_AUDIO_BUFFER_BYTES)));
}
Ok(())
}
/// Number of source frames needed to resample `frames` output frames. Arithmetic is checked so
/// corrupted rates cannot wrap into a small allocation.
pub fn resample_source_frames(frames: usize, source_rate: u32, output_rate: u32) -> Result<usize> {
if output_rate == 0 {
return Err(MediaError::Decode("zero requested audio sample rate".into()));
}
let source = usize::try_from(source_rate).map_err(|_| MediaError::Decode("invalid source sample rate".into()))?;
let output = usize::try_from(output_rate).map_err(|_| MediaError::Decode("invalid output sample rate".into()))?;
frames
.checked_mul(source)
.and_then(|n| n.checked_add(output.saturating_sub(1)))
.map(|n| n / output)
.ok_or_else(|| MediaError::Decode("resampled audio window overflows".into()))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum MediaKind {
/// Video, possibly with audio.
@@ -257,4 +310,15 @@ mod importable_tests {
}
assert!(!is_importable(Path::new("notes.txt")));
}
#[test]
fn hostile_audio_dimensions_are_bounded_before_adapters_allocate() {
assert!(bounded_audio_dimensions(0, 0).is_err());
assert!(bounded_audio_dimensions(u32::MAX, u32::MAX).is_err());
assert!(bounded_audio_dimensions(MAX_AUDIO_SAMPLE_RATE + 1, 2).is_err());
assert_eq!(bounded_audio_dimensions(48_000, 2).ok(), Some((48_000, 2)));
assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize).is_ok());
assert!(validate_audio_buffer_shape(MAX_AUDIO_CHANNELS as usize, MAX_AUDIO_BUFFER_BYTES / 4 / MAX_AUDIO_CHANNELS as usize + 1).is_err());
assert!(resample_source_frames(usize::MAX, MAX_AUDIO_SAMPLE_RATE, 1).is_err());
}
}
+19 -1
View File
@@ -115,7 +115,13 @@ impl WavSource {
let (tag, ch, rate, bits) = fmt.ok_or_else(|| MediaError::Decode(format!("{name}: missing fmt chunk")))?;
let (off, dlen) = data.ok_or_else(|| MediaError::Decode(format!("{name}: missing data chunk")))?;
let float = tag == 3;
if !(tag == 1 || float) || ch == 0 || !matches!(bits, 8 | 16 | 24 | 32 | 64) {
if !(tag == 1 || float)
|| ch == 0
|| rate == 0
|| u32::from(ch) > crate::MAX_AUDIO_CHANNELS
|| rate > crate::MAX_AUDIO_SAMPLE_RATE
|| !matches!(bits, 8 | 16 | 24 | 32 | 64)
{
return Err(MediaError::Unsupported(format!("{name}: WAV format tag {tag}, {bits} bits")));
}
let frame_bytes = u64::from(ch) * u64::from(bits / 8);
@@ -191,6 +197,7 @@ impl MediaSource for WavSource {
}
fn audio(&self, start: i64, frames: usize, sample_rate: u32) -> Result<AudioBuffer> {
let total = self.frames;
crate::validate_audio_buffer_shape(self.channels, frames)?;
let mut out = AudioBuffer::silence(sample_rate, self.channels, frames);
if total == 0 || sample_rate == 0 {
return Ok(out);
@@ -343,4 +350,15 @@ mod tests {
let src = WavSource::parse("short.wav", with_chunk(&wav, b"bext", &[0u8; 100]).into()).unwrap();
assert_eq!(src.time_reference(), None);
}
#[test]
fn hostile_header_dimensions_are_rejected_before_publishing_or_reading() {
let mut wav = write_wav16(&[0.0; 2], 1, 48_000);
// `fmt ` starts at byte 12: channels at +22, rate at +24.
wav[22..24].copy_from_slice(&u16::MAX.to_le_bytes());
assert!(WavSource::parse("wide.wav", wav.into()).is_err());
let mut wav = write_wav16(&[0.0; 2], 1, 48_000);
wav[24..28].copy_from_slice(&(crate::MAX_AUDIO_SAMPLE_RATE + 1).to_le_bytes());
assert!(WavSource::parse("fast.wav", wav.into()).is_err());
}
}
+1 -1
View File
@@ -108,7 +108,7 @@ impl VideoEncoder for NvencEncoder {
return Err(ExportError::Encode(format!("NVENC: a {}x{} picture for a {}x{} encoder", f.width, f.height, self.w, self.h)));
}
let (w, h) = (self.w as usize, self.h as usize);
let hint = if self.enc.codec() == Codec::H264 { " (turn Export ▸ Hardware encoding off to use the software encoder)" } else { "" };
let hint = if self.enc.codec() == Codec::H264 { " (turn Export > Hardware encoding off to use the software encoder)" } else { "" };
let ps = match (self.enc.is_ten_bit(), f.hdr) {
// Main 10 takes the encoded R'G'B' floats: 10-bit limited-range BT.2020 NCL (or the signal's matrix)
(true, Some(rgb)) => {
+78
View File
@@ -35,6 +35,47 @@ pub fn is_layer(e: &EffectInstance) -> bool {
is_layer_id(&e.effect)
}
/// Give every graphic layer without a uid (or with a uid another layer already uses) a fresh one
/// that is unique among `effects`; non-layer effects are left alone.
///
/// This is the project-format lazy upgrade: files written before layers had uids (or with a
/// duplicate written by a hostile/buggy producer) get stable identities when they are opened, so
/// reorder/delete/undo and the Program monitor's text editor keep addressing the same layer.
/// The optional `layer.uid` field means the file schema does not change; a save simply writes the
/// assigned uid. Returns whether anything changed.
pub fn ensure_layer_uids(effects: &mut [EffectInstance]) -> bool {
let mut used: std::collections::BTreeSet<u64> =
effects.iter().filter(|e| is_layer(e)).filter_map(|e| e.layer.as_ref()).map(|x| x.uid).filter(|uid| *uid != 0).collect();
let mut seen = std::collections::BTreeSet::new();
let mut next = 1u64;
let mut changed = false;
for e in effects.iter_mut().filter(|e| is_layer(e)) {
let uid = e.layer.as_ref().map_or(0, |x| x.uid);
if uid != 0 && seen.insert(uid) {
continue;
}
// Zero means "not assigned yet"; a repeated uid must move to a free one so two identical
// layers can never share an identity.
while used.contains(&next) {
let Some(after) = next.checked_add(1) else {
// A hostile file used every uid; leave this layer without one rather than loop.
next = 0;
break;
};
next = after;
}
if next == 0 {
continue;
}
e.layer.get_or_insert_with(Default::default).uid = next;
used.insert(next);
seen.insert(next);
changed = true;
next = next.saturating_add(1);
}
changed
}
fn p(id: &'static str, label: &'static str, kind: ParamKind, default: ParamValue, animatable: bool, group: &'static str) -> ParamDef {
ParamDef { id, label, kind, default, animatable, group: if group.is_empty() { None } else { Some(group) } }
}
@@ -499,6 +540,43 @@ mod tests {
assert_eq!(layer_display_name(&e, 0), "Hello");
}
#[test]
fn assigning_a_new_layer_uid_never_reuses_an_existing_layers_uid() {
let make = |uid| {
let mut layer = new_text_layer("text", Vec2::new(10.0, 20.0), 40.0);
layer.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid, ..Default::default() }));
layer
};
for initial in [[1, 0, 2, 1], [0, 1, 2, 1], [1, 2, 0, 1]] {
let mut layers: Vec<_> = initial.into_iter().map(make).collect();
assert!(ensure_layer_uids(&mut layers));
let uids: Vec<_> = layers.iter().map(crate::gtemplate::layer_uid).collect();
assert_eq!(uids.iter().copied().collect::<std::collections::BTreeSet<_>>().len(), 4, "{uids:?}");
assert_eq!(uids[initial.iter().position(|uid| *uid == 1).unwrap()], 1);
assert_eq!(uids[initial.iter().position(|uid| *uid == 2).unwrap()], 2);
assert!(!ensure_layer_uids(&mut layers), "a second pass must preserve identity");
}
}
#[test]
fn layer_uids_are_unique_and_repair_legacy_or_duplicate_entries() {
let a = new_text_layer("one", Vec2::new(10.0, 20.0), 40.0);
let mut b = new_text_layer("two", Vec2::new(10.0, 20.0), 40.0);
let mut shape = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance();
// Legacy file: no uids at all; one layer already carries a duplicate uid.
assert_eq!(crate::gtemplate::layer_uid(&a), 0);
b.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() }));
shape.layer = Some(Box::new(crate::graphic_design::LayerExtra { uid: 7, ..Default::default() }));
let mut effects = vec![a, b, shape];
assert!(ensure_layer_uids(&mut effects));
let uids: Vec<u64> = effects.iter().filter(|e| is_layer(e)).map(crate::gtemplate::layer_uid).collect();
assert!(uids.iter().all(|uid| *uid != 0));
let unique: std::collections::BTreeSet<u64> = uids.iter().copied().collect();
assert_eq!(unique.len(), uids.len(), "every layer keeps its own identity: {uids:?}");
// Idempotent: a second pass changes nothing.
assert!(!ensure_layer_uids(&mut effects));
}
#[test]
fn defaults_centre_and_shadow_direction() {
let mut e = crate::effect::find_effect(SHAPE_LAYER).unwrap().instance();
+2 -8
View File
@@ -350,14 +350,8 @@ pub fn layer_uid(e: &EffectInstance) -> u64 {
}
/// Give every layer without a uid a fresh one (unique among `layers`).
pub fn ensure_uids(layers: &mut [EffectInstance]) {
let mut next = layers.iter().map(layer_uid).max().unwrap_or(0) + 1;
for l in layers.iter_mut() {
if layer_uid(l) == 0 {
l.layer.get_or_insert_with(Default::default).uid = next;
next += 1;
}
}
pub fn ensure_uids(layers: &mut [EffectInstance]) -> bool {
graphic::ensure_layer_uids(layers)
}
// ------------------------------------------------------------------------------------------------
+45
View File
@@ -285,6 +285,38 @@ impl Param {
self.value_at(t).as_f64().unwrap_or(0.0)
}
/// Borrowing text evaluation for read-only observers (the Program monitor's IME producer).
///
/// Text parameters never interpolate, so the result is the applicable static or keyframed
/// `ParamValue::Text`; an interpolating non-text value yields `None`. Unlike
/// [`Self::value_at`] this does not clone the document-sized string.
pub fn text_ref_at(&self, t: Tick) -> Option<&str> {
fn text(v: &ParamValue) -> Option<&str> {
match v {
ParamValue::Text(s) => Some(s.as_str()),
_ => None,
}
}
let k = &self.keyframes;
if k.is_empty() {
return text(&self.value);
}
if t <= k[0].time {
return text(&k[0].value);
}
let last = &k[k.len() - 1];
if t >= last.time {
return text(&last.value);
}
let i = match k.binary_search_by_key(&t, |kf| kf.time) {
Ok(i) => return text(&k[i].value),
Err(i) => i - 1,
};
// Hold semantics match `value_at`: non-interpolating values (including text) stay until
// the next keyframe; an interpolating segment never evaluates to text.
if k[i].interp == Interpolation::Hold || !k[i].value.interpolates() { text(&k[i].value) } else { None }
}
/// Allocation-free [`Param::f64_at`] for scalar parameters (Float / Choice / Bool), for
/// per-sample evaluation in the audio mixer. Non-scalar values evaluate to 0.
pub fn scalar_at(&self, t: Tick) -> f64 {
@@ -405,6 +437,19 @@ mod tests {
}
}
#[test]
fn text_ref_at_borrows_without_cloning_and_holds_non_interpolating_values() {
let p = Param::new(ParamValue::Text("abc".into()));
assert_eq!(p.text_ref_at(Tick(0)), Some("abc"));
let f = Param::new(ParamValue::Float(1.0));
assert_eq!(f.text_ref_at(Tick(0)), None);
let mut p = Param::new(ParamValue::Text("a".into()));
p.toggle_animation(Tick(0));
p.set_at(Tick(100), ParamValue::Text("b".into()));
assert_eq!(p.text_ref_at(Tick(50)), Some("a"), "text does not interpolate");
assert_eq!(p.text_ref_at(Tick(100)), Some("b"));
}
#[test]
fn toggle_off_keeps_value() {
let mut p = Param::new(ParamValue::Float(1.0));

Some files were not shown because too many files have changed in this diff Show More