Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c736a97432 | ||
|
|
f2b36fe277 | ||
|
|
bd944ee5df | ||
|
|
005d09503b | ||
|
|
771b614ad4 | ||
|
|
7f8e102b13 | ||
|
|
9953befe28 | ||
|
|
a2f6ba8b44 |
No files matched your search
@@ -0,0 +1,48 @@
|
||||
# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner
|
||||
# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter.
|
||||
name: FreeBSD
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
paths:
|
||||
- Cargo.toml
|
||||
- Cargo.lock
|
||||
- "**/Cargo.toml"
|
||||
- .github/workflows/freebsd.yml
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: freebsd-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
freebsd:
|
||||
name: FreeBSD 14 (build + test)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 300
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Build and test in a FreeBSD VM
|
||||
uses: vmactions/freebsd-vm@c46abacb49f09938ca4e1702d15d836285d694cc # v1
|
||||
with:
|
||||
release: "14.3"
|
||||
usesh: true
|
||||
cpu: 4
|
||||
mem: 12288
|
||||
# Nothing to bring back; copying the target dir out of the VM fails on large builds.
|
||||
copyback: false
|
||||
prepare: |
|
||||
pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal
|
||||
run: |
|
||||
. "$HOME/.cargo/env"
|
||||
# The checkout is synced in under another owner; let tests that call git read it.
|
||||
git config --global --add safe.directory "*"
|
||||
rustc --version
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked
|
||||
CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked
|
||||
@@ -70,6 +70,12 @@ jobs:
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-apple-darwin, x86_64-apple-darwin
|
||||
@@ -90,6 +96,8 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/macos/package.sh --arch universal
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -120,6 +128,12 @@ jobs:
|
||||
FILMCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -144,6 +158,8 @@ jobs:
|
||||
AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }}
|
||||
AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }}
|
||||
AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }}
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: ./packaging/windows/package.ps1 -Arch ${{ matrix.arch }}
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -170,6 +186,12 @@ jobs:
|
||||
NFPM_VERSION: 2.47.0
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- name: Install build and packaging tools
|
||||
run: |
|
||||
sudo apt-get update
|
||||
@@ -187,6 +209,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$FILMCRAFT_VERSION"
|
||||
- name: Build and package (AppImage, deb, rpm, tar.gz)
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/linux/package.sh
|
||||
- name: Check the packages
|
||||
run: |
|
||||
@@ -215,6 +240,12 @@ jobs:
|
||||
WASM_BINDGEN_VERSION: 0.2.129
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# Fonts (optional build input, required for releases): docs/contributing.md#building-with-craft-fonts
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
with:
|
||||
repository: storytold/craft-fonts
|
||||
ref: abb83316d96aa59c1cf64784289e378fe9fa5695 # bump deliberately
|
||||
path: craft-fonts
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: wasm32-unknown-unknown
|
||||
@@ -232,6 +263,9 @@ jobs:
|
||||
if: inputs.version != ''
|
||||
run: cargo xtask version set "$FILMCRAFT_VERSION"
|
||||
- name: Build and zip
|
||||
env:
|
||||
CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts
|
||||
CRAFT_FONTS_REQUIRED: "1"
|
||||
run: packaging/web/package.sh
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
|
||||
@@ -92,6 +92,13 @@ reproduce an image (for example, point lists traced from someone else's icon).
|
||||
need a sidecar and an `ATTRIBUTION.md` row (licence `LicenseRef-ArtCraft-Trademark`). No other
|
||||
non-open asset is allowed, and this exception never covers third-party marks (Adobe, Discord, GitHub
|
||||
and other logos stay out; draw a generic icon instead).
|
||||
9. **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo.** Don't commit new font files (the small Latin UI fonts already in `assets/fonts/` stay). A
|
||||
font FilmCraft needs is added to craft-fonts, which the app reads through the optional build input
|
||||
`CRAFT_FONTS_DIR=<absolute path of a craft-fonts checkout>` (`crates/text/build.rs` embeds the fonts in its
|
||||
`fonts/manifest.txt` as `filmcraft_text::fonts::CRAFT_FONTS`; unset, it is empty and the app uses its
|
||||
bundled and system fonts). Never add craft-fonts to a `Cargo.toml`. Code using `CRAFT_FONTS` must work
|
||||
when it is empty. Standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
## 2. Clean-room code
|
||||
|
||||
|
||||
@@ -106,6 +106,21 @@ is traced or derived from Adobe artwork.
|
||||
| Lumetri Presets (Effects panel: Cinematic, Film Emulation, Monochrome, Technical) and their preview picture | `crates/render/src/lumetri_presets.rs`: Lumetri parameter values chosen by contributors, descriptive names, and a procedural preview picture (dusk sky, ridges, lake, grey ramp, colour chips) |
|
||||
| Colour palette and layout metrics | `crates/ui-egui/src/theme.rs`: colour values and sizes only; no artwork |
|
||||
|
||||
## Optional build input: craft-fonts
|
||||
|
||||
These fonts are **not files in this repository**. Builds made with the optional build input
|
||||
`CRAFT_FONTS_DIR` (all official releases; see [docs/contributing.md](docs/contributing.md#building-with-craft-fonts))
|
||||
embed them from [storytold/craft-fonts](https://github.com/storytold/craft-fonts), where each file,
|
||||
its author, pinned upstream source and licence text are listed in its
|
||||
[ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md). Release packages
|
||||
carry each font's licence as `OFL-<family>.txt`.
|
||||
|
||||
| Font | Use in FilmCraft | Licence |
|
||||
|---|---|---|
|
||||
| BIZ UDPGothic Regular, Bold (Morisawa) | Japanese UI text; Japanese fallback for sans titles (the web build embeds only Regular) | OFL-1.1 |
|
||||
| Shippori Mincho Regular (FONTDASU) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
| BIZ UDMincho Regular (Morisawa) | Japanese fallback for serif titles (native builds) | OFL-1.1 |
|
||||
|
||||
## First-party brand marks
|
||||
|
||||
The ArtCraft name, wordmark and logos are trademarks of the ArtCraft Team. They are not open source and
|
||||
|
||||
@@ -11,6 +11,7 @@ FilmCraft is a clean-room, open-source, pure-Rust non-linear video editor target
|
||||
## Non-negotiables
|
||||
**Read [`AGENTS.md`](AGENTS.md) first; its rules override everything here.**
|
||||
- **Never crash** ([`AGENTS.md`](AGENTS.md) §0, standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md)). People depend on FilmCraft; a crash loses their work, so this outranks feature work. No `unwrap`/`expect`/`panic!`/`unreachable!`/`todo!`/`unimplemented!` or `unsafe` outside tests: return `Result<T, E>` and propagate with `?` (`ok_or`, `let … else`, `if let`). Treat every number from media, project files, presets or CLI/MCP/control params as hostile: `get()` instead of `[i]`, checked/saturating math, no division by possibly-zero values, capped allocations, bounded recursion. `lock().unwrap_or_else(PoisonError::into_inner)`; background jobs run under `catch_unwind`. The panic hook + UI `catch_unwind` (`crates/ui-egui/src/crash.rs`) are the last-resort guard, not a licence. Every crash fix gets a regression test that panicked before. In particular (§1): never use Adobe iconography, images or any other Adobe asset. Every asset must be openly licensed (OSS / public domain / Creative Commons, or original work by a contributor) and must have a `<file>.attribution` sidecar plus an entry in `ATTRIBUTION.md`. `cargo xtask assets` enforces this.
|
||||
- **Fonts** live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this repo (no new font files here; [`AGENTS.md`](AGENTS.md) §1.9, standard: [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). Build with them: `git clone https://github.com/storytold/craft-fonts ../craft-fonts && CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run -p filmcraft` (`crates/text/build.rs` embeds them as `filmcraft_text::fonts::CRAFT_FONTS`, used for Japanese in the UI and titles). Unset, `CRAFT_FONTS` is empty and everything must still build, test and run; never add craft-fonts to a `Cargo.toml`. Tests on its glyphs skip when it is empty, so run the gates with and without `CRAFT_FONTS_DIR` when you touch fonts.
|
||||
- **Clean-room.** Never read/disassemble anything inside Adobe app bundles (names/listings only). Never copy Adobe icons, presets, LUTs, fonts. Never copy GPL/LGPL/AGPL code (FFmpeg, x264, x265, MLT, Kdenlive, Shotcut, Olive, LAME…). ffmpeg/ffprobe run only as external test oracles / fixture generators — never linked or shipped.
|
||||
- **Pure Rust** in the product. OS media APIs only via Rust bindings in `crates/platform` (hardware decoding; the one crate allowed `unsafe`, see `AGENTS.md` §0.3 and `docs/adr/0001-platform-ffi.md`), optional, behind traits, with our decoders as the fallback.
|
||||
- **Layering** ([`docs/architecture.md`](docs/architecture.md) §1, enforced by `cargo xtask layers`): nothing below L5 depends on egui/eframe/winit/rfd/cpal. L0 codec/container crates depend only on `filmcraft-bitstream`.
|
||||
|
||||
@@ -15,4 +15,7 @@ list (author, source, license) is in ATTRIBUTION.md.
|
||||
Inter font (assets/fonts/Inter-*.ttf) — OFL-1.1. See assets/fonts/OFL-Inter.txt.
|
||||
Noto Serif font (assets/fonts/NotoSerif-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-NotoSerif.txt.
|
||||
JetBrains Mono font (assets/fonts/JetBrainsMono-Regular.ttf) — OFL-1.1. See assets/fonts/OFL-JetBrainsMono.txt.
|
||||
Builds made with craft-fonts (CRAFT_FONTS_DIR; all official releases) also embed the fonts listed in
|
||||
https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md (BIZ UDPGothic, Shippori Mincho,
|
||||
BIZ UDMincho) — 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.
|
||||
@@ -252,6 +252,13 @@ cargo run --release -p filmcraft-cli -- commands # list every engine c
|
||||
cargo run --release -p filmcraft-cli -- mcp # MCP server (headless)
|
||||
```
|
||||
|
||||
Japanese text in the interface and in titles comes from [craft-fonts](https://github.com/storytold/craft-fonts), an optional build input (release builds always include it; without it FilmCraft uses its own and the system's fonts):
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft
|
||||
```
|
||||
|
||||
The control protocol is documented in [docs/control-protocol.md](docs/control-protocol.md). Stuck, or want to show what you made? Ask in [Discord](https://discord.gg/artcraft).
|
||||
|
||||
## Documentation
|
||||
@@ -351,7 +358,9 @@ FilmCraft is dual-licensed under [MIT](LICENSE-MIT) or [Apache-2.0](LICENSE-APAC
|
||||
Copyright (c) 2026 ArtCraft Team and the FilmCraft contributors. Required notices are in [NOTICE](NOTICE).
|
||||
|
||||
Bundled fonts, icons, images and other assets keep their own open licenses; each one is listed
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
with its author, source and license in [ATTRIBUTION.md](ATTRIBUTION.md). Builds made with
|
||||
[craft-fonts](https://github.com/storytold/craft-fonts) (all official releases) also embed its fonts
|
||||
(OFL-1.1), listed in its [ATTRIBUTION.md](https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md).
|
||||
|
||||
**Footage and music in the screenshots:** NASA's Apollo 11 film and television footage from [images.nasa.gov](https://images.nasa.gov) (launch, Launch Control Center, lunar surface and recovery) and the Apollo 11 air-to-ground voice transcript, all US Government works in the public domain (NASA does not endorse this project); *Night of the Living Dead* (1968), *Carnival of Souls* (1962) and *Charade* (1963), all in the US public domain; *Earth Views from the ISS* by NASA; Chopin's Nocturne Op. 48 No. 1 and Ballade No. 1, performed for Musopen and released under CC0. The media itself is not in this repository. Sources and details for every asset are in [ATTRIBUTION.md](ATTRIBUTION.md).
|
||||
|
||||
|
||||
+1
-1
@@ -205,4 +205,4 @@ Status: ✅ done · 🟡 in progress · ⬜ not started. Estimates are remaining
|
||||
|
||||
### Interface localization
|
||||
|
||||
Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses a Japanese font already installed on the system (none is bundled); without one, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
Edit > Language switches English/Japanese and persists in UI state. Commands `app.language.english` and `app.language.japanese` are reachable through the control channel. Core native/in-window menu labels are translated; untranslated labels use English. Japanese text uses the Japanese fonts from [craft-fonts](https://github.com/storytold/craft-fonts) when FilmCraft is built with them (`CRAFT_FONTS_DIR`; all official releases, including the web build), otherwise a Japanese font already installed on the system; with neither, switching to Japanese is refused with a message. Dialog/panel translation coverage remains open. Existing vertical text support is preserved.
|
||||
@@ -609,7 +609,11 @@ fn camera_keys_cut_and_select() {
|
||||
let keys: Vec<(String, String)> =
|
||||
s.shortcuts.bindings.iter().filter(|b| b.command.starts_with("multicam.")).map(|b| (b.command.clone(), b.keys.clone())).collect();
|
||||
assert!(keys.contains(&("multicam.selectCamera1".into(), "1".into())), "{keys:?}");
|
||||
assert!(keys.contains(&("multicam.cutToCamera9".into(), "Ctrl+9".into())), "{keys:?}");
|
||||
assert!(keys.contains(&("multicam.cutToCamera2".into(), "Ctrl+2".into())), "{keys:?}");
|
||||
// ⌃9 is Premiere's macOS key; off macOS Ctrl+9 is Toggle All Audio Targets (⌘9 there), which
|
||||
// wins, so Cut to Camera 9 has no default key on Windows, Linux and FreeBSD
|
||||
let nine = keys.contains(&("multicam.cutToCamera9".into(), "Ctrl+9".into()));
|
||||
assert_eq!(nine, cfg!(target_os = "macos"), "{keys:?}");
|
||||
}
|
||||
|
||||
/// Colour of camera `k` (0-based) in the many-angle tests: distinct reds and greens.
|
||||
|
||||
@@ -363,6 +363,22 @@ pub struct Binding {
|
||||
pub panel: Option<String>,
|
||||
}
|
||||
|
||||
/// Off macOS the Control and Command keys are the same key. The built-in tables are written for
|
||||
/// macOS, so a binding on the Mac Control key (`⌃T`) can land on the same key as a Command binding
|
||||
/// (`⌘T`) in the same context: the Command binding wins and the Control one is left out there
|
||||
/// (that command has no default key on that platform). macOS keeps both.
|
||||
fn drop_control_collisions(b: &mut Vec<Binding>, p: Platform) {
|
||||
if p.is_mac() {
|
||||
return;
|
||||
}
|
||||
let primary: Vec<(String, Option<Chord>)> =
|
||||
b.iter().filter(|x| x.chord().is_some_and(|c| !c.mods.ctrl)).map(|x| (x.context().to_string(), x.chord().map(|c| c.effective(p)))).collect();
|
||||
b.retain(|x| {
|
||||
let Some(c) = x.chord() else { return true };
|
||||
!(c.mods.ctrl && primary.iter().any(|(ctx, k)| ctx == x.context() && *k == Some(c.effective(p))))
|
||||
});
|
||||
}
|
||||
|
||||
impl Binding {
|
||||
fn new(command: &str, keys: &str, panel: Option<&str>) -> Self {
|
||||
Binding { command: command.to_string(), keys: normalize(keys).unwrap_or_else(|_| keys.to_string()), panel: panel.map(str::to_string) }
|
||||
@@ -564,8 +580,15 @@ impl Shortcuts {
|
||||
}
|
||||
|
||||
/// Premiere-default entries FilmCraft Default adopts: commands with no shortcut of their own
|
||||
/// whose Premiere key is free in that context.
|
||||
/// whose Premiere key is free in that context on this platform.
|
||||
pub fn audit(&self) -> Vec<Binding> {
|
||||
self.audit_for(Platform::current())
|
||||
}
|
||||
|
||||
/// [`Self::audit`] for platform `p`. The Premiere table is Premiere's macOS keyboard: off macOS
|
||||
/// the Control and Command keys are the same key, so an entry whose key would collide there
|
||||
/// with one already taken (`⌃T` vs `⌘T`) is left out, and the command keeps no default key.
|
||||
pub(crate) fn audit_for(&self, platform: Platform) -> Vec<Binding> {
|
||||
let base = self.base_defaults();
|
||||
let mut added: Vec<Binding> = Vec::new();
|
||||
for (c, k, p) in presets::PREMIERE {
|
||||
@@ -573,7 +596,8 @@ impl Shortcuts {
|
||||
continue;
|
||||
}
|
||||
let b = Binding::new(c, k, panel_opt(p));
|
||||
let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord() == b.chord());
|
||||
let key = b.chord().map(|c| c.effective(platform));
|
||||
let taken = base.iter().chain(added.iter()).any(|o| o.context() == b.context() && o.chord().map(|c| c.effective(platform)) == key);
|
||||
if !taken {
|
||||
added.push(b);
|
||||
}
|
||||
@@ -583,8 +607,13 @@ impl Shortcuts {
|
||||
|
||||
/// The bindings of a built-in preset.
|
||||
pub fn builtin(&self, name: &str) -> Option<Vec<Binding>> {
|
||||
self.builtin_for(name, Platform::current())
|
||||
}
|
||||
|
||||
/// [`Self::builtin`] as it is built on platform `p`.
|
||||
pub(crate) fn builtin_for(&self, name: &str, p: Platform) -> Option<Vec<Binding>> {
|
||||
let mut b = self.base_defaults();
|
||||
b.extend(self.audit());
|
||||
b.extend(self.audit_for(p));
|
||||
let table: Vec<Entry> = match name {
|
||||
DEFAULT_PRESET => Vec::new(),
|
||||
PREMIERE_PRESET => presets::premiere(),
|
||||
@@ -593,6 +622,7 @@ impl Shortcuts {
|
||||
_ => return None,
|
||||
};
|
||||
self.apply_table(&mut b, &table);
|
||||
drop_control_collisions(&mut b, p);
|
||||
Some(b)
|
||||
}
|
||||
|
||||
|
||||
@@ -218,3 +218,21 @@ fn list_searches_labels_and_keys() {
|
||||
let v = s.execute("shortcuts.list", json!({"panel": "History", "assigned": true})).unwrap();
|
||||
assert_eq!(v.as_array().unwrap().len(), 2, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_keymaps_have_no_conflicts_on_any_platform() {
|
||||
// The Premiere audit used to check keys on macOS only, so off macOS `⌃9`/`⌘9`, `⌃⇧M`/`⌘⇧M` and
|
||||
// `⌃T`/`⌘T` ended up on the same Ctrl key; CI only ran on macOS, so nobody saw it.
|
||||
use crate::shortcuts::Platform;
|
||||
let s = Session::default();
|
||||
for p in [Platform::Mac, Platform::Windows, Platform::Linux] {
|
||||
let mut sc = s.shortcuts.clone();
|
||||
sc.bindings = sc.builtin_for(crate::shortcuts::DEFAULT_PRESET, p).unwrap();
|
||||
assert!(sc.conflicts(p).is_empty(), "{p:?}: {:?}", sc.conflicts(p));
|
||||
}
|
||||
// macOS keeps every Premiere key it had
|
||||
let mac = s.shortcuts.builtin_for(crate::shortcuts::DEFAULT_PRESET, Platform::Mac).unwrap();
|
||||
for (cmd, keys) in [("multicam.cutToCamera9", "Ctrl+9"), ("markers.addRange", "Ctrl+Shift+M"), ("graphics.newText", "Cmd+T")] {
|
||||
assert!(mac.iter().any(|b| b.command == cmd && b.keys == keys), "{cmd} {keys} on macOS");
|
||||
}
|
||||
}
|
||||
+27
-1
@@ -317,7 +317,23 @@ impl GpuCompositor {
|
||||
view_formats: &[],
|
||||
});
|
||||
let dummy = dummy_tex.create_view(&Default::default());
|
||||
let fx = fx::FxStage::supported(device).then(|| fx::FxStage::new(device, &shader, &layer_bgl));
|
||||
// The effect stage's pipelines can still be invalid on a device that passes the limits
|
||||
// check (Rgba32Float is not renderable everywhere, e.g. software Vulkan/GL). Validation
|
||||
// errors would panic, so they are captured: without the stage, effects render on the CPU.
|
||||
let fx = fx::FxStage::supported(device)
|
||||
.then(|| {
|
||||
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
|
||||
let stage = fx::FxStage::new(device, &shader, &layer_bgl);
|
||||
match poll_now(scope.pop()) {
|
||||
Some(None) => Some(stage),
|
||||
Some(Some(e)) => {
|
||||
log::warn!("GPU effects unavailable on this device, rendering them on the CPU: {e}");
|
||||
None
|
||||
}
|
||||
None => None,
|
||||
}
|
||||
})
|
||||
.flatten();
|
||||
Self {
|
||||
device: device.clone(),
|
||||
queue: queue.clone(),
|
||||
@@ -846,6 +862,16 @@ struct SrcInfo {
|
||||
color: filmcraft_color::ColorInfo,
|
||||
}
|
||||
|
||||
/// Poll a future once: wgpu's native error scopes resolve immediately. `None` if it is still
|
||||
/// pending (then the caller assumes the worst).
|
||||
fn poll_now<F: std::future::Future>(f: F) -> Option<F::Output> {
|
||||
let mut f = std::pin::pin!(f);
|
||||
match f.as_mut().poll(&mut std::task::Context::from_waker(std::task::Waker::noop())) {
|
||||
std::task::Poll::Ready(v) => Some(v),
|
||||
std::task::Poll::Pending => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A render pass drawing into the accumulator.
|
||||
fn accum_pass<'e>(enc: &'e mut wgpu::CommandEncoder, view: &wgpu::TextureView, load: wgpu::LoadOp<wgpu::Color>) -> wgpu::RenderPass<'e> {
|
||||
enc.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
|
||||
@@ -758,7 +758,7 @@ fn seed_sweep() {
|
||||
];
|
||||
for seed in 1..=8u64 {
|
||||
for c in cfgs {
|
||||
check(Cfg { seed: (seed * 0x9E37_79B9_7F4A_7C15) ^ c.w as u64, ..c });
|
||||
check(Cfg { seed: seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) ^ c.w as u64, ..c });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ and the Timecode / Clip Name effects. Pure Rust; it builds for `wasm32-unknown-u
|
||||
|
||||
| Module | What |
|
||||
|---|---|
|
||||
| `fonts` | Font database. Bundled faces (always present, also on the web): Inter Regular / Medium / SemiBold / Bold / Italic, JetBrains Mono Regular, Noto Serif Regular — all SIL OFL 1.1, unmodified, in `assets/fonts/` with `.attribution` sidecars. More faces come from `FontSource`s: `DirectorySource::system()` scans the platform font folders on first need (only the `name` and `OS/2` tables are read, via `sfnt`); on wasm the scan finds nothing and hosts can call `add_font_data`. `resolve(family, style)` picks a face, synthesises bold/italic when the family lacks them, and falls back to Inter (flagged `missing`). `fallback_for` finds a face for characters the chosen face lacks. |
|
||||
| `fonts` | Font database. Bundled faces (always present, also on the web): Inter Regular / Medium / SemiBold / Bold / Italic, JetBrains Mono Regular, Noto Serif Regular — all SIL OFL 1.1, unmodified, in `assets/fonts/` with `.attribution` sidecars. More faces come from `FontSource`s: `DirectorySource::system()` scans the platform font folders on first need (only the `name` and `OS/2` tables are read, via `sfnt`); on wasm the scan finds nothing and hosts can call `add_font_data`. `resolve(family, style)` picks a face, synthesises bold/italic when the family lacks them, and falls back to Inter (flagged `missing`). `fallback_for` finds a face for characters the chosen face lacks: bundled faces first, then the optional craft-fonts faces (`CRAFT_FONTS`, embedded by `build.rs` when built with `CRAFT_FONTS_DIR`; Mincho for serif text, Gothic otherwise, nearest weight), then loaded system faces. |
|
||||
| `layout` | Paragraph layout: per-character font fallback → bidi levels (UAX #9) → items (runs of one face, direction and size) shaped with harfrust (OpenType kerning, ligatures, marks, complex scripts) → tracking → line breaking at UAX #14 opportunities (area text) → visual reordering per line → alignment (left / centre / right / justify), leading (added to the natural line height), baseline shift, all caps / synthesised small caps (78 %), underline. Every character boundary gets a caret stop (ligatures are split evenly), so editors can place carets, hit-test and draw selections. Layouts are cached by text + style (512 entries, LRU). |
|
||||
| `layout_rich` | Per-character style runs (`StyleRun`: a byte range with its own `TextStyle`): runs split shaping items (font, size, tracking, caps, faux styles), baseline shift and underline apply per run, a line holding a larger run is pushed down from the line above (baseline distance = max(base line height, previous descent + this line's ascent + leading)), and every glyph / underline carries its style index so renderers can colour runs (`render::draw_run`). |
|
||||
| `raster` | Our anti-aliased rasteriser (non-zero winding, 5 sub-scanlines, exact horizontal coverage, active-edge list), contour paths with transforms, rounded rectangles, ellipses, polygons and outline emboldening. Coverage is linear, so compositing in linear light is correct. |
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Optional craft-fonts build input (see storytold/craft-fonts `docs/integration.md` and
|
||||
//! craftrules `standards/fonts.md`).
|
||||
//!
|
||||
//! With `CRAFT_FONTS_DIR=<absolute path of a craft-fonts checkout>` (a relative path resolves from
|
||||
//! `crates/text`) this embeds every font listed in its
|
||||
//! `fonts/manifest.txt` as [`CRAFT_FONTS`](crate::fonts::CRAFT_FONTS); unset, `CRAFT_FONTS` is
|
||||
//! empty and nothing else changes. A `CRAFT_FONTS_DIR` that is not a checkout is a warning, or an
|
||||
//! error with `CRAFT_FONTS_REQUIRED=1` (release builds).
|
||||
//!
|
||||
//! Native builds embed every font. The web (wasm32) build embeds only the UI font (BIZ UDPGothic
|
||||
//! Regular, ~4.5 MB) to stay under the web size budget (Cloudflare serves files up to 25 MiB).
|
||||
//! Only the local directory is read: no network.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=build.rs");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_DIR");
|
||||
println!("cargo::rerun-if-env-changed=CRAFT_FONTS_REQUIRED");
|
||||
let mut src = String::from("pub static CRAFT_FONTS: &[CraftFont] = &[\n");
|
||||
if let Some(dir) = std::env::var_os("CRAFT_FONTS_DIR").map(PathBuf::from) {
|
||||
match craft_fonts(&dir) {
|
||||
Ok(entries) => src.push_str(&entries),
|
||||
Err(e) if std::env::var_os("CRAFT_FONTS_REQUIRED").is_some() => {
|
||||
println!("cargo::error=CRAFT_FONTS_DIR={}: {e}", dir.display());
|
||||
}
|
||||
Err(e) => println!("cargo::warning=building without craft-fonts: CRAFT_FONTS_DIR={}: {e}", dir.display()),
|
||||
}
|
||||
}
|
||||
src.push_str("];\n");
|
||||
let out = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("craft_fonts.rs");
|
||||
if let Err(e) = std::fs::write(&out, src) {
|
||||
println!("cargo::error=writing {}: {e}", out.display());
|
||||
}
|
||||
}
|
||||
|
||||
/// One `CraftFont { .. }` initialiser per manifest line.
|
||||
fn craft_fonts(dir: &std::path::Path) -> Result<String, String> {
|
||||
let manifest = dir.join("fonts/manifest.txt");
|
||||
println!("cargo::rerun-if-changed={}", manifest.display());
|
||||
let text = std::fs::read_to_string(&manifest).map_err(|e| format!("{}: {e}", manifest.display()))?;
|
||||
let wasm = std::env::var("CARGO_CFG_TARGET_ARCH").as_deref() == Ok("wasm32");
|
||||
let mut out = String::new();
|
||||
for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) {
|
||||
let f: Vec<&str> = line.split(" | ").map(str::trim).collect();
|
||||
let [family, style, file, scripts, ..] = f.as_slice() else {
|
||||
return Err(format!("malformed manifest line: {line}"));
|
||||
};
|
||||
if wasm && !(*family == "BIZ UDPGothic" && *style == "Regular") {
|
||||
continue;
|
||||
}
|
||||
let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?;
|
||||
println!("cargo::rerun-if-changed={}", path.display());
|
||||
let scripts: Vec<String> = scripts.split(',').map(|s| format!("{:?}", s.trim())).collect();
|
||||
let _ = writeln!(
|
||||
out,
|
||||
" CraftFont {{ family: {family:?}, style: {style:?}, scripts: &[{}], bytes: include_bytes!({:?}) }},",
|
||||
scripts.join(", "),
|
||||
path.display().to_string(),
|
||||
);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! The font database: bundled fonts (always available, also on the web) plus fonts discovered by
|
||||
//! [`FontSource`]s (system font folders on native platforms).
|
||||
//! The font database: bundled fonts (always available, also on the web), the optional
|
||||
//! [`CRAFT_FONTS`] (present when built with `CRAFT_FONTS_DIR`, also on the web) plus fonts
|
||||
//! discovered by [`FontSource`]s (system font folders on native platforms).
|
||||
//!
|
||||
//! Faces are addressed by [`FaceId`] (an index that never changes for the life of the process).
|
||||
//! [`resolve`] maps a family + style name (as stored in projects) to a face and the synthetic
|
||||
@@ -22,6 +23,27 @@ pub static INTER_ITALIC: &[u8] = include_bytes!("../../../assets/fonts/Inter-Ita
|
||||
pub static JETBRAINS_MONO_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/JetBrainsMono-Regular.ttf");
|
||||
pub static NOTO_SERIF_REGULAR: &[u8] = include_bytes!("../../../assets/fonts/NotoSerif-Regular.ttf");
|
||||
|
||||
/// A font from the optional craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`; see
|
||||
/// `build.rs` and storytold/craft-fonts `docs/integration.md`).
|
||||
pub struct CraftFont {
|
||||
pub family: &'static str,
|
||||
pub style: &'static str,
|
||||
/// ISO 15924 scripts the font is for, e.g. `"Jpan"`.
|
||||
pub scripts: &'static [&'static str],
|
||||
pub bytes: &'static [u8],
|
||||
}
|
||||
|
||||
include!(concat!(env!("OUT_DIR"), "/craft_fonts.rs"));
|
||||
|
||||
/// The craft-fonts entries for Japanese (`Jpan`), in [`CRAFT_FONTS`] order. Empty when the app was
|
||||
/// built without craft-fonts.
|
||||
pub fn craft_japanese() -> impl Iterator<Item = &'static CraftFont> {
|
||||
CRAFT_FONTS.iter().filter(|f| f.scripts.contains(&"Jpan"))
|
||||
}
|
||||
|
||||
/// Origin of faces registered from [`CRAFT_FONTS`].
|
||||
pub const CRAFT_ORIGIN: &str = "craft-fonts";
|
||||
|
||||
/// The default family for new text.
|
||||
pub const DEFAULT_FAMILY: &str = "Inter";
|
||||
|
||||
@@ -240,6 +262,13 @@ fn db() -> &'static RwLock<Db> {
|
||||
push(&mut db, info_from(n, FaceData::Static(b), "bundled"));
|
||||
}
|
||||
}
|
||||
// after the bundled faces, before anything a FontSource finds (a system copy of the same
|
||||
// family + style is then skipped as a duplicate)
|
||||
for f in CRAFT_FONTS {
|
||||
for n in read_faces_bytes(f.bytes) {
|
||||
push(&mut db, info_from(n, FaceData::Static(f.bytes), CRAFT_ORIGIN));
|
||||
}
|
||||
}
|
||||
RwLock::new(db)
|
||||
})
|
||||
}
|
||||
@@ -394,7 +423,15 @@ fn resolve_in(family: &str, style: &str) -> Option<Resolved> {
|
||||
Some(Resolved { face: best.id, synth_bold: w >= 600 && best.info.weight <= 500, synth_italic: it && !best.info.italic, missing: false })
|
||||
}
|
||||
|
||||
/// A face that covers `c`, preferring `prefer`; bundled and already-registered faces are searched.
|
||||
/// Whether a family name reads as a serif face (Noto Serif, Shippori Mincho, …), to pick a Mincho
|
||||
/// fallback for serif text and a Gothic one otherwise.
|
||||
fn is_serif(family: &str) -> bool {
|
||||
let f = family.to_ascii_lowercase();
|
||||
(f.contains("serif") && !f.contains("sans")) || f.contains("mincho")
|
||||
}
|
||||
|
||||
/// A face that covers `c`, preferring `prefer`; bundled, craft-fonts and already-registered faces
|
||||
/// are searched, in that order.
|
||||
pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
|
||||
let p = face(prefer);
|
||||
if p.has_char(c) || c.is_control() {
|
||||
@@ -407,6 +444,15 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
|
||||
return f.id;
|
||||
}
|
||||
}
|
||||
// then the craft-fonts faces (Japanese): Mincho for serif text, Gothic otherwise, nearest weight
|
||||
let serif = is_serif(&p.info.family);
|
||||
let mut craft: Vec<&Arc<Face>> = faces.iter().filter(|f| f.info.origin == CRAFT_ORIGIN).collect();
|
||||
craft.sort_by_key(|f| (is_serif(&f.info.family) != serif, f.info.italic != p.info.italic, f.info.weight.abs_diff(p.info.weight)));
|
||||
for f in craft {
|
||||
if f.has_char(c) {
|
||||
return f.id;
|
||||
}
|
||||
}
|
||||
for f in faces.iter().filter(|f| f.info.origin != "bundled" && f.bytes.get().is_some()) {
|
||||
if f.has_char(c) {
|
||||
return f.id;
|
||||
@@ -464,6 +510,37 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// craft-fonts faces are registered after the bundled ones and serve Japanese characters the
|
||||
/// requested face lacks: Gothic for sans text, Mincho for serif text.
|
||||
#[test]
|
||||
fn japanese_falls_back_to_craft_fonts() {
|
||||
let craft: Vec<_> = all_faces().into_iter().filter(|f| f.info.origin == CRAFT_ORIGIN).collect();
|
||||
if craft_japanese().next().is_none() {
|
||||
eprintln!("SKIPPED: built without craft-fonts (set CRAFT_FONTS_DIR to run)");
|
||||
assert!(craft.is_empty());
|
||||
return;
|
||||
}
|
||||
assert!(craft.len() >= craft_japanese().count(), "{} faces", craft.len());
|
||||
let bundled = all_faces().iter().take_while(|f| f.info.origin == "bundled").count();
|
||||
assert!(craft.iter().all(|f| f.id >= bundled), "craft-fonts faces come after the bundled faces");
|
||||
let sans = resolve("Inter", "Regular").face;
|
||||
let serif = resolve("Noto Serif", "Regular").face;
|
||||
for c in "日本語の文字".chars() {
|
||||
let g = face(fallback_for(c, sans));
|
||||
assert_eq!(g.info.origin, CRAFT_ORIGIN, "{c}");
|
||||
assert!(g.has_char(c) && !is_serif(&g.info.family), "{c}: {}", g.info.family);
|
||||
let m = face(fallback_for(c, serif));
|
||||
assert!(m.has_char(c) && is_serif(&m.info.family), "{c}: {}", m.info.family);
|
||||
}
|
||||
// the bold UI face for bold text
|
||||
let bold = face(fallback_for('日', resolve("Inter", "Bold").face));
|
||||
assert!(bold.info.weight >= 600, "{} {}", bold.info.family, bold.info.style);
|
||||
// and laid out with real glyphs (no tofu)
|
||||
let l = crate::layout("日本語の文字", &crate::TextStyle { size: 40.0, ..Default::default() }, &Default::default());
|
||||
assert_eq!(l.glyphs.len(), 6);
|
||||
assert!(l.glyphs.iter().all(|g| g.id != 0), "{:?}", l.glyphs);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metrics_and_coverage() {
|
||||
let f = face(resolve("Inter", "Regular").face);
|
||||
|
||||
@@ -852,7 +852,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn japanese_vertical_titles_with_an_installed_font() {
|
||||
// Japanese glyphs come from a system font (none is bundled): skip where none is installed.
|
||||
// Japanese glyphs come from the craft-fonts (built with CRAFT_FONTS_DIR) or a system font:
|
||||
// skip where neither is present.
|
||||
crate::fonts::scan_system();
|
||||
if !crate::fonts::all_faces().iter().any(|f| "日本語縦書き".chars().all(|c| f.has_char(c))) {
|
||||
eprintln!("SKIPPED: no Japanese font installed");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! FilmCraft text engine (layer L1).
|
||||
//!
|
||||
//! - [`fonts`]: font database — bundled OFL fonts (Inter, JetBrains Mono, Noto Serif) plus faces
|
||||
//! - [`fonts`]: font database — bundled OFL fonts (Inter, JetBrains Mono, Noto Serif), the optional
|
||||
//! craft-fonts ([`fonts::CRAFT_FONTS`], Japanese; empty unless built with `CRAFT_FONTS_DIR`) plus faces
|
||||
//! discovered by [`fonts::FontSource`]s (system font folders on native platforms; nothing on the
|
||||
//! web unless the host registers font data), family/style resolution with synthetic bold/italic
|
||||
//! and per-character fallback.
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
//! Interface translations. Command ids, document text and file names remain stable.
|
||||
//! Untranslated labels fall back to English so coverage can grow incrementally.
|
||||
//!
|
||||
//! No Japanese font is bundled: Japanese uses a font already installed on the system
|
||||
//! ([`system_japanese_font`]); without one, switching to Japanese is refused with a message.
|
||||
//! Japanese text uses the Japanese craft-fonts when FilmCraft was built with them (`CRAFT_FONTS_DIR`;
|
||||
//! `theme::install` already puts them in every font family, see [`craft_japanese_font`]), otherwise
|
||||
//! a font already installed on the system ([`system_japanese_font`]); with neither, switching to
|
||||
//! Japanese is refused with a message.
|
||||
|
||||
use std::sync::{Arc, OnceLock};
|
||||
|
||||
@@ -90,10 +92,24 @@ pub fn system_japanese_font() -> Option<Arc<egui::FontData>> {
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Add the system's Japanese font as the last fallback of every theme font family, from the next
|
||||
/// Whether the craft-fonts build input (empty unless built with `CRAFT_FONTS_DIR`) supplies a
|
||||
/// Japanese interface font: some craft-fonts face covers [`JAPANESE_SAMPLE`]. `theme::install` adds
|
||||
/// these faces to every font family, so nothing else needs installing (and no system scan runs).
|
||||
pub fn craft_japanese_font() -> bool {
|
||||
filmcraft_text::fonts::craft_japanese().next().is_some()
|
||||
&& filmcraft_text::fonts::all_faces()
|
||||
.iter()
|
||||
.any(|f| f.info.origin == filmcraft_text::fonts::CRAFT_ORIGIN && JAPANESE_SAMPLE.chars().all(|c| f.has_char(c)))
|
||||
}
|
||||
|
||||
/// Japanese for the interface: true when built with the craft-fonts (already installed by
|
||||
/// `theme::install`). Otherwise add the system's Japanese font as the last fallback of every theme font family, from the next
|
||||
/// pass on. Returns false (and changes nothing) when no Japanese font is installed. Call it again
|
||||
/// after `theme::install`, which replaces the font definitions.
|
||||
pub fn install_japanese_font(ctx: &egui::Context) -> bool {
|
||||
if craft_japanese_font() {
|
||||
return true;
|
||||
}
|
||||
let Some(font) = system_japanese_font() else { return false };
|
||||
let families = crate::theme::font_families()
|
||||
.into_iter()
|
||||
@@ -229,8 +245,9 @@ mod tests {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::default()));
|
||||
let r = crate::menus::invoke(&mut app, &ctx, "app.language.japanese", serde_json::json!({}));
|
||||
if system_japanese_font().is_none() {
|
||||
// nothing is bundled: refused, and the interface stays English
|
||||
let craft = craft_japanese_font();
|
||||
if !craft && system_japanese_font().is_none() {
|
||||
// no craft-fonts and no system font: refused, and the interface stays English
|
||||
assert!(r.is_err(), "{r:?}");
|
||||
assert_eq!(app.ui.language, Language::En);
|
||||
return;
|
||||
@@ -239,10 +256,16 @@ mod tests {
|
||||
let mut output = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
output.textures_delta.clear();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
// every theme family falls back to the system Japanese font
|
||||
// every theme family falls back to the craft-fonts (when built with them; no system
|
||||
// font is added then) or to the system Japanese font
|
||||
for family in crate::theme::font_families() {
|
||||
let stack = fonts.definitions().families.get(&family).cloned().unwrap_or_default();
|
||||
assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}");
|
||||
if craft {
|
||||
assert!(stack.last().is_some_and(|n| n.starts_with("craft:")), "{family:?}: {stack:?}");
|
||||
assert!(!stack.iter().any(|n| n == JAPANESE_FONT), "{family:?}: {stack:?}");
|
||||
} else {
|
||||
assert_eq!(stack.last().map(String::as_str), Some(JAPANESE_FONT), "{family:?}: {stack:?}");
|
||||
}
|
||||
}
|
||||
// and the glyphs resolve. (Only families whose replacement-box face is another font:
|
||||
// egui's `has_glyph` reports false for any character served by the face it also uses
|
||||
|
||||
@@ -147,7 +147,7 @@ pub fn panel_command_id(p: PanelKind) -> String {
|
||||
/// Execute a UI or engine command by id.
|
||||
pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Result<Value, String> {
|
||||
if matches!(id, "app.language.english" | "app.language.japanese") {
|
||||
// no Japanese font is bundled: Japanese needs one installed on the system
|
||||
// Japanese needs the craft-fonts (built with CRAFT_FONTS_DIR) or a font installed on the system
|
||||
if id == "app.language.japanese" && !crate::i18n::install_japanese_font(ctx) {
|
||||
return Err("no Japanese font is installed on this system (for example Noto Sans CJK JP); the interface stays in English".into());
|
||||
}
|
||||
|
||||
@@ -230,13 +230,14 @@ impl Tokens {
|
||||
}
|
||||
}
|
||||
|
||||
/// Install fonts (Inter, Inter SemiBold, JetBrains Mono) and egui visuals.
|
||||
/// Every font family the theme defines (fallback fonts such as the system Japanese font are added to
|
||||
/// each of them).
|
||||
/// Every font family the theme defines (fallback fonts such as the Japanese fonts are added to each
|
||||
/// of them).
|
||||
pub fn font_families() -> Vec<FontFamily> {
|
||||
vec![FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("semibold".into()), FontFamily::Name("medium".into())]
|
||||
}
|
||||
|
||||
/// Install fonts (Inter, Inter SemiBold, JetBrains Mono, then the Japanese craft-fonts when built with
|
||||
/// `CRAFT_FONTS_DIR`) and egui visuals.
|
||||
pub fn install(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut fonts = FontDefinitions::default();
|
||||
fonts.font_data.insert("inter".into(), Arc::new(FontData::from_static(filmcraft_text::fonts::INTER_REGULAR)));
|
||||
@@ -247,10 +248,32 @@ pub fn install(ctx: &egui::Context, t: &Tokens) {
|
||||
fonts.families.entry(FontFamily::Monospace).or_default().insert(0, "jbmono".into());
|
||||
fonts.families.insert(FontFamily::Name("semibold".into()), vec!["inter-semibold".into(), "inter".into()]);
|
||||
fonts.families.insert(FontFamily::Name("medium".into()), vec!["inter-medium".into(), "inter".into()]);
|
||||
add_craft_fonts(&mut fonts);
|
||||
ctx.set_fonts(fonts);
|
||||
apply_visuals(ctx, t);
|
||||
}
|
||||
|
||||
/// Name of the egui font for a craft-fonts entry.
|
||||
fn craft_font_name(f: &filmcraft_text::fonts::CraftFont) -> String {
|
||||
format!("craft:{} {}", f.family, f.style)
|
||||
}
|
||||
|
||||
/// Append the Japanese craft-fonts (empty unless built with `CRAFT_FONTS_DIR`) as the last fallbacks
|
||||
/// of every font family, after the app's own fonts: BIZ UDPGothic first (bold before regular in
|
||||
/// the "semibold" and "medium" families), then the other Japanese faces.
|
||||
fn add_craft_fonts(fonts: &mut FontDefinitions) {
|
||||
let jpan: Vec<_> = filmcraft_text::fonts::craft_japanese().collect();
|
||||
for f in &jpan {
|
||||
fonts.font_data.insert(craft_font_name(f), Arc::new(FontData::from_static(f.bytes)));
|
||||
}
|
||||
for (family, stack) in fonts.families.iter_mut() {
|
||||
let heavy = matches!(family, FontFamily::Name(n) if matches!(n.as_ref(), "semibold" | "medium"));
|
||||
let mut order = jpan.clone();
|
||||
order.sort_by_key(|f| (!f.family.contains("Gothic"), (f.style == "Bold") != heavy));
|
||||
stack.extend(order.iter().map(|f| craft_font_name(f)));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
||||
let mut v = if t.kind == ThemeKind::Light { Visuals::light() } else { Visuals::dark() };
|
||||
v.panel_fill = t.panel_bg;
|
||||
@@ -300,3 +323,66 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
||||
s.animation_time = 0.12;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const JAPANESE: &str = "日本語の文字";
|
||||
|
||||
fn installed() -> egui::Context {
|
||||
let ctx = egui::Context::default();
|
||||
install(&ctx, &Tokens::for_kind(ThemeKind::default()));
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |_| {});
|
||||
out.textures_delta.clear();
|
||||
ctx
|
||||
}
|
||||
|
||||
/// Built with craft-fonts: every font family ends with the Japanese faces (BIZ UDPGothic
|
||||
/// first), and Japanese text gets real glyphs, not the replacement box.
|
||||
#[test]
|
||||
fn japanese_renders_with_craft_fonts() {
|
||||
if filmcraft_text::fonts::craft_japanese().next().is_none() {
|
||||
eprintln!("SKIPPED: built without craft-fonts (set CRAFT_FONTS_DIR to run)");
|
||||
return;
|
||||
}
|
||||
let ctx = installed();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
let defs = fonts.definitions().clone();
|
||||
for (family, stack) in &defs.families {
|
||||
let first = stack.iter().position(|n| n.starts_with("craft:")).unwrap_or(stack.len());
|
||||
assert!(first > 0 && stack[first..].iter().all(|n| n.starts_with("craft:")), "{family:?}: {stack:?}");
|
||||
assert!(stack[first].starts_with("craft:BIZ UDPGothic"), "{family:?}: {stack:?}");
|
||||
}
|
||||
for family in [FontFamily::Proportional, FontFamily::Monospace] {
|
||||
let font = FontId::new(13.0, family);
|
||||
for ch in JAPANESE.chars() {
|
||||
assert!(fonts.has_glyph(&font, ch), "missing {ch} in {font:?}");
|
||||
}
|
||||
}
|
||||
});
|
||||
// laid out and drawn: the replacement box (U+FFFD) would show as one glyph per character
|
||||
// from the fallback face; a real face gives each character its own width
|
||||
let galley = ctx.fonts_mut(|f| f.layout_no_wrap(JAPANESE.into(), FontId::new(13.0, FontFamily::Proportional), Color32::WHITE));
|
||||
assert_eq!(galley.rows.iter().map(|r| r.glyphs.len()).sum::<usize>(), JAPANESE.chars().count());
|
||||
assert!(galley.size().x > 13.0 * 4.0, "{:?}", galley.size());
|
||||
}
|
||||
|
||||
/// Built without craft-fonts: the theme installs only the app's own fonts and Latin text
|
||||
/// works as before.
|
||||
#[test]
|
||||
fn works_without_craft_fonts() {
|
||||
let ctx = installed();
|
||||
let n = filmcraft_text::fonts::craft_japanese().count();
|
||||
ctx.fonts_mut(|fonts| {
|
||||
let defs = fonts.definitions().clone();
|
||||
assert_eq!(defs.font_data.keys().filter(|k| k.starts_with("craft:")).count(), n);
|
||||
assert_eq!(defs.families[&FontFamily::Proportional].first().map(String::as_str), Some("inter"));
|
||||
assert_eq!(defs.families[&FontFamily::Monospace].first().map(String::as_str), Some("jbmono"));
|
||||
assert!(fonts.has_glyph(&FontId::new(13.0, FontFamily::Proportional), 'A'));
|
||||
if n == 0 {
|
||||
assert!(defs.families.values().flatten().all(|name| !name.starts_with("craft:")));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,27 @@ code 2 instead of a window.
|
||||
Dev builds compile dependencies at `opt-level = 2` and workspace crates at `opt-level = 1`. For
|
||||
playback and codec speed, use `--release`.
|
||||
|
||||
### Building with craft-fonts
|
||||
|
||||
Font assets live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never in this
|
||||
repo ([standard](https://github.com/storytold/craftrules/blob/main/standards/fonts.md)). It is an optional build input, not a Cargo
|
||||
dependency:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/storytold/craft-fonts ../craft-fonts
|
||||
CRAFT_FONTS_DIR="$PWD/../craft-fonts" cargo run --release -p filmcraft
|
||||
```
|
||||
|
||||
Use an absolute path: `build.rs` runs in `crates/text`, so a relative `CRAFT_FONTS_DIR` would
|
||||
resolve from there. `crates/text/build.rs` reads its `fonts/manifest.txt` and embeds the fonts as
|
||||
`filmcraft_text::fonts::CRAFT_FONTS` (native: all of them; the web build: only BIZ UDPGothic
|
||||
Regular, to keep the wasm small). The Japanese ones become the last fallback of every UI font family
|
||||
(`crates/ui-egui/src/theme.rs`, BIZ UDPGothic first) and fallback faces in the text engine (Gothic
|
||||
for sans text, Mincho for serif text). Without `CRAFT_FONTS_DIR`, `CRAFT_FONTS` is empty and
|
||||
FilmCraft uses its bundled and system fonts. A `CRAFT_FONTS_DIR` that is not a checkout is a build
|
||||
warning, or an error with `CRAFT_FONTS_REQUIRED=1` (release builds set both). Tests on these fonts'
|
||||
glyphs skip when it is empty; when you touch fonts, run the gates both with and without it.
|
||||
|
||||
## 3. Quality gates
|
||||
|
||||
Every commit must pass all of these:
|
||||
@@ -59,6 +80,8 @@ Every commit must pass all of these:
|
||||
| wasm | `cargo xtask wasm` (checks every L0–L4 crate, `filmcraft-ui-egui` and `filmcraft-web` for `wasm32-unknown-unknown`) |
|
||||
| All of the above | `cargo xtask ci` (runs clippy and tests with `--release`) |
|
||||
|
||||
Run them with `CRAFT_FONTS_DIR` set too when you change font code ([craft-fonts](#building-with-craft-fonts)).
|
||||
|
||||
`cargo xtask` is an alias in `.cargo/config.toml` for `cargo run -p xtask --`.
|
||||
|
||||
### Never crash
|
||||
@@ -209,6 +232,12 @@ In short:
|
||||
- Screenshots show only FilmCraft (or other open projects) with media we generated or that is
|
||||
openly licensed.
|
||||
|
||||
- **Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here.**
|
||||
Don't commit new font files to this repo (the Latin UI fonts already in `assets/fonts/` stay): add
|
||||
the font to craft-fonts (file, licence, manifest line, `ATTRIBUTION.md` row) and use it through
|
||||
`filmcraft_text::fonts::CRAFT_FONTS`. See [Building with craft-fonts](#building-with-craft-fonts)
|
||||
and [`craftrules/standards/fonts.md`](https://github.com/storytold/craftrules/blob/main/standards/fonts.md).
|
||||
|
||||
For each asset file `X`, commit a sidecar `X.attribution`. This is the real one for
|
||||
`assets/fonts/Inter-Regular.ttf`:
|
||||
|
||||
|
||||
+15
-1
@@ -44,12 +44,26 @@ warn() {
|
||||
if [ -n "${GITHUB_ACTIONS:-}" ]; then echo "::warning::$*"; else echo "warning: $*" >&2; fi
|
||||
}
|
||||
|
||||
# Copy licence and readme files that exist into a package directory.
|
||||
# Copy licence and readme files that exist into a package directory, plus the licences of the
|
||||
# craft-fonts fonts the build embedded (only when built with CRAFT_FONTS_DIR).
|
||||
copy_docs() {
|
||||
local dest="$1" f
|
||||
for f in README.md LICENSE LICENSE-MIT LICENSE-APACHE COPYRIGHT; do
|
||||
if [ -f "$ROOT/$f" ]; then cp "$ROOT/$f" "$dest/"; fi
|
||||
done
|
||||
copy_font_licences "$dest"
|
||||
}
|
||||
|
||||
# Copy each craft-fonts licence ($CRAFT_FONTS_DIR/fonts/<family>/OFL.txt) to <dest>/OFL-<family>.txt.
|
||||
# Does nothing without CRAFT_FONTS_DIR.
|
||||
copy_font_licences() {
|
||||
local dest="$1" lic family
|
||||
if [ -z "${CRAFT_FONTS_DIR:-}" ]; then return 0; fi
|
||||
for lic in "$CRAFT_FONTS_DIR"/fonts/*/OFL.txt; do
|
||||
[ -f "$lic" ] || continue
|
||||
family="$(basename "$(dirname "$lic")")"
|
||||
cp "$lic" "$dest/OFL-$family.txt"
|
||||
done
|
||||
}
|
||||
|
||||
# Portable SHA-256 of a file (prints just the hash).
|
||||
|
||||
@@ -108,6 +108,8 @@ sed -e "s/@VERSION@/$VERSION/g" -e "s/@SHORT_VERSION@/$SHORT_VERSION/g" \
|
||||
"$HERE/Info.plist.in" >"$APP/Contents/Info.plist"
|
||||
plutil -lint "$APP/Contents/Info.plist"
|
||||
printf 'APPL????' >"$APP/Contents/PkgInfo"
|
||||
# licences of the embedded craft-fonts fonts (builds with CRAFT_FONTS_DIR)
|
||||
copy_font_licences "$APP/Contents/Resources"
|
||||
|
||||
# Sign inside-out: nested code first, then the bundle itself (no --deep on the final signature).
|
||||
# Today the only nested code is the main executable; frameworks/helpers would be signed here too.
|
||||
|
||||
@@ -110,6 +110,12 @@ foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE') {
|
||||
$p = Join-Path $Root $f
|
||||
if (Test-Path $p) { Copy-Item $p $Portable }
|
||||
}
|
||||
# Licences of the embedded craft-fonts fonts (builds with CRAFT_FONTS_DIR) as OFL-<family>.txt.
|
||||
if ($env:CRAFT_FONTS_DIR) {
|
||||
foreach ($lic in Get-ChildItem -Path (Join-Path $env:CRAFT_FONTS_DIR 'fonts\*\OFL.txt') -ErrorAction SilentlyContinue) {
|
||||
Copy-Item $lic.FullName (Join-Path $Portable "OFL-$($lic.Directory.Name).txt")
|
||||
}
|
||||
}
|
||||
$Zip = Join-Path $Dist "filmcraft-$Version-windows-$Arch-portable.zip"
|
||||
Remove-Item -Force $Zip -ErrorAction SilentlyContinue
|
||||
Compress-Archive -Path $Portable -DestinationPath $Zip
|
||||
|
||||
Reference in new issue
Block a user