Compare commits

..
Author SHA1 Message Date
Brandon ThomasandClaude Opus 5.5 c736a97432 gpu: a device that cannot render the effect stage falls back to CPU effects instead of panicking
The GPU effect stage (#35) renders into Rgba32Float, which is not renderable on every device (the FreeBSD CI's software adapter rejects it). FxStage::supported only checks compute/storage limits, so GpuCompositor::new created an invalid pipeline and wgpu's default error handler panicked. Stage creation now runs inside a validation error scope; on an error the compositor runs without it and effects render on the CPU, as on devices without compute shaders. Found by the FreeBSD CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 18:23:57 -04:00
Brandon ThomasandClaude Opus 5.5 f2b36fe277 engine: camera-key test follows the per-platform default keymap
Off macOS, Ctrl+9 belongs to Toggle All Audio Targets (⌘9 there), so Cut to Camera 9 has no default key on Windows, Linux and FreeBSD; cameras 1–8 keep Ctrl+1–8 everywhere. Found by the FreeBSD CI job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 16:01:36 -04:00
Brandon ThomasandClaude Opus 5.5 bd944ee5df FreeBSD: shared workflow template; default shortcuts conflict-free off macOS
- .github/workflows/freebsd.yml follows the shared template: vmactions pinned
  by commit, cpu: 4, git installed, safe.directory for the synced checkout.
- Default shortcuts: the built-in tables are Premiere's macOS keyboard. Off
  macOS, Control and Command are one key, so Ctrl+9, Ctrl+Shift+M and Ctrl+T
  each held two commands on Windows, Linux and FreeBSD (shortcuts_tests::
  assign_reassigns_conflicts_and_undoes failed there; CI only ran on macOS).
  The Command binding now wins and the Mac-Control one is left out on those
  platforms; the Premiere audit checks keys on the platform it builds for.
  macOS is unchanged. New test: default keymaps conflict-free on every
  platform, and macOS keeps its keys.
- mpeg2v synth_tests::seed_sweep hashed seeds with a plain u64 multiply,
  which overflows in the dev profile (FreeBSD CI runs `cargo test` in dev);
  now wrapping_mul.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:29:06 -04:00
Brandon ThomasandClaude Opus 5.5 005d09503b FreeBSD: build and test the workspace in a FreeBSD VM
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 15:29:06 -04:00
Brandon Thomas 771b614ad4 Merge pull request #81 from storytold/pr/craft-fonts
Fonts: use craft-fonts as an optional build input (Japanese UI and title fallback)
2026-10-06 15:08:06 -04:00
Brandon ThomasandClaude Opus 5.5 7f8e102b13 Fonts: the Japanese interface uses craft-fonts first; absolute CRAFT_FONTS_DIR
The merged Japanese interface (#38) looked only for a system font. When
FilmCraft is built with craft-fonts, theme::install already appends the
Japanese faces to every font family, so switching to Japanese uses them
(no system scan) and works on the web too; otherwise it falls back to an
installed system font as before. Docs use an absolute CRAFT_FONTS_DIR
(build.rs runs in crates/text, so a relative path resolves from there).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 14:26:14 -04:00
Brandon ThomasandClaude Opus 5.5 9953befe28 Fonts: use craft-fonts as an optional build input
crates/text/build.rs embeds the fonts of a storytold/craft-fonts checkout
named by CRAFT_FONTS_DIR as filmcraft_text::fonts::CRAFT_FONTS (native:
all of them; wasm32: only BIZ UDPGothic Regular). Unset, CRAFT_FONTS is
empty and nothing changes. A bad CRAFT_FONTS_DIR is a warning, or an
error with CRAFT_FONTS_REQUIRED=1. Never a Cargo dependency, no network.

- UI: the Japanese faces are the last fallback of every egui font family
  (BIZ UDPGothic first, Bold first in the semibold/medium families).
- Text engine: registered after the bundled faces; fallback_for picks
  them after the bundled faces and before system faces (Mincho for serif
  text, Gothic otherwise, nearest weight).
- Tests: Japanese glyphs (no tofu) in the UI and title paths, skipped
  without craft-fonts; the theme works with CRAFT_FONTS empty.
- Release: every build job checks out craft-fonts at a pinned commit and
  builds with CRAFT_FONTS_DIR + CRAFT_FONTS_REQUIRED; packages carry
  OFL-<family>.txt. NOTICE, ATTRIBUTION.md (optional build input), AGENTS.md,
  CLAUDE.md, README and docs/contributing.md describe it.

FilmCraft main bundles no Japanese font, so no font files move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 11:40:29 -04:00
Brandon Thomas a2f6ba8b44 Merge pull request #72 from storytold/pr/docs-hw-decode-correction
Docs: hardware decode correction; log the 2026-10-06 community batch
2026-10-06 11:39:47 -04:00
25 changed files with 524 additions and 26 deletions

No files matched your search

+48
View File
@@ -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
+34
View File
@@ -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:
+7
View File
@@ -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
+15
View File
@@ -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
+1
View File
@@ -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`.
+3
View File
@@ -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.
+10 -1
View File
@@ -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
View File
@@ -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.
+5 -1
View File
@@ -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.
+33 -3
View File
@@ -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)
}
+18
View File
@@ -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
View File
@@ -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 {
+1 -1
View File
@@ -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 });
}
}
}
+1 -1
View File
@@ -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. |
+64
View File
@@ -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)
}
+80 -3
View File
@@ -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);
+2 -1
View File
@@ -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");
+2 -1
View File
@@ -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.
+30 -7
View File
@@ -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
+1 -1
View File
@@ -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());
}
+89 -3
View File
@@ -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:")));
}
});
}
}
+29
View File
@@ -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
View File
@@ -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).
+2
View File
@@ -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.
+6
View File
@@ -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