mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 07:38:24 +00:00
Merge pull request #2257 from aiongg/contrib-batch-1
Integration batch 1: 18 contributor PRs (CJK fonts, timeline, preview caches, interface languages)
This commit is contained in:
58 files changed
+4587
-296
No files matched your search
+2
-1
@@ -97,7 +97,7 @@ UI strings translated out of ≈ 3,900 (detail: [localization-parity.md](docs/lo
|
||||
| Korean | none | 0% |
|
||||
| Vietnamese | none | 0% |
|
||||
|
||||
Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%) and Russian (menus only, 16%). No language has had
|
||||
Also shipped: Traditional Chinese (partial, 29%), Ukrainian (partial, 29%), Czech (partial, 29%), Georgian (partial, 38%; native-speaker reviewed) and Russian (menus only, 16%). No other language has had
|
||||
native-speaker review.
|
||||
|
||||
## Upcoming
|
||||
@@ -135,6 +135,7 @@ Come tell us what matters most to you on [Discord](https://discord.gg/artcraft).
|
||||
follow the shared standard: `docs/parity.md` became `docs/target-app-parity.md`; new
|
||||
`docs/roadmap.md`, `localization-parity.md`, `file-format-parity.md`, `hardware-parity.md`,
|
||||
`ui-parity.md`, `effects-parity.md` and `scripting-parity.md`.
|
||||
- 2026-10-14: Georgian (`ka`): every menu row, every panel-catalog row the Chinese catalogs have plus 195 Georgian-only ones, and the effect names and categories; Match System for `ka` / `ka-GE` / `ka_GE.UTF-8` locales. Reviewed by a native speaker.
|
||||
- 2026-10-10: Spanish (#460) and Brazilian Portuguese (#217) menu translations.
|
||||
- 2026-10-10: Japanese now translates the interface below the menu bar too: `crates/ui-egui/src/i18n/ui.rs` has a Japanese table with the same 396 rows as the Chinese ones (panels, dialogs, buttons and tooltips; the shape test keeps the three catalogs listing the same source strings). Menus were already Japanese.
|
||||
- 2026-10-09: Ukrainian now covers all 396 current panel-catalog strings, including dialogs, buttons and tooltips, alongside its menu translation. The catalog keeps the same source keys and placeholder counts as the Chinese catalogs; missing or engine-generated text still falls back to English.
|
||||
|
||||
@@ -50,6 +50,7 @@ web-sys = { version = "0.3", features = [
|
||||
"HtmlElement",
|
||||
"HtmlInputElement",
|
||||
"Location",
|
||||
"Navigator",
|
||||
"Url",
|
||||
"UrlSearchParams",
|
||||
"Window",
|
||||
|
||||
@@ -184,6 +184,11 @@ fn install_hooks(app: &mut EffectcraftApp) {
|
||||
#[wasm_bindgen]
|
||||
pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
eframe::WebLogger::init(log::LevelFilter::Info).ok();
|
||||
// Match System follows `?lang=` in the address, then the browser's languages; a language
|
||||
// chosen in Settings still wins.
|
||||
let navigator = web_sys::window().map(|w| w.navigator());
|
||||
let browser = navigator.iter().flat_map(|n| n.languages().iter().filter_map(|v| v.as_string()).collect::<Vec<_>>());
|
||||
effectcraft_ui_egui::i18n::set_system_locales(query_value("lang"), browser.collect());
|
||||
let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
|
||||
let canvas: web_sys::HtmlCanvasElement = doc.get_element_by_id(&canvas_id).ok_or("no canvas")?.dyn_into()?;
|
||||
let empty = query_flag("empty");
|
||||
@@ -257,6 +262,7 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
|
||||
if n > 0 {
|
||||
app.frames.set_remote(Some(Arc::new(frames::WebFrames::start(n, !query_flag("nogpuworkers")))));
|
||||
}
|
||||
app.frames.set_virtual_media(files::STORE.media_epoch(), Arc::new(|path| files::STORE.file_identity(path)));
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
api::set_sender(tx);
|
||||
app = app.with_control(rx);
|
||||
|
||||
@@ -416,6 +416,7 @@ fn post_reply(r: &FrameReply, px: Option<&[u8]>) {
|
||||
fn new_server() -> FrameServer {
|
||||
let mut s = FrameServer::new(crate::worker::pool(), Some(Arc::new(effectcraft_expr::Expressions)));
|
||||
s.set_layer_store(Some(Arc::new(effectcraft_engine::render::PrefetchStore::default())));
|
||||
s.set_virtual_id(Some(Arc::new(|path| crate::files::STORE.file_identity(path))));
|
||||
s
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use effectcraft_engine::config::{ConfigStore, FileOps};
|
||||
@@ -45,6 +46,8 @@ pub struct Entry {
|
||||
/// Which write this is: new with every write of the key, so a file replaced by another of
|
||||
/// the same size still reads as changed (workers are sent it again, #218).
|
||||
pub version: u64,
|
||||
/// FNV-1a of `data`, filled the first time a disk-cache key asks for it.
|
||||
hash: Option<u64>,
|
||||
}
|
||||
|
||||
/// A queued write for the browser storage: `data` = `None` deletes the key.
|
||||
@@ -69,13 +72,13 @@ impl Mirror {
|
||||
/// An entry read from the browser's storage at startup (nothing to flush).
|
||||
pub fn load(&mut self, key: &str, data: Arc<[u8]>, modified: f64) {
|
||||
let version = self.next_version();
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version });
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist: true, version, hash: None });
|
||||
}
|
||||
|
||||
pub fn put(&mut self, key: &str, data: Arc<[u8]>, modified: f64, persist: bool) {
|
||||
let was_persisted = self.entries.get(key).is_some_and(|e| e.persist);
|
||||
let version = self.next_version();
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist, version });
|
||||
self.entries.insert(key.to_string(), Entry { data, modified, persist, version, hash: None });
|
||||
if persist || was_persisted {
|
||||
self.touch(key);
|
||||
}
|
||||
@@ -149,14 +152,31 @@ pub fn key_path(key: &str) -> Option<&str> {
|
||||
key.strip_prefix(FILES).filter(|p| p.starts_with('/'))
|
||||
}
|
||||
|
||||
/// FNV-1a 64. Computed when a disk-cache key first needs the file, not on every write.
|
||||
fn content_hash(data: &[u8]) -> u64 {
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for &b in data {
|
||||
h ^= u64::from(b);
|
||||
h = h.wrapping_mul(0x0000_0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// The persistent store: a [`Mirror`] and a hook that wakes the flusher after changes.
|
||||
#[derive(Default)]
|
||||
pub struct Store {
|
||||
pub mirror: Mutex<Mirror>,
|
||||
/// Called after every change that needs flushing (the web host schedules a flush).
|
||||
pub on_change: OnceLock<fn()>,
|
||||
/// Clock in milliseconds since the Unix epoch (tests pin it).
|
||||
pub clock: OnceLock<fn() -> f64>,
|
||||
/// Bumped when a virtual file is written. Preview cache lookups key on it; they do not hash.
|
||||
media_epoch: Arc<AtomicU64>,
|
||||
}
|
||||
|
||||
impl Default for Store {
|
||||
fn default() -> Self {
|
||||
Self { mirror: Mutex::default(), on_change: OnceLock::new(), clock: OnceLock::new(), media_epoch: Arc::new(AtomicU64::new(0)) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Store {
|
||||
@@ -179,12 +199,40 @@ impl Store {
|
||||
/// Store a file under `path` (persisted unless `persist` is false: render outputs).
|
||||
pub fn put_file(&self, path: &str, data: Arc<[u8]>, persist: bool) {
|
||||
let now = self.now();
|
||||
self.lock().put(&file_key(path), data, now, persist);
|
||||
let key = file_key(path);
|
||||
let replaced = self.lock().get(&key).is_none_or(|e| !Arc::ptr_eq(&e.data, &data));
|
||||
self.lock().put(&key, data, now, persist);
|
||||
if replaced {
|
||||
self.media_epoch.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
if persist {
|
||||
self.changed();
|
||||
}
|
||||
}
|
||||
|
||||
/// The generation preview cache lookups key on. It changes when [`Self::put_file`] writes
|
||||
/// a different blob, and it is not a content hash.
|
||||
pub fn media_epoch(&self) -> Arc<AtomicU64> {
|
||||
self.media_epoch.clone()
|
||||
}
|
||||
|
||||
/// Size and content hash of the file under `path`. The hash is stored on the entry so a
|
||||
/// later key does not walk the bytes again.
|
||||
pub fn file_identity(&self, path: &str) -> Option<(u64, u64)> {
|
||||
let key = file_key(path);
|
||||
let mut mirror = self.lock();
|
||||
let entry = mirror.entries.get(&key)?;
|
||||
if let Some(hash) = entry.hash {
|
||||
return Some((entry.data.len() as u64, hash));
|
||||
}
|
||||
let hash = content_hash(&entry.data);
|
||||
let len = entry.data.len() as u64;
|
||||
if let Some(entry) = mirror.entries.get_mut(&key) {
|
||||
entry.hash = Some(hash);
|
||||
}
|
||||
Some((len, hash))
|
||||
}
|
||||
|
||||
pub fn file(&self, path: &str) -> Option<Arc<[u8]>> {
|
||||
self.lock().get(&file_key(path)).map(|e| e.data.clone())
|
||||
}
|
||||
@@ -446,6 +494,11 @@ mod tests {
|
||||
assert_ne!(a, b);
|
||||
assert_eq!(&data[..], b"blue");
|
||||
assert_eq!(s.file_versioned("/collision.png").map(|f| f.1), Some(b), "reading doesn't change it");
|
||||
let epoch = s.media_epoch().load(Ordering::Relaxed);
|
||||
let id = s.file_identity("/collision.png").expect("identity");
|
||||
assert_eq!(id, (4, content_hash(b"blue")));
|
||||
assert_eq!(s.file_identity("/collision.png"), Some(id), "the hash is kept on the entry");
|
||||
assert!(epoch >= 2, "each new blob bumps the generation the preview keys on");
|
||||
}
|
||||
|
||||
/// A write that failed is flushed again with the key's latest state (#215).
|
||||
|
||||
@@ -4,8 +4,8 @@ use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::{Justify, TextDoc, Value as KV};
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{
|
||||
Comp, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid, TrackMatte,
|
||||
limits,
|
||||
Comp, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MaskMode, MatteKind, Node, Project, PropGroup, Quality, Solid,
|
||||
TrackMatte, limits,
|
||||
};
|
||||
use effectcraft_time::Tick;
|
||||
use serde_json::{Value, json};
|
||||
@@ -307,6 +307,103 @@ fn add_item(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"layer": id.0}))
|
||||
}
|
||||
|
||||
/// A layer whose source is an item (footage, precomposition or solid), as in After Effects'
|
||||
/// Replace Source. Text, shape, null, camera, light and 3D model layers have no AVItem source to
|
||||
/// replace.
|
||||
fn replaceable_layer(l: &Layer) -> bool {
|
||||
matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. })
|
||||
}
|
||||
|
||||
/// The source a Project item gives a layer, or why it can't replace one.
|
||||
fn replacement_source(item: ItemId, it: &effectcraft_project::Item) -> Result<LayerSource> {
|
||||
const CMD: &str = "layer.replaceSource";
|
||||
match &it.kind {
|
||||
ItemKind::Comp(_) => Ok(LayerSource::Comp { item }),
|
||||
ItemKind::Footage(f) if f.kind == FootageKind::Data => {
|
||||
Err(bad(CMD, "data files can't be layer sources; read them in expressions with footage(\"name\").sourceData"))
|
||||
}
|
||||
ItemKind::Footage(f) if f.kind == FootageKind::Model => Err(bad(CMD, "3D model items can't replace a 2D layer source; add them as 3D layers")),
|
||||
ItemKind::Footage(_) => Ok(LayerSource::Footage { item }),
|
||||
ItemKind::Solid(_) => Ok(LayerSource::Solid { item }),
|
||||
ItemKind::Folder => Err(bad(CMD, "folders can't be layer sources")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a source brings audio properties (precompositions always can; footage when it has an
|
||||
/// audio stream).
|
||||
fn source_has_audio(project: &Project, source: &LayerSource) -> bool {
|
||||
match source {
|
||||
LayerSource::Comp { .. } => true,
|
||||
LayerSource::Footage { item } => project.item(*item).is_some_and(|i| matches!(&i.kind, ItemKind::Footage(f) if f.has_audio)),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn has_replace_source(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
let c = s.active_comp().ok_or("no composition")?;
|
||||
if !s.state.selected_layers.iter().filter_map(|l| c.layer(*l)).any(replaceable_layer) {
|
||||
return Err("select a footage, composition or solid layer".into());
|
||||
}
|
||||
if s.state.project_selection.is_empty() {
|
||||
return Err("select a footage item, a composition or a solid in the Project panel".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Replace the source of the selected (or `layers`) AV layers with a Project item, as After
|
||||
/// Effects' Alt-drag and Ctrl+Alt+/ do. The layer keeps every property: name, timing, transforms,
|
||||
/// keyframes, expressions, effects, masks, switches, parenting and track matte. Only its source
|
||||
/// changes; precompositions bring their Essential Properties group through `sync_project`, and a
|
||||
/// source with audio adds an Audio group when the layer has none.
|
||||
fn replace_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const CMD: &str = "layer.replaceSource";
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let ids = unlocked(s, cid, ids, CMD)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(CMD, "select a layer first"));
|
||||
}
|
||||
let item = match p.get("item") {
|
||||
Some(Value::Number(n)) => ItemId(n.as_u64().ok_or_else(|| bad(CMD, "item: a project item id"))?),
|
||||
Some(Value::String(name)) => s.project.find_by_name(name).map(|i| i.id).ok_or_else(|| bad(CMD, format!("no item `{name}`")))?,
|
||||
_ => *s.state.project_selection.first().ok_or_else(|| bad(CMD, "select a footage item, a composition or a solid in the Project panel"))?,
|
||||
};
|
||||
let it = s.project.item(item).ok_or_else(|| bad(CMD, format!("no item {}", item.0)))?;
|
||||
let source = replacement_source(item, it)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
for lid in &ids {
|
||||
let l = comp.layer(*lid).ok_or_else(|| bad(CMD, "no such layer"))?;
|
||||
if !replaceable_layer(l) {
|
||||
return Err(bad(CMD, format!("“{}” has no source item to replace", l.name)));
|
||||
}
|
||||
}
|
||||
if let LayerSource::Comp { item: new } = &source
|
||||
&& s.project.comp_contains(*new, cid)
|
||||
{
|
||||
return Err(bad(CMD, "a composition can't contain itself"));
|
||||
}
|
||||
let has_audio = source_has_audio(&s.project, &source);
|
||||
let source_for_edit = source.clone();
|
||||
s.edit("Replace Layer Source", None, |proj, _| {
|
||||
for lid in &ids {
|
||||
let needs_audio = has_audio && proj.comp(cid).and_then(|c| c.layer(*lid)).is_some_and(|l| l.props.sub("audio").is_none());
|
||||
let audio = if needs_audio {
|
||||
let mut next = Ids(&mut proj.next_id);
|
||||
Some(build::audio(&mut next))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
l.source = source_for_edit.clone();
|
||||
if let Some(audio) = audio {
|
||||
l.props.children.push(audio.into());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"layers": ids.iter().map(|l| l.0).collect::<Vec<_>>(), "item": item.0}))
|
||||
}
|
||||
|
||||
fn select(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -417,8 +514,10 @@ fn set_switch_others(s: &mut Session, cid: ItemId, ids: &[LayerId], name: &str,
|
||||
let applies = |l: &Layer| match name {
|
||||
"audio" => l.props.sub("audio").is_some(),
|
||||
"lock" | "locked" => true,
|
||||
// Nulls have a Video switch too (it hides their outline in the viewer).
|
||||
"video" | "eye" => (l.source.is_av() || matches!(l.source, LayerSource::Null)) && !mattes.contains(&l.id),
|
||||
// Every layer has a Video switch (it hides a null's outline in the viewer and turns a
|
||||
// camera or light off), as the Timeline's eye cell shows; track mattes keep theirs off
|
||||
// by design.
|
||||
"video" | "eye" => !mattes.contains(&l.id),
|
||||
_ => l.source.is_av() && !mattes.contains(&l.id),
|
||||
};
|
||||
let mut alone = true;
|
||||
@@ -1628,6 +1727,15 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
has_comp,
|
||||
add_item
|
||||
),
|
||||
cmd!(
|
||||
"layer.replaceSource",
|
||||
"Replace Layer Source",
|
||||
[],
|
||||
Some("Cmd+Alt+/"),
|
||||
"{comp?, layers?, layer?, item?: id|name (default the Project panel selection), fixExpressions?: bool (accepted for After Effects compatibility)} → {layers, item}",
|
||||
has_replace_source,
|
||||
replace_source
|
||||
),
|
||||
cmd!("layer.select", "Select Layers", [], None, "{layers: [id|name|#n], add?, toggle?}", has_comp, select),
|
||||
cmd!("layer.selectNext", "Select Next Layer", [], Some("Cmd+ArrowDown"), "{add?}", has_comp, select_next),
|
||||
cmd!("layer.selectPrevious", "Select Previous Layer", [], Some("Cmd+ArrowUp"), "{add?}", has_comp, select_prev),
|
||||
|
||||
@@ -114,10 +114,10 @@ fn frame_blending(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn hide_other_video(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
s.edit("Hide Other Video", None, |proj, _| {
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut() {
|
||||
if !ids.contains(&l.id) && (l.source.is_av() || matches!(l.source, LayerSource::Null)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
// Every layer has a Video switch (nulls, cameras and lights included, as the Timeline's
|
||||
// eye cell shows): the selection stays shown.
|
||||
for l in proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.iter_mut().filter(|l| !ids.contains(&l.id)) {
|
||||
l.switches.video = false;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
@@ -754,17 +754,33 @@ fn reveal_in_finder(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
|
||||
fn reveal_source(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let item = source_item(s, p, "layer.revealSource")?;
|
||||
s.state.project_selection = vec![item];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": item.0}))
|
||||
let (cid, ids) = layers_p(s, p)?;
|
||||
let mut items: Vec<ItemId> = Vec::new();
|
||||
{
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
for layer in ids.iter().filter_map(|id| comp.layer(*id)) {
|
||||
if let Some(item) = layer.source.item()
|
||||
&& s.project.item(item).is_some()
|
||||
&& !items.contains(&item)
|
||||
{
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
if items.is_empty() {
|
||||
return Err(bad("layer.revealSource", "the selected layers have no source item"));
|
||||
}
|
||||
s.state.project_selection = items.clone();
|
||||
let raw: Vec<u64> = items.iter().map(|item| item.0).collect();
|
||||
frontend(s, "project.reveal", &json!({"items": raw}))?;
|
||||
Ok(json!({"item": items[0].0, "items": raw}))
|
||||
}
|
||||
|
||||
fn reveal_comp(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
s.state.project_selection = vec![cid];
|
||||
frontend(s, "window.panel", &json!({"panel": "project"}))?;
|
||||
Ok(json!({"item": cid.0}))
|
||||
frontend(s, "project.reveal", &json!({"items": [cid.0]}))?;
|
||||
Ok(json!({"item": cid.0, "items": [cid.0]}))
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -909,7 +925,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
cmd!("layer.trackMatte", "Track Matte", [], None, "{layers?, op: none|alpha|alphaInverted|luma|lumaInverted|above|below}", has_layers, track_matte_op),
|
||||
cmd!("layer.openSource", "Open Layer Source", ["Layer"], None, "{layer?}", has_source_item, open_source),
|
||||
cmd!("layer.revealInFinder", "Reveal in Finder", ["Layer"], None, "{layer?}", has_footage_layer, reveal_in_finder),
|
||||
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layer?}", has_source_item, reveal_source),
|
||||
cmd!("layer.revealSource", "Reveal Layer Source in Project", ["Layer", "Reveal"], None, "{layers?}", has_source_item, reveal_source),
|
||||
cmd!("comp.revealInProject", "Reveal Composition in Project", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_comp),
|
||||
cmd!("layer.revealExpressionErrors", "Reveal Expression Errors", ["Layer", "Reveal"], None, "{comp?}", has_comp, reveal_expression_errors),
|
||||
cmd!(
|
||||
|
||||
@@ -1290,6 +1290,8 @@ mod tests_proxy;
|
||||
#[cfg(test)]
|
||||
mod tests_quick_offset;
|
||||
#[cfg(test)]
|
||||
mod tests_replace_source;
|
||||
#[cfg(test)]
|
||||
mod tests_rig3d;
|
||||
#[cfg(test)]
|
||||
mod tests_sequence;
|
||||
|
||||
@@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[
|
||||
("Français", "fr"),
|
||||
("Deutsch", "de"),
|
||||
("Čeština", "cs"),
|
||||
("ქართული", "ka"),
|
||||
];
|
||||
|
||||
/// Settings ▸ Startup & Repair ▸ Window Graphics: (label, `startup.windowGraphics` value).
|
||||
@@ -110,7 +111,8 @@ page!(General {
|
||||
/// Synchronize Time of All Related Items.
|
||||
sync_time_related_items: bool = true,
|
||||
expression_pick_whip_compact: bool = true,
|
||||
create_split_layers_above: bool = false,
|
||||
/// Create Split Layers Above Original Layer (After Effects' default).
|
||||
create_split_layers_above: bool = true,
|
||||
use_system_color_picker: bool = false,
|
||||
/// Number of projects in File ▸ Open Recent.
|
||||
recent_items: u32 = 10,
|
||||
|
||||
@@ -263,6 +263,8 @@ pub struct FrameServer {
|
||||
/// The disk cache's layer buffers (see the module docs) and the project's salt.
|
||||
layer_store: Option<Arc<PrefetchStore>>,
|
||||
salt: u64,
|
||||
/// Size and content hash for footage paths with no native metadata (the browser file table).
|
||||
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
|
||||
}
|
||||
|
||||
/// A frame whose readbacks are in flight.
|
||||
@@ -294,9 +296,16 @@ impl FrameServer {
|
||||
pending: None,
|
||||
layer_store: None,
|
||||
salt: 0,
|
||||
virtual_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Identity of virtual footage files, used when native metadata cannot describe the path.
|
||||
/// Called from [`FrameServer::load`], not from the UI thread's cache lookup.
|
||||
pub fn set_virtual_id(&mut self, id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>) {
|
||||
self.virtual_id = id;
|
||||
}
|
||||
|
||||
/// The store behind the layer cache when a request asks for `layers` (the browser: the
|
||||
/// disk cache's layer buffers, fetched before the request).
|
||||
pub fn set_layer_store(&mut self, store: Option<Arc<PrefetchStore>>) {
|
||||
@@ -338,7 +347,7 @@ impl FrameServer {
|
||||
|
||||
fn load(&mut self, revision: u64) -> Result<(), String> {
|
||||
let p = Project::from_json(&self.doc.to_string()).map_err(|e| e.to_string())?;
|
||||
self.salt = effectcraft_render::disk_cache::footage_salt(&p);
|
||||
self.salt = effectcraft_render::disk_cache::footage_salt_with(&p, self.virtual_id.as_deref());
|
||||
self.cache.set_store_salt(self.salt);
|
||||
self.project = Some(Arc::new(p));
|
||||
self.revision = Some(revision);
|
||||
|
||||
@@ -42,14 +42,14 @@ fn split_layers_go_above_or_below_the_original() {
|
||||
let mut s = session();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
s.execute("time.set", json!({"time": 1.0})).unwrap();
|
||||
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![a, below], "off (default): the new part goes below");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": true})).unwrap();
|
||||
let above = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![above, a]);
|
||||
assert_eq!(order, vec![above, a], "on (default): the new part goes above");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("prefs.set", json!({"key": "general.createSplitLayersAbove", "value": false})).unwrap();
|
||||
let below = s.execute("edit.splitLayer", json!({"layers": [a]})).unwrap()[0].as_u64().unwrap();
|
||||
let order: Vec<u64> = s.active_comp().unwrap().layers.iter().map(|l| l.id.0).collect();
|
||||
assert_eq!(order, vec![a, below]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -111,6 +111,29 @@ fn set_switch_others() {
|
||||
assert!(s.execute("layer.setSwitch", json!({"layers": [c], "switch": "nope", "others": true})).is_err());
|
||||
}
|
||||
|
||||
/// The Video switch has a cell on every layer's row, cameras and lights included, so Alt-clicking
|
||||
/// it (and Hide Other Video) takes them along with the rest.
|
||||
#[test]
|
||||
fn video_others_takes_cameras_and_lights() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Point"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let get = |s: &Session, f: fn(&effectcraft_project::Layer) -> bool| [a, cam, light].map(|l| f(&layer(s, l)));
|
||||
// The camera and the light are hidden: Alt-click on the solid's eye brings them back, since
|
||||
// they stand apart with it, and the next Alt-click hides them with every other layer.
|
||||
s.execute("layer.setSwitch", json!({"layers": [cam, light], "switch": "video", "value": false})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "video", "others": true})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
|
||||
// Hide Other Video takes them too.
|
||||
s.execute("layer.showAllVideo", json!({})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true; 3]);
|
||||
s.execute("layer.hideOtherVideo", json!({"layers": [a]})).unwrap();
|
||||
assert_eq!(get(&s, |l| l.switches.video), [true, false, false]);
|
||||
}
|
||||
|
||||
/// A null's Video switch (the eye) works like any layer's, as in After Effects: it toggles,
|
||||
/// Alt-click and Hide Other Video take nulls along, and its children still follow it while it is
|
||||
/// hidden (parenting ignores the eye).
|
||||
@@ -725,6 +748,37 @@ fn reveal_properties() {
|
||||
assert!(frontend_events(&mut s).iter().all(|(c, _)| c == "timeline.revealProps"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_layer_source_selects_each_layers_source_item() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "#ff0000");
|
||||
let b = solid(&mut s, "#00ff00");
|
||||
let ia = layer(&s, a).source.item().unwrap();
|
||||
let ib = layer(&s, b).source.item().unwrap();
|
||||
assert_ne!(ia, ib);
|
||||
s.execute("layer.select", json!({"layers": [a, b]})).unwrap();
|
||||
let r = s.execute("layer.revealSource", json!({})).unwrap();
|
||||
assert_eq!(r["item"], ia.0);
|
||||
assert_eq!(r["items"], json!([ia.0, ib.0]));
|
||||
assert_eq!(s.state.project_selection, vec![ia, ib]);
|
||||
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [ia.0, ib.0]}))]);
|
||||
// A null has no source. Alone it is refused; in a mixed selection only the sources are revealed.
|
||||
let n = s.execute("layer.newNull", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [n]})).unwrap();
|
||||
assert!(s.execute("layer.revealSource", json!({})).is_err());
|
||||
s.execute("layer.select", json!({"layers": [n, a]})).unwrap();
|
||||
let r = s.execute("layer.revealSource", json!({})).unwrap();
|
||||
assert_eq!(r["items"], json!([ia.0]));
|
||||
assert_eq!(s.state.project_selection, vec![ia]);
|
||||
// Reveal Composition in Project opens the same panel and folders.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
s.drain_events();
|
||||
let r = s.execute("comp.revealInProject", json!({"comp": cid.0})).unwrap();
|
||||
assert_eq!(r["item"], cid.0);
|
||||
assert_eq!(s.state.project_selection, vec![cid]);
|
||||
assert_eq!(frontend_events(&mut s), vec![("project.reveal".to_string(), json!({"items": [cid.0]}))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequence_layers() {
|
||||
let mut s = comp();
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Layer ▸ Replace Source (#991, and the `replaceSource` part of #813): Alt-drag and Ctrl+Alt+/
|
||||
//! replace an AV layer's source while every other layer property stays.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{Footage, FootageKind, ItemId, ItemKind, LayerSource, Solid};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::Session;
|
||||
|
||||
fn comp() -> Session {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 640, "height": 360, "frameRate": 30, "duration": 10})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn layer(s: &Session, id: u64) -> effectcraft_project::Layer {
|
||||
s.active_comp().unwrap().layer(effectcraft_project::LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn solid(s: &mut Session, name: &str) -> u64 {
|
||||
s.execute("layer.newSolid", json!({"name": name, "color": "#ff0000", "width": 200, "height": 100})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
fn add_footage(s: &mut Session, name: &str, kind: FootageKind, width: u32, height: u32, has_audio: bool) -> ItemId {
|
||||
let f = Footage { path: format!("/tmp/{name}"), kind, width, height, has_video: kind != FootageKind::Data, has_audio, ..Default::default() };
|
||||
Arc::make_mut(&mut s.project).add_item(name, Label::None, None, ItemKind::Footage(f))
|
||||
}
|
||||
|
||||
/// The replacement changes `source` and nothing else: name, label, comment, timing, stretch,
|
||||
/// switches, blending, parenting, track matte and the whole property tree (keys, expression,
|
||||
/// effect, mask) stay.
|
||||
#[test]
|
||||
fn replace_source_keeps_every_layer_property() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Hero");
|
||||
let parent = solid(&mut s, "Parent");
|
||||
let matte = solid(&mut s, "Matte");
|
||||
s.execute("layer.timing", json!({"layers": [a], "in": 1.0, "out": 5.0})).unwrap();
|
||||
s.execute("layer.timeStretch", json!({"layers": [a], "percent": 50})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "preserveTransparency", "value": true})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [a], "switch": "motionBlur", "value": true})).unwrap();
|
||||
s.execute("layer.setBlendMode", json!({"layers": [a], "mode": "Multiply"})).unwrap();
|
||||
s.execute("layer.setParent", json!({"layers": [a], "parent": parent, "compensate": false})).unwrap();
|
||||
s.execute("layer.setTrackMatte", json!({"layer": a, "matte": matte, "kind": "alpha"})).unwrap();
|
||||
s.execute("layer.setComment", json!({"layers": [a], "comment": "keep me"})).unwrap();
|
||||
s.execute("effect.apply", json!({"layer": a, "effect": "Gaussian Blur"})).unwrap();
|
||||
s.execute("mask.new", json!({"layer": a, "vertices": [[10, 10], [90, 10], [90, 90]], "closed": true})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 1.0, "value": 25.0})).unwrap();
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "transform/opacity", "time": 2.0, "value": 75.0})).unwrap();
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/rotation", "expression": "time * 90"})).unwrap();
|
||||
let before = layer(&s, a);
|
||||
let item = add_footage(&mut s, "new.png", FootageKind::Still, 120, 80, false);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Footage { item });
|
||||
assert_eq!(after.name, before.name);
|
||||
assert_eq!(after.label, before.label);
|
||||
assert_eq!(after.comment, before.comment);
|
||||
assert_eq!((after.start_time, after.in_point, after.out_point, after.stretch), (before.start_time, before.in_point, before.out_point, before.stretch));
|
||||
assert_eq!((after.switches, after.blend_mode, after.preserve_transparency), (before.switches, before.blend_mode, before.preserve_transparency));
|
||||
assert_eq!((after.parent, after.track_matte), (before.parent, before.track_matte));
|
||||
assert_eq!(after.props, before.props);
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 3, "no layer is added or removed");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, before.source, "one undo step restores the source");
|
||||
}
|
||||
|
||||
/// Composition, footage and solid sources are interchangeable; a precomposition adds its Audio
|
||||
/// group when the layer has none, and an existing Audio group is kept when the next source has no
|
||||
/// audio.
|
||||
#[test]
|
||||
fn replace_source_crosses_types_and_adds_audio() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Layer");
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 200, "height": 100, "frameRate": 25, "duration": 3})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
let before = layer(&s, a);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": inner})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Comp { item: ItemId(inner) });
|
||||
assert_eq!(after.name, before.name);
|
||||
assert!(after.props.sub("audio").is_some(), "a precomposition brings Audio");
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 64, 32, false);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": footage.0})).unwrap();
|
||||
let after = layer(&s, a);
|
||||
assert_eq!(after.source, LayerSource::Footage { item: footage });
|
||||
assert!(after.props.sub("audio").is_some(), "the Audio group is preserved for the next source");
|
||||
let new_solid = Arc::make_mut(&mut s.project).add_item(
|
||||
"New Solid",
|
||||
Label::None,
|
||||
None,
|
||||
ItemKind::Solid(Solid { color: [0.0, 1.0, 0.0], width: 10, height: 10, pixel_aspect: 1.0 }),
|
||||
);
|
||||
s.execute("layer.replaceSource", json!({"layers": [a], "item": new_solid.0})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, LayerSource::Solid { item: new_solid });
|
||||
assert_eq!(s.active_comp().unwrap().layers.len(), 1);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer(&s, a).source, LayerSource::Footage { item: footage });
|
||||
}
|
||||
|
||||
/// Folders, data files, 3D models, non-source layers and composition cycles are refused before
|
||||
/// anything is edited.
|
||||
#[test]
|
||||
fn replace_source_refuses_invalid_sources_targets_and_cycles() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "Layer");
|
||||
let folder = Arc::make_mut(&mut s.project).add_item("Folder", Label::None, None, ItemKind::Folder);
|
||||
let data = add_footage(&mut s, "data.txt", FootageKind::Data, 0, 0, false);
|
||||
let model = add_footage(&mut s, "model.glb", FootageKind::Model, 10, 10, false);
|
||||
for (item, needle) in [(folder, "folder"), (data, "data"), (model, "3D model")] {
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [a], "item": item.0})).unwrap_err().to_string();
|
||||
assert!(err.contains(needle), "{err}");
|
||||
}
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
|
||||
let text = s.execute("layer.newText", json!({"text": "Hello"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [text], "item": footage.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("no source item"), "{err}");
|
||||
let inner = s.execute("comp.new", json!({"name": "Inner", "width": 100, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
let main = s.project.items.values().find(|i| i.name == "Main").unwrap().id;
|
||||
s.execute("comp.open", json!({"comp": main})).unwrap();
|
||||
let inner_layer = s.execute("layer.addItem", json!({"item": inner})).unwrap()["layer"].as_u64().unwrap();
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [inner_layer], "item": main.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("contain"), "{err}");
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+/ takes the source from the Project panel selection and replaces every selected
|
||||
/// layer; locked layers are left alone (and an all-locked selection is an error).
|
||||
#[test]
|
||||
fn replace_source_uses_the_project_selection_and_skips_locked_layers() {
|
||||
let mut s = comp();
|
||||
let a = solid(&mut s, "A");
|
||||
let b = solid(&mut s, "B");
|
||||
let footage = add_footage(&mut s, "still.png", FootageKind::Still, 10, 10, false);
|
||||
s.state.project_selection = vec![footage];
|
||||
let r = s.execute("layer.replaceSource", json!({"layers": [a, b]})).unwrap();
|
||||
assert_eq!(r["item"], json!(footage.0));
|
||||
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
|
||||
assert!(matches!(layer(&s, b).source, LayerSource::Footage { item } if item == footage));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [b], "switch": "lock", "value": true})).unwrap();
|
||||
let old_b = layer(&s, b).source;
|
||||
let r = s.execute("layer.replaceSource", json!({"layers": [a, b], "item": footage.0})).unwrap();
|
||||
assert_eq!(r["layers"], json!([a]));
|
||||
assert!(matches!(layer(&s, a).source, LayerSource::Footage { item } if item == footage));
|
||||
assert_eq!(layer(&s, b).source, old_b);
|
||||
let err = s.execute("layer.replaceSource", json!({"layers": [b], "item": footage.0})).unwrap_err().to_string();
|
||||
assert!(err.contains("locked"), "{err}");
|
||||
}
|
||||
|
||||
/// After Effects' Ctrl+Alt+/ default (Cmd+Alt+/ in EffectCraft's shortcut syntax).
|
||||
#[test]
|
||||
fn replace_source_is_bound_to_ctrl_alt_slash() {
|
||||
let b = crate::shortcuts::bindables(&[]).into_iter().find(|b| b.command == "layer.replaceSource").expect("layer.replaceSource is bindable");
|
||||
assert!(b.defaults.iter().any(|d| d == "Cmd+Alt+/"), "{:?}", b.defaults);
|
||||
}
|
||||
@@ -1064,6 +1064,12 @@ fn interface_language_is_validated_persisted_and_backward_compatible() {
|
||||
cs.load_settings();
|
||||
assert_eq!(cs.prefs.general.language, "cs");
|
||||
assert_eq!(Prefs::from_json(r#"{"general":{"language":"cs"}}"#).general.language, "cs");
|
||||
// Georgian too.
|
||||
assert!(crate::prefs::LANGUAGES.contains(&("ქართული", "ka")));
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
|
||||
let mut ka = Session { config: Some(store.clone()), ..Default::default() };
|
||||
ka.load_settings();
|
||||
assert_eq!(ka.prefs.general.language, "ka");
|
||||
s.execute("prefs.set", json!({"key": "general.language", "value": "ja"})).unwrap();
|
||||
let mut reloaded = Session { config: Some(store), ..Default::default() };
|
||||
reloaded.load_settings();
|
||||
|
||||
+64
-29
@@ -45,6 +45,9 @@ struct Key {
|
||||
matte: [u32; 3],
|
||||
/// A movie frame made at this size for a reduced-resolution render ((0, 0): as decoded).
|
||||
size: (u32, u32),
|
||||
/// A still at a reduced `size`: the footage's nominal size, which decides whether it is
|
||||
/// resampled (see `Inner::still_at_size`); (0, 0) otherwise.
|
||||
nominal: (u32, u32),
|
||||
}
|
||||
|
||||
struct Entry {
|
||||
@@ -678,7 +681,7 @@ impl Inner {
|
||||
FloatCurve::Gamma24 => 2,
|
||||
} << 2);
|
||||
let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] };
|
||||
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0) };
|
||||
let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte, size: (0, 0), nominal: (0, 0) };
|
||||
match footage.kind {
|
||||
FootageKind::Sequence if !footage.sequence.is_empty() => {
|
||||
let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count);
|
||||
@@ -708,16 +711,20 @@ impl Inner {
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame of `footage` at `t`; a movie's resampled to `size` when it can be made at that
|
||||
/// size directly (see [`FootageSource::frame_at_size`]).
|
||||
/// The frame of `footage` at `t`, resampled to `size` (see [`FootageSource::frame_at_size`]):
|
||||
/// a movie's made at that size directly, a still's resampled once and kept at that size.
|
||||
fn frame_at(self: &Arc<Self>, footage: &Footage, t: Tick, playback: bool, size: Option<(u32, u32)>, float: FloatCurve) -> Result<Arc<Image>> {
|
||||
if !footage.has_video {
|
||||
return Err(MediaError::Unsupported(format!("{}: no video", footage.path)));
|
||||
}
|
||||
let mut loc = Self::locate(footage, t, float);
|
||||
let movie = loc.media_t.is_some();
|
||||
if movie && let Some(size) = size {
|
||||
// A still asked for at its own size is the unsized frame (one decode, one entry).
|
||||
if let Some(size) = size.filter(|s| movie || *s != (footage.width, footage.height)) {
|
||||
loc.key.size = size;
|
||||
if !movie {
|
||||
loc.key.nominal = (footage.width, footage.height);
|
||||
}
|
||||
}
|
||||
let mut c = lock(&self.cache);
|
||||
// sequential playback of a movie: read the next frame ahead
|
||||
@@ -773,34 +780,62 @@ impl Inner {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
fn prefetch(self: &Arc<Self>, _footage: &Footage, _t: Tick, _size: Option<(u32, u32)>, _float: FloatCurve) {}
|
||||
|
||||
/// A still (image file or sequence frame) at `loc.key.size` when one is set: resampled as the
|
||||
/// renderer would ([`FootageSource::frame_at_size`]), so the pool keeps the reduced frame and a
|
||||
/// reduced-resolution render neither decodes nor resamples a large still again for every frame
|
||||
/// (#1812: 21120-px plates at Quarter). Like the renderer's `footage_buf`, an image decoded
|
||||
/// larger than the footage's nominal size (a smart object's pixels) is resampled too, and one
|
||||
/// decoded smaller is not (the renderer scales the layer instead).
|
||||
fn still_at_size(img: Image, loc: &Loc, footage: &Footage) -> Image {
|
||||
let (w, h) = loc.key.size;
|
||||
let smaller = img.width == 0 || footage.width as f64 / img.width as f64 > 1.0 + 1e-9;
|
||||
if w == 0 || h == 0 || smaller || (w, h) == (img.width, img.height) {
|
||||
return img;
|
||||
}
|
||||
effectcraft_raster::resample(&img, w, h)
|
||||
}
|
||||
|
||||
fn decode(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
|
||||
match loc.media_t {
|
||||
None => self.decode_still(loc, footage).map(|img| Self::still_at_size(img, loc, footage)),
|
||||
Some(_) => self.decode_media(loc, footage),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_still(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
|
||||
let op = AlphaOp::of(footage, loc.float);
|
||||
let path = &loc.key.path;
|
||||
if &**path == PLACEHOLDER {
|
||||
return Ok(colour_bars(footage.width, footage.height));
|
||||
}
|
||||
let bytes = self.read(path)?;
|
||||
if let Some(img) = crate::dpx::decode(path, &bytes)? {
|
||||
return Ok(dynamic_to_image(&img, op));
|
||||
}
|
||||
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
|
||||
return Ok(img);
|
||||
}
|
||||
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
|
||||
// channel error)
|
||||
if path.to_ascii_lowercase().ends_with(".exr")
|
||||
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
|
||||
{
|
||||
return Err(MediaError::Decode(format!("{path}: {why}")));
|
||||
}
|
||||
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
|
||||
let img = match loaded {
|
||||
Ok(img) => img,
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
|
||||
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
|
||||
};
|
||||
Ok(dynamic_to_image(&img, op))
|
||||
}
|
||||
|
||||
fn decode_media(&self, loc: &Loc, footage: &Footage) -> Result<Image> {
|
||||
let op = AlphaOp::of(footage, loc.float);
|
||||
let path = &loc.key.path;
|
||||
match loc.media_t {
|
||||
None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)),
|
||||
None => {
|
||||
let bytes = self.read(path)?;
|
||||
if let Some(img) = crate::dpx::decode(path, &bytes)? {
|
||||
return Ok(dynamic_to_image(&img, op));
|
||||
}
|
||||
if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? {
|
||||
return Ok(img);
|
||||
}
|
||||
// (an OpenEXR compression the decoder lacks came out transparent or as a missing
|
||||
// channel error)
|
||||
if path.to_ascii_lowercase().ends_with(".exr")
|
||||
&& let Some(why) = crate::exr_channels::unsupported_compression(&bytes)
|
||||
{
|
||||
return Err(MediaError::Decode(format!("{path}: {why}")));
|
||||
}
|
||||
let loaded = crate::probe::load_oriented(&bytes, crate::still_format(path, &bytes));
|
||||
let img = match loaded {
|
||||
Ok(img) => img,
|
||||
// A multi-layer OpenEXR file without an unnamed RGB layer: its colour layer.
|
||||
Err(e) => crate::exr_channels::cached_layered_image(path, bytes).ok_or_else(|| MediaError::Decode(format!("{path}: {e}")))?,
|
||||
};
|
||||
Ok(dynamic_to_image(&img, op))
|
||||
}
|
||||
None => self.decode_still(loc, footage),
|
||||
Some(mt) if gif_anim::is_animated(footage) => {
|
||||
let anim = self.gif(path)?;
|
||||
let frame = anim.frame_at(Tick(mt.0)).ok_or_else(|| MediaError::Decode(format!("{path}: no frames")))?;
|
||||
@@ -1045,7 +1080,7 @@ mod tests {
|
||||
fn lru_respects_budget_and_recycles() {
|
||||
let mut c = Lru::default();
|
||||
let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead
|
||||
let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0) };
|
||||
let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3], size: (0, 0), nominal: (0, 0) };
|
||||
let budget = 3 * (4096 + 64);
|
||||
for i in 0..10 {
|
||||
c.insert(k(i), img(), budget);
|
||||
|
||||
@@ -584,6 +584,46 @@ fn sequence_gaps_show_placeholders() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #1812: a still asked for at a reduced size comes back as the renderer would resample it, and
|
||||
/// is kept at that size: later frames at that size neither decode nor resample it again (a
|
||||
/// parallax project's 21120-px plates were decoded and resampled for every frame at Quarter).
|
||||
#[test]
|
||||
fn stills_at_reduced_sizes_are_resampled_once() {
|
||||
let dir = std::env::temp_dir().join(format!("ec-still-size-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let p = dir.join("plate.png");
|
||||
let px = |x: u32, y: u32| {
|
||||
image::Rgba([(x * 7 % 256) as u8, (y * 13 % 256) as u8, ((x ^ y) % 256) as u8, if (x + y).is_multiple_of(5) { 0 } else { 255 - (x % 128) as u8 }])
|
||||
};
|
||||
image::RgbaImage::from_fn(613, 97, px).save(&p).expect("png");
|
||||
let f = probe(&p).expect("probe");
|
||||
let pool = MediaPool::new();
|
||||
let full = pool.frame(ItemId(1), &f, Tick::ZERO).expect("frame");
|
||||
// At its own size it is the unsized frame: no second decode, no second full-size copy.
|
||||
let misses = pool.stats().misses;
|
||||
let native = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, f.width, f.height).expect("frame");
|
||||
assert!(Arc::ptr_eq(&full, &native), "the native size reuses the unsized frame");
|
||||
assert_eq!(pool.stats().misses, misses);
|
||||
for (w, h) in [(306, 48), (153, 24), (77, 12)] {
|
||||
let sized = pool.frame_at_size(ItemId(1), &f, Tick::ZERO, w, h).expect("frame");
|
||||
assert_eq!(*sized, effectcraft_raster::resample(&full, w, h), "{w}×{h}: the renderer's resample");
|
||||
let misses = pool.stats().misses;
|
||||
let again = pool.frame_at_size(ItemId(1), &f, Tick(3 * TICKS_PER_SECOND), w, h).expect("frame");
|
||||
assert!(Arc::ptr_eq(&sized, &again), "{w}×{h}: kept at that size");
|
||||
assert_eq!(pool.stats().misses, misses, "{w}×{h}: not decoded again");
|
||||
}
|
||||
// Pixels larger than the nominal size (a smart object's) are resampled as the renderer does;
|
||||
// smaller ones are left as they are (the renderer scales the layer up instead).
|
||||
// One pool, one file, one requested size: the two decisions don't share a cache entry.
|
||||
let larger = Footage { width: 306, height: 48, ..f.clone() };
|
||||
let smaller = Footage { width: 1226, height: 194, ..f.clone() };
|
||||
let sized = pool.frame_at_size(ItemId(2), &larger, Tick::ZERO, 153, 24).expect("frame");
|
||||
assert_eq!(*sized, effectcraft_raster::resample(&full, 153, 24));
|
||||
let kept = pool.frame_at_size(ItemId(3), &smaller, Tick::ZERO, 153, 24).expect("frame");
|
||||
assert_eq!(*kept, *full);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn png_with_alpha() {
|
||||
let Some(p) = alpha_png() else { return };
|
||||
|
||||
+83
-14
@@ -22,7 +22,7 @@ use std::hash::{Hash, Hasher};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_project::{ItemKind, Layer, LayerSource, Node, PropGroup};
|
||||
use effectcraft_project::{FootageKind, ItemKind, Layer, LayerSource, Node, PropGroup};
|
||||
|
||||
use crate::eval::EvalCtx;
|
||||
|
||||
@@ -168,15 +168,23 @@ impl LayerCache {
|
||||
// Disk cache: a hit comes back into memory.
|
||||
let (dc, dk) = self.disk_key(key)?;
|
||||
let buf = dc.get_layer(dk)?;
|
||||
self.insert_mem(key, buf.clone());
|
||||
Some(buf)
|
||||
Some(self.insert_mem(key, buf))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&self, key: u64, buf: Arc<Buf>) {
|
||||
/// Keep `buf` under `key`, and return the buffer the cache holds for it: the one kept first
|
||||
/// when another render of the same layer got there before (#1812: preview workers starting
|
||||
/// together each rendered every layer; sharing the first buffer, the GPU compositor uploads
|
||||
/// and packs it once instead of once per worker, which ran it out of memory). Renders don't
|
||||
/// wait for each other: on the frame workers' shared thread pool a wait can end up under
|
||||
/// the very work it waits for.
|
||||
pub fn insert(&self, key: u64, buf: Arc<Buf>) -> Arc<Buf> {
|
||||
if self.gated() {
|
||||
return;
|
||||
return buf;
|
||||
}
|
||||
if let Some(kept) = self.inner.lock().ok().and_then(|g| g.map.get(&key).map(|e| e.buf.clone())) {
|
||||
return kept;
|
||||
}
|
||||
let started = self.inner.lock().ok().and_then(|mut g| g.missed_at.remove(&key));
|
||||
if let Some((dc, dk)) = self.disk_key(key) {
|
||||
@@ -185,20 +193,24 @@ impl LayerCache {
|
||||
dc.put_layer(dk, &buf);
|
||||
}
|
||||
}
|
||||
self.insert_mem(key, buf);
|
||||
self.insert_mem(key, buf)
|
||||
}
|
||||
|
||||
fn insert_mem(&self, key: u64, buf: Arc<Buf>) {
|
||||
/// See [`LayerCache::insert`]: the buffer kept under `key` (`buf`, unless one was kept first
|
||||
/// or `buf` is too large to keep).
|
||||
fn insert_mem(&self, key: u64, buf: Arc<Buf>) -> Arc<Buf> {
|
||||
let bytes = buf.img.data.len() * std::mem::size_of::<effectcraft_raster::Px>();
|
||||
let Ok(mut g) = self.inner.lock() else { return };
|
||||
let Ok(mut g) = self.inner.lock() else { return buf };
|
||||
if bytes > g.budget / 4 {
|
||||
return;
|
||||
return buf;
|
||||
}
|
||||
g.clock += 1;
|
||||
let now = g.clock;
|
||||
if let Some(old) = g.map.insert(key, Entry { buf, bytes, last_use: now }) {
|
||||
g.bytes -= old.bytes;
|
||||
if let Some(e) = g.map.get_mut(&key) {
|
||||
e.last_use = now;
|
||||
return e.buf.clone();
|
||||
}
|
||||
g.map.insert(key, Entry { buf: buf.clone(), bytes, last_use: now });
|
||||
g.bytes += bytes;
|
||||
if g.bytes > g.budget {
|
||||
// Evict least-recently used entries down to 3/4 of the budget.
|
||||
@@ -214,6 +226,7 @@ impl LayerCache {
|
||||
}
|
||||
}
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// The memory budget in bytes.
|
||||
@@ -481,7 +494,7 @@ fn hash_values(h: &mut KeyHasher, ctx: &EvalCtx, layer: &Layer, g: &PropGroup) {
|
||||
}
|
||||
|
||||
/// Cache key for the processed (source → masks → effects) buffer of `layer` at the context
|
||||
/// time, or `None` when the layer must not be cached (footage, precomps, cameras, adjustment
|
||||
/// time, or `None` when the layer must not be cached (movie footage, precomps, cameras, adjustment
|
||||
/// layers…).
|
||||
pub fn layer_key(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool) -> Option<u64> {
|
||||
// Time effects see property values at other times. Keyframes are part of the hashed
|
||||
@@ -514,6 +527,34 @@ fn reads_other_times(layer: &Layer) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
/// A still image (no proxy): its layer is cached like a text or shape layer, so the GPU
|
||||
/// compositor uploads it once, not every frame (#1812: four 21120 px plates re-uploaded by every
|
||||
/// preview worker ran the GPU out of memory). Movies and sequences stay in the media pool only.
|
||||
fn still_footage(ctx: &EvalCtx, item: effectcraft_project::ItemId) -> bool {
|
||||
ctx.project.item(item).is_some_and(|it| it.proxy.is_none() && matches!(&it.kind, ItemKind::Footage(f) if f.kind == FootageKind::Still))
|
||||
}
|
||||
|
||||
/// Does the layer have an animated mask that motion blur may draw at other times (Layer ▸ Mask ▸
|
||||
/// Motion Blur On, or Same As Layer with the layer blurred)?
|
||||
fn mask_motion_blur(ctx: &EvalCtx, layer: &Layer, blur: bool) -> bool {
|
||||
use effectcraft_project::{GroupKind, MaskMotionBlur};
|
||||
fn animated(g: &PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
Node::Prop(p) => !p.keys.is_empty() || p.has_expression(),
|
||||
Node::Group(sub) => animated(sub),
|
||||
})
|
||||
}
|
||||
ctx.comp.enable_motion_blur
|
||||
&& layer.masks().is_some_and(|m| {
|
||||
m.groups().any(|g| match g.kind {
|
||||
GroupKind::Mask { motion_blur, .. } => {
|
||||
(motion_blur == MaskMotionBlur::On || (motion_blur == MaskMotionBlur::SameAsLayer && blur)) && animated(g)
|
||||
}
|
||||
_ => false,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn has_expression(g: &PropGroup) -> bool {
|
||||
g.children.iter().any(|c| match c {
|
||||
Node::Prop(p) => p.has_expression(),
|
||||
@@ -552,14 +593,17 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
|
||||
hash_debug(&mut h, s);
|
||||
}
|
||||
LayerSource::Text | LayerSource::Shape => {}
|
||||
LayerSource::Footage { item } if footage => {
|
||||
LayerSource::Footage { item } if footage || still_footage(ctx, *item) => {
|
||||
let it = ctx.project.item(*item)?;
|
||||
let ItemKind::Footage(f) = &it.kind else { return None };
|
||||
hash_debug(&mut h, f);
|
||||
// A proxy (and its Use Proxy switch) changes the pixels.
|
||||
hash_debug(&mut h, &it.proxy);
|
||||
item.hash(&mut h);
|
||||
ctx.source_time(layer).0.hash(&mut h);
|
||||
// A still is the same at every time (unless a proxy, which may be a movie, stands in).
|
||||
if f.kind != FootageKind::Still || it.proxy.is_some() {
|
||||
ctx.source_time(layer).0.hash(&mut h);
|
||||
}
|
||||
// Frame blending mixes neighbouring source frames.
|
||||
ctx.comp.enable_frame_blending.hash(&mut h);
|
||||
}
|
||||
@@ -625,6 +669,11 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
|
||||
effectcraft_effects::paint::cache_key(g, lt).0.hash(&mut h);
|
||||
}
|
||||
}
|
||||
// Mask motion blur draws animated masks at the shutter's other times too: their values at
|
||||
// this time don't tell two frames apart (a hold before a move).
|
||||
if mask_motion_blur(ctx, layer, blur) {
|
||||
layer.layer_time(ctx.time).0.hash(&mut h);
|
||||
}
|
||||
if time_dependent(layer, effects != Some(0)) {
|
||||
layer.layer_time(ctx.time).0.hash(&mut h);
|
||||
// Effects also see the layer's time mapping.
|
||||
@@ -668,3 +717,23 @@ pub fn derive(key: u64, i: u64) -> u64 {
|
||||
i.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod shared_insert_tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use super::LayerCache;
|
||||
use effectcraft_effects::Buf;
|
||||
use effectcraft_raster::Image;
|
||||
|
||||
/// Two renders of one layer kept under one key: both use the buffer kept first.
|
||||
#[test]
|
||||
fn the_buffer_kept_first_is_shared() {
|
||||
let cache = LayerCache::default();
|
||||
let buf = || Arc::new(Buf { img: Image::new(4, 4), offset: [0.0; 2], scale: 1.0 });
|
||||
let (a, b) = (buf(), buf());
|
||||
assert!(Arc::ptr_eq(&cache.insert(3, a.clone()), &a));
|
||||
assert!(Arc::ptr_eq(&cache.insert(3, b), &a), "the later render gets the first buffer");
|
||||
assert!(Arc::ptr_eq(&cache.get(3).unwrap(), &a));
|
||||
}
|
||||
}
|
||||
@@ -779,8 +779,12 @@ fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
|
||||
h.write(&[0xff]);
|
||||
}
|
||||
|
||||
/// A file's size and modification time (so edited footage gets new keys).
|
||||
fn file_stamp(h: &mut Hash128, path: &str) {
|
||||
/// Size and content hash of a path that has no native metadata (the browser file table).
|
||||
pub type VirtualFileId<'a> = &'a (dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync);
|
||||
|
||||
/// A file's size and modification time (so edited footage gets new keys). When the path has no
|
||||
/// native metadata, `virtual_id` supplies a durable size and content hash instead.
|
||||
fn file_stamp(h: &mut Hash128, path: &str, virtual_id: Option<VirtualFileId<'_>>) {
|
||||
if path.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -791,26 +795,36 @@ fn file_stamp(h: &mut Hash128, path: &str) {
|
||||
{
|
||||
h.write_u64(d.as_nanos() as u64);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if let Some((len, hash)) = virtual_id.and_then(|id| id(path)) {
|
||||
h.write_u64(len);
|
||||
h.write_u64(hash);
|
||||
}
|
||||
}
|
||||
|
||||
fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage) {
|
||||
file_stamp(h, &f.path);
|
||||
fn footage_stamp(h: &mut Hash128, f: &effectcraft_project::Footage, virtual_id: Option<VirtualFileId<'_>>) {
|
||||
file_stamp(h, &f.path, virtual_id);
|
||||
for p in &f.sequence {
|
||||
file_stamp(h, p);
|
||||
file_stamp(h, p, virtual_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Salt for layer cache keys on disk: the renderer version and every footage file's identity
|
||||
/// (layer keys name footage items, not file contents).
|
||||
pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
|
||||
footage_salt_with(project, None)
|
||||
}
|
||||
|
||||
/// [`footage_salt`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
|
||||
pub fn footage_salt_with(project: &effectcraft_project::Project, virtual_id: Option<VirtualFileId<'_>>) -> u64 {
|
||||
let mut h = Hash128::default();
|
||||
h.write(RENDER_VERSION.as_bytes());
|
||||
for (id, it) in &project.items {
|
||||
if let effectcraft_project::ItemKind::Footage(f) = &it.kind {
|
||||
h.write_u64(id.0);
|
||||
hash_debug(&mut h, f);
|
||||
footage_stamp(&mut h, f);
|
||||
footage_stamp(&mut h, f, virtual_id);
|
||||
}
|
||||
}
|
||||
let v = h.finish();
|
||||
@@ -822,6 +836,11 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
|
||||
/// files' size and time). With expressions in play (which can read any comp) the whole project
|
||||
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count.
|
||||
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
|
||||
comp_content_key_with(project, comp, None)
|
||||
}
|
||||
|
||||
/// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
|
||||
pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option<VirtualFileId<'_>>) -> u128 {
|
||||
use effectcraft_project::{ItemKind, LayerSource};
|
||||
let mut h = Hash128::default();
|
||||
h.write(RENDER_VERSION.as_bytes());
|
||||
@@ -855,11 +874,11 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
|
||||
k => hash_debug(&mut h, k),
|
||||
}
|
||||
if let ItemKind::Footage(f) = &it.kind {
|
||||
footage_stamp(&mut h, f);
|
||||
footage_stamp(&mut h, f, virtual_id);
|
||||
}
|
||||
hash_debug(&mut h, &it.proxy);
|
||||
if let Some(px) = &it.proxy {
|
||||
footage_stamp(&mut h, &px.footage);
|
||||
footage_stamp(&mut h, &px.footage, virtual_id);
|
||||
}
|
||||
}
|
||||
h.write_u64(comp.0);
|
||||
@@ -1082,4 +1101,29 @@ mod tests {
|
||||
assert_eq!(b.get_layer(k).unwrap().img, buf(k as u32, 16, 16).img);
|
||||
}
|
||||
}
|
||||
|
||||
/// A path with no native metadata takes its key from the caller's size and content hash (#1253).
|
||||
#[test]
|
||||
fn virtual_file_bytes_change_content_keys() {
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{Comp, Footage, ItemKind, LayerSource, Project};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
let mut p = Project::default();
|
||||
let f = Footage { path: "/virtual.mov".into(), width: 16, height: 9, duration: Tick::from_seconds_f64(1.0), has_video: true, ..Default::default() };
|
||||
let fid = p.add_item("virtual.mov", effectcraft_color::Label::None, None, ItemKind::Footage(f));
|
||||
let mut comp = Comp::new(16, 9, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
comp.layers = vec![build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, (16, 9), None)];
|
||||
let cid = p.add_item("C", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
|
||||
|
||||
let id = |mark: u64| move |path: &str| (path == "/virtual.mov").then_some((8, mark));
|
||||
let (one, two) = (id(1), id(2));
|
||||
let a = footage_salt_with(&p, Some(&one));
|
||||
let b = footage_salt_with(&p, Some(&two));
|
||||
let ka = comp_content_key_with(&p, cid, Some(&one));
|
||||
let kb = comp_content_key_with(&p, cid, Some(&two));
|
||||
assert_ne!(a, b, "replaced virtual media must not reuse the salt");
|
||||
assert_ne!(ka, kb, "replaced virtual media must not reuse the frame key");
|
||||
assert_eq!(comp_content_key(&p, cid), comp_content_key_with(&p, cid, None));
|
||||
}
|
||||
}
|
||||
+30
-20
@@ -1478,10 +1478,11 @@ impl<'a> Renderer<'a> {
|
||||
let mut nb = (*b).clone();
|
||||
color::convert(&mut nb.img, &c);
|
||||
let nb = Arc::new(nb);
|
||||
if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) {
|
||||
cache.insert(k, nb.clone());
|
||||
// The buffer the cache keeps (another worker's, if it kept one first).
|
||||
match (self.cache_to_keep(), key) {
|
||||
(Some(cache), Some(k)) => cache.insert(k, nb),
|
||||
_ => nb,
|
||||
}
|
||||
nb
|
||||
}
|
||||
|
||||
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
@@ -1496,9 +1497,11 @@ impl<'a> Renderer<'a> {
|
||||
return Some((b, key));
|
||||
}
|
||||
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, flat.clone());
|
||||
}
|
||||
// The buffer the cache keeps (another worker's, if it kept one first).
|
||||
let flat = match (self.cache_to_keep(), key) {
|
||||
(Some(c), Some(k)) => c.insert(k, flat),
|
||||
_ => flat,
|
||||
};
|
||||
Some((flat, key))
|
||||
}
|
||||
|
||||
@@ -1527,9 +1530,11 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
let buf = self.masked_source(ctx, layer)?;
|
||||
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
// The buffer the cache keeps (another worker's, if it kept one first).
|
||||
let buf = match (self.cache_to_keep(), key) {
|
||||
(Some(c), Some(k)) => c.insert(k, buf),
|
||||
_ => buf,
|
||||
};
|
||||
Some((buf, key))
|
||||
}
|
||||
|
||||
@@ -1552,9 +1557,11 @@ impl<'a> Renderer<'a> {
|
||||
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
|
||||
}
|
||||
let buf = Arc::new(buf);
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, buf.clone());
|
||||
}
|
||||
// The buffer the cache keeps (another worker's, if it kept one first).
|
||||
let buf = match (self.cache_to_keep(), key) {
|
||||
(Some(c), Some(k)) => c.insert(k, buf),
|
||||
_ => buf,
|
||||
};
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
@@ -1582,12 +1589,13 @@ impl<'a> Renderer<'a> {
|
||||
t.cached = false;
|
||||
t.effects.push(("layerStyles".into(), t0.elapsed().as_secs_f64() * 1e3));
|
||||
}
|
||||
let body = Arc::new(st.body);
|
||||
let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
|
||||
let mut body = Arc::new(st.body);
|
||||
let mut passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
|
||||
// The buffers the cache keeps (another worker's, if it kept them first).
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, body.clone());
|
||||
for (i, (b, _)) in passes.iter().enumerate() {
|
||||
c.insert(cache::derive(k, i as u64 + 1), b.clone());
|
||||
body = c.insert(k, body);
|
||||
for (i, (b, _)) in passes.iter_mut().enumerate() {
|
||||
*b = c.insert(cache::derive(k, i as u64 + 1), b.clone());
|
||||
}
|
||||
}
|
||||
Some(StyledLayer { passes, body, content, key, ckey, plain: false })
|
||||
@@ -2215,9 +2223,11 @@ impl<'a> Renderer<'a> {
|
||||
let mut foot = self.source(ctx, layer)?;
|
||||
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
|
||||
let foot = Arc::new(foot);
|
||||
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
|
||||
c.insert(k, foot.clone());
|
||||
}
|
||||
// The buffer the cache keeps (another worker's, if it kept one first).
|
||||
let foot = match (self.cache_to_keep(), key) {
|
||||
(Some(c), Some(k)) => c.insert(k, foot),
|
||||
_ => foot,
|
||||
};
|
||||
Some(foot)
|
||||
}
|
||||
|
||||
|
||||
@@ -1010,3 +1010,32 @@ fn a_selected_layer_reading_the_comp_scene_invalidates_its_owner() {
|
||||
let (a, b) = (particles_x(&here), particles_x(&panned));
|
||||
assert!(b < a - 20.0, "the blended particles follow the camera: {a} -> {b}");
|
||||
}
|
||||
|
||||
/// #1812: preview workers that render a layer at once end up with one buffer (the one kept
|
||||
/// first), so the GPU compositor uploads and packs it once, not once per worker.
|
||||
#[test]
|
||||
fn workers_rendering_a_layer_at_once_share_one_buffer() {
|
||||
let (mut p, cid, comp) = setup();
|
||||
let mut l = solid(&mut p, &comp, [0.2, 0.5, 0.8], 1600, 1200);
|
||||
add_effect(&mut p, &mut l, "ec.blur.gaussian", [1600.0, 1200.0], &[("blurriness", Value::Scalar(60.0))]);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let cache = crate::LayerCache::default();
|
||||
let start = std::sync::Barrier::new(8);
|
||||
let bufs: Vec<_> = std::thread::scope(|s| {
|
||||
let hs: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
s.spawn(|| {
|
||||
let mut r = crate::Renderer::new(&p, &crate::NoFootage, crate::RenderOpts::default());
|
||||
r.cache = Some(&cache);
|
||||
let c = p.comp(cid).unwrap();
|
||||
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
|
||||
start.wait();
|
||||
r.layer_buf(&ctx, &c.layers[0]).unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
hs.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
assert!(bufs.iter().all(|b| std::sync::Arc::ptr_eq(b, &bufs[0])), "one shared buffer");
|
||||
assert_eq!(cache.stats().entries, 1);
|
||||
}
|
||||
@@ -81,3 +81,28 @@ fn reduced_renders_take_footage_resampled_by_the_source() {
|
||||
Renderer::new(&p, &sized, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert!(sized.sizes.unwrap().into_inner().unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// #1812: a still's layer is kept in the layer cache across times (its texture then uploads to
|
||||
/// the GPU once, not every frame); a movie's is fetched again at every time.
|
||||
#[test]
|
||||
fn still_layers_are_cached_across_times_and_movies_are_not() {
|
||||
for (kind, fetches) in [(FootageKind::Still, 1), (FootageKind::Video, 3)] {
|
||||
let (mut p, cid) = project(BitDepth::Bpc8, false);
|
||||
let Some(LayerSource::Footage { item }) = p.comp(cid).map(|c| c.layers[0].source.clone()) else { panic!("footage layer") };
|
||||
if let Some(ItemKind::Footage(f)) = p.item_mut(item).map(|it| &mut it.kind) {
|
||||
f.kind = kind;
|
||||
}
|
||||
let cache = crate::LayerCache::default();
|
||||
let src = Clip { sizes: Some(Mutex::default()) };
|
||||
let opts = RenderOpts { scale: 0.5, ..Default::default() };
|
||||
for t in [0.0, 0.25, 0.5] {
|
||||
let t = Tick::from_seconds_f64(t);
|
||||
let mut r = Renderer::new(&p, &src, opts);
|
||||
r.cache = Some(&cache);
|
||||
let frame = r.comp_frame(cid, t);
|
||||
assert_eq!(frame, Renderer::new(&p, &src, opts).comp_frame(cid, t), "{kind:?} at {t:?}");
|
||||
}
|
||||
// (each uncached render above fetches once more)
|
||||
assert_eq!(src.sizes.unwrap().into_inner().unwrap().len(), fetches + 3, "{kind:?}");
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,31 @@ fn alpha_at_25(p: &Project, cid: ItemId, motion_blur: bool) -> f32 {
|
||||
Renderer::new(p, &NoFootage, RenderOpts { motion_blur, ..Default::default() }).comp_frame(cid, half).get(25, 50)[3]
|
||||
}
|
||||
|
||||
/// A mask that holds from frame 0 to 2, then moves: frames 1 and 2 have the same mask value, but at
|
||||
/// frame 2 the shutter already sees it move. The layer cache must not serve frame 1's layer there.
|
||||
#[test]
|
||||
fn mask_motion_blur_keys_the_layer_cache_by_time() {
|
||||
let (mut p, cid) = project(true);
|
||||
let rect = |x: f64| Value::Path(ShapePath::rect([x, 50.0], 20.0, 100.0));
|
||||
let l = &mut p.comp_mut(cid).unwrap().layers[0];
|
||||
let Some(Node::Group(m)) = l.props.sub_mut("masks").unwrap().children.first_mut() else { panic!("mask") };
|
||||
m.get_mut("path").unwrap().keys = vec![
|
||||
Keyframe::new(Tick::ZERO, rect(20.0)),
|
||||
Keyframe::new(FrameRate::FPS_30.tick_of(2), rect(20.0)),
|
||||
Keyframe::new(FrameRate::FPS_30.tick_of(3), rect(60.0)),
|
||||
];
|
||||
let cache = crate::LayerCache::default();
|
||||
let render = |t: Tick, cached: bool| {
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: true, ..Default::default() });
|
||||
r.cache = cached.then_some(&cache);
|
||||
r.comp_frame(cid, t)
|
||||
};
|
||||
let (f1, f2) = (FrameRate::FPS_30.tick_of(1), FrameRate::FPS_30.tick_of(2));
|
||||
assert_ne!(render(f1, false), render(f2, false), "the shutter at frame 2 sees the move");
|
||||
render(f1, true);
|
||||
assert_eq!(render(f2, true), render(f2, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mask_motion_blur_follows_the_render_and_the_comp() {
|
||||
let (p, cid) = project(true);
|
||||
|
||||
@@ -475,3 +475,35 @@ fn gradient_glow_uses_gradient() {
|
||||
let px = img.straight(121, 50);
|
||||
assert!(px[0] > px[2], "near the edge the glow takes the gradient's start colour {px:?}");
|
||||
}
|
||||
|
||||
/// #1812 (review of #1928): a styled layer rendered by several workers at once shares its body and
|
||||
/// style passes, as unstyled layers share their buffer.
|
||||
#[test]
|
||||
fn workers_styling_a_layer_at_once_share_its_buffers() {
|
||||
let (p, cid) = scene([1.0; 3], None, |p, c, l| {
|
||||
add(p, c, l, "dropShadow");
|
||||
add(p, c, l, "stroke");
|
||||
});
|
||||
let cache = crate::LayerCache::default();
|
||||
let start = std::sync::Barrier::new(8);
|
||||
let styled: Vec<_> = std::thread::scope(|s| {
|
||||
let hs: Vec<_> = (0..8)
|
||||
.map(|_| {
|
||||
s.spawn(|| {
|
||||
let mut r = Renderer::new(&p, &NoFootage, RenderOpts::default());
|
||||
r.cache = Some(&cache);
|
||||
let ctx = r.eval_ctx(cid, Tick::ZERO).unwrap();
|
||||
start.wait();
|
||||
r.styled_layer(&ctx, &p.comp(cid).unwrap().layers[0]).unwrap()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
hs.into_iter().map(|h| h.join().unwrap()).collect()
|
||||
});
|
||||
let first = &styled[0];
|
||||
assert!(!first.passes.is_empty(), "the drop shadow is a pass");
|
||||
for st in &styled {
|
||||
assert!(std::sync::Arc::ptr_eq(&st.body, &first.body), "one body");
|
||||
assert!(st.passes.iter().zip(&first.passes).all(|(a, b)| std::sync::Arc::ptr_eq(&a.0, &b.0)), "one set of passes");
|
||||
}
|
||||
}
|
||||
+86
-18
@@ -12,7 +12,7 @@ use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3};
|
||||
use effectcraft_keyframe::{Justify, TextDoc, Value};
|
||||
use effectcraft_path::{BezPath, FillRule, StrokeStyle};
|
||||
use effectcraft_project::{Layer, PropGroup};
|
||||
use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{Image, Mask};
|
||||
use effectcraft_text::path_text::{self, PathMeasure};
|
||||
use effectcraft_text::selectors::{self, BasedOn, Mode, Range, Shape, Wiggly};
|
||||
use effectcraft_text::{CharGlyph, TextLayout, char_glyph_style, layout_doc};
|
||||
@@ -187,6 +187,14 @@ pub fn range_value_of(f: f64, offset: f64, units_index: bool, n: usize) -> f64 {
|
||||
if units_index { (v * n as f64).round() } else { v * 100.0 }
|
||||
}
|
||||
|
||||
/// Whether property `uid` of `layer` is a Range Selector's Start, End or Offset while its Units
|
||||
/// are Index: the value is then a count of characters (words, lines), not a percentage.
|
||||
pub fn in_index_units(layer: &Layer, uid: effectcraft_project::Uid) -> bool {
|
||||
let Some(sel) = layer.props.parent_of(uid).filter(|g| g.match_id == "rangeSelector") else { return false };
|
||||
let is_range_value = sel.props().any(|p| p.uid == uid && matches!(p.match_id.as_str(), "start" | "end" | "offset"));
|
||||
is_range_value && sel.prop("advanced/units").is_some_and(|u| u.value == Value::Enum(1))
|
||||
}
|
||||
|
||||
/// Selection of every glyph by an animator's selectors, per dimension.
|
||||
pub fn animator_selection(ctx: &EvalCtx, layer: &Layer, anim: &PropGroup, lay: &TextLayout) -> Vec<[f64; 3]> {
|
||||
let sels: Vec<&PropGroup> = anim.sub("selectors").map(|s| s.groups().filter(|g| g.enabled).collect()).unwrap_or_default();
|
||||
@@ -479,6 +487,9 @@ pub struct PlacedGlyph {
|
||||
pub fill: [f32; 4],
|
||||
pub stroke: [f32; 4],
|
||||
pub stroke_width: f64,
|
||||
/// Faux Bold (or synthetic bold for a family without a bold face): how far the outline grows
|
||||
/// on every side, in character space (0 when not bold).
|
||||
pub bold: f64,
|
||||
/// The character's style fills it (Character panel fill on).
|
||||
pub apply_fill: bool,
|
||||
/// Index of the character in the text.
|
||||
@@ -498,8 +509,30 @@ impl PlacedGlyph {
|
||||
pub fn path(&self) -> BezPath {
|
||||
effectcraft_path::transform(std::slice::from_ref(&self.local), &self.m2()).remove(0)
|
||||
}
|
||||
/// [`Self::bold`] in layer space.
|
||||
fn bold_in_layer(&self) -> f64 {
|
||||
self.bold * self.m2().mean_scale()
|
||||
}
|
||||
/// Whether [`Self::bold`] grows the outline as evenly in layer space as in character space:
|
||||
/// no bold, or a matrix that only rotates and scales uniformly.
|
||||
fn bold_is_even(&self) -> bool {
|
||||
let [[a, b, _], [c, d, _], _] = self.m2().0;
|
||||
let n = a * a + b * b + c * c + d * d;
|
||||
self.bold == 0.0 || ((a * a + c * c - b * b - d * d).abs() <= 1e-9 * n && (a * b + c * d).abs() <= 1e-9 * n)
|
||||
}
|
||||
}
|
||||
|
||||
/// How far a circle of radius `r` reaches along x and along y once mapped by `m`.
|
||||
fn axis_reach(m: &Mat3, r: f64) -> [f64; 2] {
|
||||
let [[a, b, _], [c, d, _], _] = m.0;
|
||||
[r * a.hypot(b), r * c.hypot(d)]
|
||||
}
|
||||
|
||||
/// How far Faux Bold grows a glyph's outline on every side, as a fraction of its size: Inter
|
||||
/// Regular grown by this has about the stems of Inter Bold, so synthetic bold stands in for a
|
||||
/// missing bold face.
|
||||
const FAUX_BOLD_RADIUS: f64 = 0.03;
|
||||
|
||||
/// Laid-out, animated text of a layer at the context time.
|
||||
pub struct TextGeom {
|
||||
pub doc: TextDoc,
|
||||
@@ -777,6 +810,7 @@ pub fn text_geom(ctx: &EvalCtx, layer: &Layer) -> Option<TextGeom> {
|
||||
fill,
|
||||
stroke,
|
||||
stroke_width: (base_stroke_w + x.stroke_width).max(0.0),
|
||||
bold: if g.synth_bold { FAUX_BOLD_RADIUS * g.size } else { 0.0 },
|
||||
apply_fill: st.apply_fill,
|
||||
char_index: g.char_index,
|
||||
rest,
|
||||
@@ -826,17 +860,41 @@ fn passes(geom: &TextGeom) -> Vec<Pass> {
|
||||
v
|
||||
}
|
||||
|
||||
/// Coverage of the outline grown by `d` on every side (shrunk when `d` is negative): the fill
|
||||
/// merged with, or cut by, a round stroke `2|d|` wide, which keeps the edges anti-aliased.
|
||||
fn grown_coverage(paths: &[BezPath], mm: &Mat3, w: u32, h: u32, d: f64) -> Mask {
|
||||
let mut cov = effectcraft_path::fill_coverage(paths, mm, w, h, FillRule::NonZero);
|
||||
if d.abs() < 1e-9 {
|
||||
return cov;
|
||||
}
|
||||
let st = StrokeStyle { width: 2.0 * d.abs(), join: effectcraft_path::Join::Round, ..Default::default() };
|
||||
let ring = effectcraft_path::stroke_coverage(paths, &st, mm, w, h);
|
||||
for (c, r) in cov.data.iter_mut().zip(&ring.data) {
|
||||
*c = if d > 0.0 { c.max(*r) } else { c.min(1.0 - r) };
|
||||
}
|
||||
cov
|
||||
}
|
||||
|
||||
/// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths
|
||||
/// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred
|
||||
/// when the character has Blur.
|
||||
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image {
|
||||
/// when the character has Blur. `bold` is the glyph's Faux Bold in the paths' space.
|
||||
fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2], bold: f64) -> Image {
|
||||
let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32);
|
||||
let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m;
|
||||
let cov = if stroke {
|
||||
let cov = if stroke && bold > 0.0 {
|
||||
// The stroke of the bold outline: the band between it grown and shrunk by half the width.
|
||||
let half = g.stroke_width / 2.0;
|
||||
let mut outer = grown_coverage(paths, &mm, w, h, bold + half);
|
||||
let inner = grown_coverage(paths, &mm, w, h, bold - half);
|
||||
for (o, i) in outer.data.iter_mut().zip(&inner.data) {
|
||||
*o = o.min(1.0 - i);
|
||||
}
|
||||
outer
|
||||
} else if stroke {
|
||||
let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
|
||||
effectcraft_path::stroke_coverage(paths, &st, &mm, w, h)
|
||||
} else {
|
||||
effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero)
|
||||
grown_coverage(paths, &mm, w, h, bold)
|
||||
};
|
||||
let col = if stroke { g.stroke } else { g.fill };
|
||||
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
|
||||
@@ -892,10 +950,16 @@ fn runs(geom: &TextGeom) -> Vec<(Vec<usize>, bool)> {
|
||||
if g.xf.opacity <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
// A run grows its outline in layer space by one radius, which matches each glyph grown in
|
||||
// its own space only when the bold stays round there.
|
||||
if geom.blend == BlendMode::Normal
|
||||
&& solid_fill(g, stroke)
|
||||
&& g.bold_is_even()
|
||||
&& let Some((run, false)) = out.last_mut()
|
||||
&& run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill)
|
||||
&& run.last().is_some_and(|&p| {
|
||||
let q = &geom.glyphs[p];
|
||||
solid_fill(q, false) && q.fill == g.fill && q.bold_is_even() && (q.bold_in_layer() - g.bold_in_layer()).abs() < 1e-6
|
||||
})
|
||||
{
|
||||
run.push(gi);
|
||||
continue;
|
||||
@@ -917,28 +981,31 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt
|
||||
// One glyph is drawn from its own outline; a run from its glyphs' outlines in layer
|
||||
// space, all wound the same way so overlaps add up instead of cancelling (NonZero).
|
||||
let local = Cow::Borrowed(std::slice::from_ref(&g.local));
|
||||
let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) {
|
||||
(Some(_), _) => (local, *to_px),
|
||||
(None, [_]) => (local, *to_px * g.m2()),
|
||||
let (paths, m, bold): (Cow<[BezPath]>, Mat3, f64) = match (only, run.as_slice()) {
|
||||
(Some(_), _) => (local, *to_px, g.bold),
|
||||
(None, [_]) => (local, *to_px * g.m2(), g.bold),
|
||||
(None, _) => {
|
||||
let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p };
|
||||
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px)
|
||||
(Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px, g.bold_in_layer())
|
||||
}
|
||||
};
|
||||
let Some(b) = effectcraft_path::bounds(&paths) else { continue };
|
||||
let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s];
|
||||
let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5;
|
||||
// Stroke and bold grow the outline in the paths' space: under a non-uniform `m` they reach
|
||||
// further along one axis than the other.
|
||||
let [px, py] = axis_reach(&m, g.stroke_width + 2.0 * bold);
|
||||
let pad = 2.0 + blur[0].max(blur[1]) * 1.5;
|
||||
let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1));
|
||||
let rect = [
|
||||
((r.x0 - pad).floor() as i64).max(0),
|
||||
((r.y0 - pad).floor() as i64).max(0),
|
||||
((r.x1 + pad).ceil() as i64).min(w),
|
||||
((r.y1 + pad).ceil() as i64).min(h),
|
||||
((r.x0 - px - pad).floor() as i64).max(0),
|
||||
((r.y0 - py - pad).floor() as i64).max(0),
|
||||
((r.x1 + px + pad).ceil() as i64).min(w),
|
||||
((r.y1 + py + pad).ceil() as i64).min(h),
|
||||
];
|
||||
if rect[2] <= rect[0] || rect[3] <= rect[1] {
|
||||
continue;
|
||||
}
|
||||
let patch = raster_pass(g, stroke, &paths, &m, rect, blur);
|
||||
let patch = raster_pass(g, stroke, &paths, &m, rect, blur, bold);
|
||||
blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend });
|
||||
}
|
||||
}
|
||||
@@ -950,8 +1017,9 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, s: f64) -> Buf {
|
||||
let mut bounds: Option<kurbo::Rect> = None;
|
||||
for g in &geom.glyphs {
|
||||
if let Some(b) = effectcraft_path::bounds(&[g.path()]) {
|
||||
let [bx, by] = axis_reach(&g.m2(), 2.0 * g.bold);
|
||||
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
|
||||
let b = b.inflate(pad, pad);
|
||||
let b = b.inflate(pad + bx, pad + by);
|
||||
bounds = Some(bounds.map_or(b, |a| a.union(b)));
|
||||
}
|
||||
}
|
||||
@@ -976,7 +1044,7 @@ pub fn per_char_planes(ctx: &EvalCtx, layer: &Layer, s: f64) -> Vec<(Buf, Mat4)>
|
||||
continue;
|
||||
}
|
||||
let Some(b) = effectcraft_path::bounds(std::slice::from_ref(&g.local)) else { continue };
|
||||
let pad = g.stroke_width + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
|
||||
let pad = g.stroke_width + 2.0 * g.bold + 2.0 + g.xf.blur[0].max(g.xf.blur[1]) * 1.5;
|
||||
let b = b.inflate(pad, pad);
|
||||
let w = ((b.width() * s).ceil() as u32 + 4).clamp(1, 4096);
|
||||
let h = ((b.height() * s).ceil() as u32 + 4).clamp(1, 4096);
|
||||
|
||||
@@ -99,6 +99,24 @@ fn range_based_on_words_and_lines() {
|
||||
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
|
||||
}
|
||||
|
||||
/// With Units = Index a Range Selector's Start, End and Offset are character counts, not
|
||||
/// percentages (#1814); the selector's other values, and other selectors, are unaffected.
|
||||
#[test]
|
||||
fn index_units_mark_start_end_and_offset() {
|
||||
let (mut p, _cid, comp) = setup(400, 200);
|
||||
let mut l = text_layer(&mut p, &comp, doc("ABCDEFG", 30.0));
|
||||
animator(&mut p, &mut l, &["opacity"], &["range", "wiggly"]);
|
||||
let uid = |l: &Layer, path: &str| l.props.prop(path).unwrap_or_else(|| panic!("no {path}")).uid;
|
||||
let base = "text/animators/#1/selectors/#1";
|
||||
let range_values = ["start", "end", "offset"].map(|k| uid(&l, &format!("{base}/{k}")));
|
||||
assert!(range_values.iter().all(|u| !in_index_units(&l, *u)), "Percentage, the default");
|
||||
set(&mut l, &format!("{base}/advanced/units"), Value::Enum(1));
|
||||
assert!(range_values.iter().all(|u| in_index_units(&l, *u)));
|
||||
assert!(!in_index_units(&l, uid(&l, &format!("{base}/advanced/amount"))), "Amount stays a percentage");
|
||||
assert!(!in_index_units(&l, uid(&l, "text/animators/#1/properties/opacity")));
|
||||
assert!(!in_index_units(&l, uid(&l, "transform/opacity")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn range_start_end_and_offset_are_capped() {
|
||||
let (mut p, cid, comp) = setup(400, 200);
|
||||
@@ -524,6 +542,7 @@ fn touching_glyphs_join_without_a_seam() {
|
||||
fill,
|
||||
stroke: [0.0; 4],
|
||||
stroke_width: 0.0,
|
||||
bold: 0.0,
|
||||
apply_fill: true,
|
||||
char_index: 0,
|
||||
rest: [0.0; 2],
|
||||
@@ -551,6 +570,64 @@ fn touching_glyphs_join_without_a_seam() {
|
||||
assert!(multiply[3] < 0.9, "{multiply:?}");
|
||||
}
|
||||
|
||||
/// #1676: Faux Bold, and the synthetic bold of a style the family has no face for, are drawn.
|
||||
#[test]
|
||||
fn faux_bold_and_synthetic_bold_thicken_the_glyphs() {
|
||||
// Total alpha and the alpha bounding box of a rendered word.
|
||||
let ink = |d: TextDoc| {
|
||||
let (mut p, cid, comp) = setup(400, 120);
|
||||
let mut l = text_layer(&mut p, &comp, TextDoc { text: "Hamburg".into(), size: 60.0, ..d });
|
||||
set(&mut l, "transform/position", Value::Vec3([40.0, 85.0, 0.0]));
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let img = crate::render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
(img.data.iter().map(|px| px[3]).sum::<f32>(), alpha_bbox(&img).unwrap())
|
||||
};
|
||||
let (plain, pb) = ink(TextDoc::default());
|
||||
let (bold, bb) = ink(TextDoc { faux_bold: true, ..Default::default() });
|
||||
assert!(bold > plain * 1.15, "faux bold adds ink: {plain} → {bold}");
|
||||
// 0.03 em = 1.8 px on every side.
|
||||
let grow = (bb[2] - bb[0]) - (pb[2] - pb[0]);
|
||||
assert!((3..=6).contains(&grow), "and widens the word by about 3.6 px: {pb:?} → {bb:?}");
|
||||
// Bundled Inter has no Bold Italic face: the italic face is drawn with synthetic bold.
|
||||
let (italic, _) = ink(TextDoc { style: "Italic".into(), ..Default::default() });
|
||||
let (bold_italic, _) = ink(TextDoc { style: "Bold Italic".into(), ..Default::default() });
|
||||
assert!(bold_italic > italic * 1.15, "Bold Italic is bolder than Italic: {italic} → {bold_italic}");
|
||||
// A stroke follows the bold outline: as wide as before, moved out with it.
|
||||
let stroked = TextDoc { apply_fill: false, apply_stroke: true, stroke: [1.0; 4], stroke_width: 4.0, ..Default::default() };
|
||||
let (ring, rb) = ink(stroked.clone());
|
||||
let (bold_ring, brb) = ink(TextDoc { faux_bold: true, ..stroked });
|
||||
assert!(bold_ring > ring * 0.95 && bold_ring < ring * 1.3, "the stroke keeps its width: {ring} → {bold_ring}");
|
||||
assert!(brb[0] < rb[0] && brb[2] > rb[2], "{rb:?} → {brb:?}");
|
||||
}
|
||||
|
||||
/// #1676: under a non-uniform character Scale the bold is grown in the character's space, so it
|
||||
/// reaches further along the stretched axis; it is neither clipped nor grown evenly in a run.
|
||||
#[test]
|
||||
fn faux_bold_under_a_non_uniform_scale() {
|
||||
use kurbo::Shape;
|
||||
let glyph = |x0: f64| PlacedGlyph {
|
||||
local: kurbo::Rect::new(x0, 4.0, x0 + 4.0, 16.0).to_path(0.1),
|
||||
m: Mat4::scale(vec3(9.0, 1.0, 1.0)),
|
||||
xf: CharXf::default(),
|
||||
fill: [1.0; 4],
|
||||
stroke: [0.0; 4],
|
||||
stroke_width: 0.0,
|
||||
bold: 2.0,
|
||||
apply_fill: true,
|
||||
char_index: 0,
|
||||
rest: [0.0; 2],
|
||||
};
|
||||
let geom = |glyphs| TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend: BlendMode::Normal };
|
||||
assert_eq!(runs(&geom(vec![glyph(2.0), glyph(6.0)])).len(), 2, "bold glyphs stretched unevenly are drawn one by one");
|
||||
// x 6..10 → 54..90, grown by 2 × 9 = 18 → 36..108; y 4..16 grown by 2 → 2..18.
|
||||
let mut img = crate::Image::new(120, 24);
|
||||
draw_glyphs(&geom(vec![glyph(6.0)]), &mut img, &Mat3::IDENTITY, 1.0, None);
|
||||
for (x, y) in [(37, 10), (106, 10), (70, 3)] {
|
||||
assert!(img.get(x, y)[3] > 0.99, "bold ink at ({x}, {y}): {:?}", img.get(x, y));
|
||||
}
|
||||
assert!(img.get(34, 10)[3] < 0.01 && img.get(110, 10)[3] < 0.01 && img.get(70, 0)[3] < 0.01);
|
||||
}
|
||||
|
||||
/// #1011: what dragging a Range Selector bar to fraction `f` of the text writes.
|
||||
#[test]
|
||||
fn range_bar_drag_values() {
|
||||
|
||||
@@ -1304,7 +1304,14 @@ AVLayer.prototype.constructor = AVLayer;
|
||||
}
|
||||
P.sourcePointToComp = function (p) { return pointIn(this, p, true); };
|
||||
P.compPointToSource = function (p) { return pointIn(this, p, false); };
|
||||
P.replaceSource = function () { throw __err("replaceSource is not supported yet"); };
|
||||
// Replace the layer's source item, keeping every layer property (After Effects'
|
||||
// AVLayer.replaceSource). `fixExpressions` is accepted for compatibility: only AV sources are
|
||||
// replaced, so the property tree and the layer's name stay and expressions keep resolving.
|
||||
P.replaceSource = function (newSource, fixExpressions) {
|
||||
if (!newSource || newSource.__id === undefined) throw __err("replaceSource needs a project item");
|
||||
__call("layer.replaceSource", { comp: this.__comp, layers: [this.__id], item: newSource.__id, fixExpressions: !!fixExpressions });
|
||||
return null;
|
||||
};
|
||||
P.openInViewer = function () { __call("layer.openLayer", this.__p1()); return null; };
|
||||
})();
|
||||
|
||||
|
||||
@@ -963,6 +963,37 @@ fn footage_proxies_sequences_and_conform_rate() {
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// #813: `AVLayer.replaceSource` changes the source item and keeps the layer's name, timing and
|
||||
/// properties; a folder is not a source.
|
||||
#[test]
|
||||
fn av_layer_replace_source_keeps_the_layer() {
|
||||
let mut s = session();
|
||||
let o = ok(
|
||||
&mut s,
|
||||
r#"
|
||||
var main = app.project.items.addComp("Main", 100, 100, 1, 5, 25);
|
||||
var layer = main.layers.addSolid([1, 0, 0], "Old", 100, 100, 1);
|
||||
layer.transform.position.setValue([10, 20]);
|
||||
layer.startTime = 1;
|
||||
var nested = app.project.items.addComp("Nested", 200, 100, 1, 3, 25);
|
||||
layer.replaceSource(nested, false);
|
||||
[layer.source.name, layer.source.width, layer.source.height, layer.source instanceof CompItem,
|
||||
layer.name, layer.transform.position.value[0], layer.startTime, main.numLayers]
|
||||
"#,
|
||||
);
|
||||
assert_eq!(o.result, json!(["Nested", 200, 100, true, "Old", 10, 1, 1]));
|
||||
let bad = run_code(
|
||||
&mut s,
|
||||
r#"
|
||||
var main = app.project.items.addComp("Other", 100, 100, 1, 2, 25);
|
||||
var layer = main.layers.addSolid([1, 0, 0], "Layer", 100, 100, 1);
|
||||
layer.replaceSource(app.project.items.addFolder("F"), false);
|
||||
"#,
|
||||
"replace-source.jsx",
|
||||
);
|
||||
assert!(bad.error.as_ref().is_some_and(|e| e.message.contains("folder")), "{bad:?}");
|
||||
}
|
||||
|
||||
/// A flat green footage source, so `sampleImage` on a footage layer has known pixels.
|
||||
struct Flat;
|
||||
impl effectcraft_engine::render::FootageSource for Flat {
|
||||
|
||||
+197
-26
@@ -633,6 +633,8 @@ enum FallbackScript {
|
||||
/// Sinhala (one family covers them all on Windows; elsewhere each has its own).
|
||||
Indic,
|
||||
Thai,
|
||||
/// Georgian: Mkhedruli, Asomtavruli and Nuskhuri, and Mtavruli capitals.
|
||||
Georgian,
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -661,6 +663,7 @@ fn script_of(c: char) -> FallbackScript {
|
||||
0x0590..=0x05FF | 0xFB1D..=0xFB4F => FallbackScript::Hebrew,
|
||||
0x0900..=0x0DFF | 0xA8E0..=0xA8FF => FallbackScript::Indic,
|
||||
0x0E00..=0x0E7F => FallbackScript::Thai,
|
||||
0x10A0..=0x10FF | 0x1C90..=0x1CBF | 0x2D00..=0x2D2F => FallbackScript::Georgian,
|
||||
_ => FallbackScript::Other,
|
||||
}
|
||||
}
|
||||
@@ -726,6 +729,7 @@ const INDIC_FAMILIES: &[&str] = &[
|
||||
"Noto Sans Sinhala",
|
||||
];
|
||||
const THAI_FAMILIES: &[&str] = &["Leelawadee UI", "Tahoma", "Thonburi", "Noto Sans Thai", "Noto Sans Thai UI", "Loma", "Garuda"];
|
||||
const GEORGIAN_FAMILIES: &[&str] = &["Sylfaen", ".SF Georgian", "SF Georgian", "Noto Sans Georgian", "DejaVu Sans"];
|
||||
|
||||
/// The CJK locale the host asked fallbacks to prefer, when it has one (see [`set_cjk_locale`]).
|
||||
static REQUESTED_CJK_LOCALE: RwLock<Option<String>> = RwLock::new(None);
|
||||
@@ -753,14 +757,48 @@ fn requested_cjk_locale() -> Option<String> {
|
||||
/// The CJK locale to use: [`set_cjk_locale`] when the host asked for one, otherwise the locale
|
||||
/// environment (`LANG`, `LC_ALL`, `LC_CTYPE`; `EFFECTCRAFT_CJK_LOCALE` overrides them).
|
||||
fn active_cjk_locale() -> String {
|
||||
requested_cjk_locale().unwrap_or_else(|| {
|
||||
["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"]
|
||||
.iter()
|
||||
.filter_map(|k| std::env::var(k).ok())
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.find(|v| !v.is_empty() && v != "c" && v != "posix")
|
||||
.unwrap_or_default()
|
||||
})
|
||||
requested_cjk_locale().unwrap_or_else(environment_cjk_locale)
|
||||
}
|
||||
|
||||
/// Read the environment independently of another UI's process-wide language request.
|
||||
fn environment_cjk_locale() -> String {
|
||||
["EFFECTCRAFT_CJK_LOCALE", "LC_ALL", "LC_CTYPE", "LANG"]
|
||||
.iter()
|
||||
.filter_map(|k| std::env::var(k).ok())
|
||||
.map(|v| v.trim().to_ascii_lowercase())
|
||||
.find(|v| !v.is_empty() && v != "c" && v != "posix")
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The four distinct fallback policies, rather than unbounded user-supplied locale cache keys.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
enum CjkLocale {
|
||||
Japanese,
|
||||
Korean,
|
||||
Simplified,
|
||||
Traditional,
|
||||
}
|
||||
|
||||
impl CjkLocale {
|
||||
fn from_tag(tag: &str) -> Self {
|
||||
let tag = tag.trim().to_ascii_lowercase();
|
||||
if tag.starts_with("zh") {
|
||||
if asks_traditional_chinese(&tag) { Self::Traditional } else { Self::Simplified }
|
||||
} else if tag.starts_with("ko") {
|
||||
Self::Korean
|
||||
} else {
|
||||
Self::Japanese
|
||||
}
|
||||
}
|
||||
|
||||
fn tag(self) -> &'static str {
|
||||
match self {
|
||||
Self::Japanese => "ja",
|
||||
Self::Korean => "ko",
|
||||
Self::Simplified => "zh-hans",
|
||||
Self::Traditional => "zh-hant",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The CJK language to try first for Han ideographs. Japanese when nothing says otherwise: the
|
||||
@@ -778,6 +816,7 @@ fn han_order_for(tag: &str) -> [FallbackScript; 3] {
|
||||
}
|
||||
|
||||
/// The order for the locale in force ([`active_cjk_locale`]).
|
||||
#[cfg(test)]
|
||||
fn han_order() -> [FallbackScript; 3] {
|
||||
han_order_for(&active_cjk_locale())
|
||||
}
|
||||
@@ -796,20 +835,16 @@ fn chinese_families_for(tag: &str) -> &'static [&'static str] {
|
||||
if asks_traditional_chinese(tag) { TRADITIONAL_CHINESE_FAMILIES } else { SIMPLIFIED_CHINESE_FAMILIES }
|
||||
}
|
||||
|
||||
/// [`chinese_families_for`] for the locale in force ([`active_cjk_locale`]).
|
||||
fn chinese_families() -> &'static [&'static str] {
|
||||
chinese_families_for(&active_cjk_locale())
|
||||
}
|
||||
|
||||
fn families_for(s: FallbackScript) -> &'static [&'static str] {
|
||||
fn families_for(s: FallbackScript, locale: CjkLocale) -> &'static [&'static str] {
|
||||
match s {
|
||||
FallbackScript::Japanese => JAPANESE_FAMILIES,
|
||||
FallbackScript::Han => chinese_families(),
|
||||
FallbackScript::Han => chinese_families_for(locale.tag()),
|
||||
FallbackScript::Korean => KOREAN_FAMILIES,
|
||||
FallbackScript::Arabic => ARABIC_FAMILIES,
|
||||
FallbackScript::Hebrew => HEBREW_FAMILIES,
|
||||
FallbackScript::Indic => INDIC_FAMILIES,
|
||||
FallbackScript::Thai => THAI_FAMILIES,
|
||||
FallbackScript::Georgian => GEORGIAN_FAMILIES,
|
||||
FallbackScript::Other => &[],
|
||||
}
|
||||
}
|
||||
@@ -881,9 +916,10 @@ fn fallback_cache() -> &'static RwLock<std::collections::HashMap<(u32, FaceId),
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
/// The results of [`family_faces`], per script and preferred face.
|
||||
fn family_faces_cache() -> &'static RwLock<std::collections::HashMap<(FallbackScript, FaceId), Arc<Vec<FaceId>>>> {
|
||||
static CACHE: OnceLock<RwLock<std::collections::HashMap<(FallbackScript, FaceId), Arc<Vec<FaceId>>>>> = OnceLock::new();
|
||||
/// The results of [`family_faces`], per script, preferred face and captured language policy.
|
||||
/// A lookup that finishes after a language change cannot populate another language's entry.
|
||||
fn family_faces_cache() -> &'static RwLock<std::collections::HashMap<(FallbackScript, FaceId, CjkLocale), Arc<Vec<FaceId>>>> {
|
||||
static CACHE: OnceLock<RwLock<std::collections::HashMap<(FallbackScript, FaceId, CjkLocale), Arc<Vec<FaceId>>>>> = OnceLock::new();
|
||||
CACHE.get_or_init(Default::default)
|
||||
}
|
||||
|
||||
@@ -893,6 +929,27 @@ fn family_faces_cache() -> &'static RwLock<std::collections::HashMap<(FallbackSc
|
||||
/// loaded are tried after that (cheap), and any installed face that covers the character is tried
|
||||
/// last. `prefer` is returned when nothing covers it (the glyph renders as the missing-glyph box).
|
||||
pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
|
||||
fallback_with_locale(c, prefer, || CjkLocale::from_tag(&active_cjk_locale()))
|
||||
}
|
||||
|
||||
/// Choose a fallback for an explicit interface language, independent of concurrent global
|
||||
/// language changes. Empty, `c` and `posix` use the OS environment rather than the global request.
|
||||
pub fn fallback_for_locale(c: char, prefer: FaceId, tag: &str) -> FaceId {
|
||||
let tag = tag.trim();
|
||||
let locale = || {
|
||||
if tag.is_empty() || tag.eq_ignore_ascii_case("c") || tag.eq_ignore_ascii_case("posix") {
|
||||
CjkLocale::from_tag(&environment_cjk_locale())
|
||||
} else {
|
||||
CjkLocale::from_tag(tag)
|
||||
}
|
||||
};
|
||||
fallback_with_locale(c, prefer, locale)
|
||||
}
|
||||
|
||||
/// Keep a single language snapshot through the family-order lookup and cache insertion. `locale`
|
||||
/// is read only for a script with usual families: reading it takes a lock and may consult the
|
||||
/// environment, and layout asks for a fallback for every character.
|
||||
fn fallback_with_locale(c: char, prefer: FaceId, locale: impl FnOnce() -> CjkLocale) -> FaceId {
|
||||
let p = face(prefer);
|
||||
if p.has_char(c) || c.is_control() {
|
||||
return prefer;
|
||||
@@ -913,7 +970,7 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
|
||||
if cfg!(not(target_arch = "wasm32")) && !system_scanned() {
|
||||
scan_system();
|
||||
}
|
||||
if let Some(&id) = family_faces(script, &p).iter().find(|&&id| face(id).has_char(c)) {
|
||||
if let Some(&id) = family_faces(script, &p, locale()).iter().find(|&&id| face(id).has_char(c)) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
@@ -942,23 +999,24 @@ pub fn fallback_for(c: char, prefer: FaceId) -> FaceId {
|
||||
/// The script-aware part of [`fallback_for`]: the installed faces of the platform's usual families
|
||||
/// for `script`, in the order they are tried, each in the style of `prefer`. For CJK, the other CJK
|
||||
/// languages' families follow in the locale's order.
|
||||
fn family_faces(script: FallbackScript, prefer: &Face) -> Arc<Vec<FaceId>> {
|
||||
if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id)) {
|
||||
fn family_faces(script: FallbackScript, prefer: &Face, locale: CjkLocale) -> Arc<Vec<FaceId>> {
|
||||
if let Some(faces) = family_faces_cache().read().unwrap_or_else(|e| e.into_inner()).get(&(script, prefer.id, locale)) {
|
||||
return faces.clone();
|
||||
}
|
||||
let order: Vec<FallbackScript> = match script {
|
||||
FallbackScript::Other => vec![],
|
||||
FallbackScript::Han => han_order().to_vec(),
|
||||
FallbackScript::Han => han_order_for(locale.tag()).to_vec(),
|
||||
s if s.is_cjk() => {
|
||||
let mut v = vec![s];
|
||||
v.extend(han_order().iter().copied().filter(|o| *o != s));
|
||||
v.extend(han_order_for(locale.tag()).iter().copied().filter(|o| *o != s));
|
||||
v
|
||||
}
|
||||
s => vec![s],
|
||||
};
|
||||
let style = &prefer.info.style;
|
||||
let faces: Arc<Vec<FaceId>> = Arc::new(order.into_iter().flat_map(families_for).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect());
|
||||
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id), faces.clone());
|
||||
let faces: Arc<Vec<FaceId>> =
|
||||
Arc::new(order.into_iter().flat_map(|s| families_for(s, locale)).filter_map(|fam| resolve_in(fam, style)).map(|r| r.face).collect());
|
||||
family_faces_cache().write().unwrap_or_else(|e| e.into_inner()).insert((script, prefer.id, locale), faces.clone());
|
||||
faces
|
||||
}
|
||||
|
||||
@@ -975,6 +1033,77 @@ fn system_fallback(c: char, style: &str) -> Option<FaceId> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Concurrent language requests must neither choose another context's font nor reuse its
|
||||
/// cached family order. Synthetic fonts in a child process keep this independent of the host
|
||||
/// font installation and of other tests that exercise the process-global database/locale.
|
||||
#[test]
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn cjk_requests_keep_their_language_and_cache_entries() {
|
||||
const CHILD: &str = "EFFECTCRAFT_TEST_ISOLATED_CJK";
|
||||
if std::env::var_os(CHILD).is_none() {
|
||||
let output = std::process::Command::new(std::env::current_exe().unwrap())
|
||||
.args(["--exact", "fonts::tests::cjk_requests_keep_their_language_and_cache_entries", "--nocapture"])
|
||||
.env(CHILD, "1")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
|
||||
return;
|
||||
}
|
||||
let families = [("ja", "Yu Gothic"), ("zh-hans", "Microsoft YaHei"), ("zh-hant", "Microsoft JhengHei"), ("ko", "Malgun Gothic")];
|
||||
let mut expected = Vec::new();
|
||||
{
|
||||
let mut d = db().write().unwrap();
|
||||
d.scanned = true; // This test process uses only bundled and synthetic fonts.
|
||||
for (language, family) in families {
|
||||
let mut data = test_font(family);
|
||||
let table = data[12..12 + 8 * 16].as_chunks::<16>().0.iter().find(|t| &t[..4] == b"cmap").unwrap();
|
||||
let cmap = u32::from_be_bytes(table[8..12].try_into().unwrap()) as usize + 12;
|
||||
// Map 页 to the fixture's triangle glyph instead of its private-use range.
|
||||
// All four fonts cover the probe, so coverage cannot choose the right language.
|
||||
for (offset, value) in [(16, '页' as u16), (24, '页' as u16), (30, 2u16.wrapping_sub('页' as u16))] {
|
||||
data[cmap + offset..cmap + offset + 2].copy_from_slice(&value.to_be_bytes());
|
||||
}
|
||||
let data = Arc::new(data);
|
||||
let names = read_faces_bytes(&data).into_iter().next().unwrap();
|
||||
let id = push(&mut d, info_from(names, FaceData::Shared(data), "test"));
|
||||
expected.push((language, id));
|
||||
}
|
||||
}
|
||||
let base = resolve("Inter", "Regular").face;
|
||||
for &(_, id) in &expected {
|
||||
assert!(face(id).has_char('页'));
|
||||
}
|
||||
// Force the original interleaving: Chinese starts, then Japanese replaces the global
|
||||
// language before Chinese looks up its probe. The explicit request must still win.
|
||||
set_cjk_locale("zh-hans");
|
||||
set_cjk_locale("ja");
|
||||
assert_eq!(fallback_for('页', base), expected[0].1);
|
||||
assert_eq!(fallback_for_locale('页', base, "zh-hans"), expected[1].1);
|
||||
// Do not clear the cache here: different policies must coexist in the same cache.
|
||||
for _ in 0..3 {
|
||||
for &(language, id) in &expected {
|
||||
assert_eq!(fallback_for_locale('页', base, language), id, "{language}");
|
||||
}
|
||||
}
|
||||
let barrier = Arc::new(std::sync::Barrier::new(expected.len()));
|
||||
let threads: Vec<_> = expected
|
||||
.into_iter()
|
||||
.map(|(language, id)| {
|
||||
let barrier = barrier.clone();
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
for _ in 0..128 {
|
||||
set_cjk_locale(if language == "ja" { "zh-hans" } else { "ja" });
|
||||
assert_eq!(fallback_for_locale('页', base, language), id, "{language}");
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for thread in threads {
|
||||
thread.join().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extra_font_dirs_come_from_a_path_list() {
|
||||
assert!(extra_dirs(None).is_empty());
|
||||
@@ -1122,6 +1251,8 @@ mod tests {
|
||||
assert_eq!(script_of('क'), FallbackScript::Indic);
|
||||
assert_eq!(script_of('த'), FallbackScript::Indic);
|
||||
assert_eq!(script_of('ก'), FallbackScript::Thai);
|
||||
assert_eq!(script_of('ა'), FallbackScript::Georgian);
|
||||
assert_eq!(script_of('\u{1c90}'), FallbackScript::Georgian, "Mtavruli");
|
||||
assert_eq!(script_of('\u{feff}'), FallbackScript::Other, "the byte order mark is no Arabic letter");
|
||||
}
|
||||
|
||||
@@ -1145,6 +1276,22 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Native: Mkhedruli in a Latin-only family falls back to a family from the Georgian list,
|
||||
/// ahead of faces already loaded and of the scan order. Skipped where no family from the list
|
||||
/// is installed.
|
||||
#[test]
|
||||
fn georgian_falls_back_to_a_georgian_system_family() {
|
||||
scan_system();
|
||||
let c = 'ა';
|
||||
if !GEORGIAN_FAMILIES.iter().any(|n| resolve_in(n, "Regular").is_some_and(|r| face(r.face).has_char(c))) {
|
||||
eprintln!("no Georgian family from the list is installed; skipping");
|
||||
return;
|
||||
}
|
||||
let f = face(fallback_for(c, resolve("Inter", "Regular").face));
|
||||
assert!(f.has_char(c), "{} lacks it", f.info.family);
|
||||
assert!(GEORGIAN_FAMILIES.iter().any(|n| f.info.family.eq_ignore_ascii_case(n)), "{c} → {}", f.info.family);
|
||||
}
|
||||
|
||||
/// Native: Japanese text in a Latin-only family falls back to an installed Japanese family
|
||||
/// (whichever the platform ships) instead of the missing-glyph box, without any layer having
|
||||
/// loaded a CJK font first. Skipped where no family from the list is installed.
|
||||
@@ -1302,11 +1449,35 @@ mod tests {
|
||||
assert!(!TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains(" SC") || f.contains("YaHei") || f.contains("WenQuanYi")));
|
||||
assert!(TRADITIONAL_CHINESE_FAMILIES.iter().any(|f| f.contains("Heiti TC") || f.contains("Hiragino Sans CNS")));
|
||||
// The arm the fallback itself walks: Han hands over the list the locale's script picked.
|
||||
assert_eq!(families_for(FallbackScript::Han), chinese_families_for(&active_cjk_locale()));
|
||||
assert_eq!(families_for(FallbackScript::Han, CjkLocale::from_tag(&active_cjk_locale())), chinese_families_for(&active_cjk_locale()));
|
||||
// Han text is the only thing these lists serve; the Japanese order does not change.
|
||||
assert_eq!(han_order_for("ja_JP.UTF-8")[0], FallbackScript::Japanese);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_rendered_order_keeps_the_print_face_first() {
|
||||
// A composed frame renders with these lists, so they keep the platform's print order: the
|
||||
// interface prefers the platform's UI faces on its own (see `install_script_fallbacks` in
|
||||
// the ui crate), and an existing project must not render differently because a window's
|
||||
// chrome changed.
|
||||
let pos = |list: &[&str], name: &str| list.iter().position(|f| *f == name).unwrap_or(usize::MAX);
|
||||
assert!(
|
||||
pos(JAPANESE_FAMILIES, "Yu Gothic") < pos(JAPANESE_FAMILIES, "Yu Gothic UI"),
|
||||
"the print face must come before its UI sibling: {JAPANESE_FAMILIES:?}"
|
||||
);
|
||||
for name in ["Meiryo UI", "MS UI Gothic"] {
|
||||
assert!(!JAPANESE_FAMILIES.contains(&name), "{name} belongs to the interface order, not this list");
|
||||
}
|
||||
assert_eq!(SIMPLIFIED_CHINESE_FAMILIES[0], "Microsoft YaHei");
|
||||
assert_eq!(TRADITIONAL_CHINESE_FAMILIES[0], "Microsoft JhengHei");
|
||||
for name in ["Microsoft YaHei UI", "Microsoft JhengHei UI", "PMingLiU"] {
|
||||
assert!(
|
||||
!SIMPLIFIED_CHINESE_FAMILIES.contains(&name) && !TRADITIONAL_CHINESE_FAMILIES.contains(&name),
|
||||
"{name} belongs to the interface order, not these lists"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_ui_fonts_cover_the_ukrainian_alphabet() {
|
||||
const ALPHABET: &str = "АБВГҐДЕЄЖЗИІЇЙКЛМНОПРСТУФХЦЧШЩЬЮЯабвгґдеєжзиіїйклмнопрстуфхцчшщьюя’«»";
|
||||
|
||||
@@ -41,8 +41,8 @@ impl EffectcraftApp {
|
||||
}
|
||||
let Some(comp) = self.session.active_comp() else { return out };
|
||||
if let Some(l) = self.session.state.selected_layers.first().and_then(|id| comp.layer(*id)) {
|
||||
out.push((PanelKind::EffectControls, format!("Effect Controls {}", l.name)));
|
||||
out.push((PanelKind::Properties, format!("Properties: {}", l.name)));
|
||||
out.push((PanelKind::EffectControls, format!("{} {}", crate::i18n::command_label("window.panel", "Effect Controls"), l.name)));
|
||||
out.push((PanelKind::Properties, format!("{}: {}", crate::i18n::command_label("window.panel", "Properties"), l.name)));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -206,7 +206,9 @@ impl EffectcraftApp {
|
||||
let titles = self.tab_titles();
|
||||
let decos = self.tab_decos();
|
||||
let docs = self.tab_docs();
|
||||
let title = |p: PanelKind| titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| p.title().to_string());
|
||||
let title = |p: PanelKind| {
|
||||
titles.iter().find(|(k, _)| *k == p).map(|(_, s)| s.clone()).unwrap_or_else(|| crate::i18n::command_label("window.panel", p.title()).to_string())
|
||||
};
|
||||
let info = dock::TabInfo { title: &title, decos: &decos, docs: &docs };
|
||||
// Where each group's tabs are (tab drags show where a tab will land between them).
|
||||
let mut tab_rects: Vec<(String, Vec<(PanelKind, Rect)>)> = vec![];
|
||||
@@ -570,7 +572,7 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
// Ghost of the dragged tab.
|
||||
let g = painter.layout_no_wrap(p.title().to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
|
||||
let g = painter.layout_no_wrap(crate::i18n::command_label("window.panel", p.title()).to_string(), crate::theme::Tokens::ui(12.0), Color32::WHITE);
|
||||
let gr = Rect::from_min_size(pos + vec2(12.0, 10.0), g.size() + vec2(16.0, 8.0));
|
||||
painter.rect_filled(gr, 4.0, t.focus.gamma_multiply(0.85));
|
||||
painter.galley(gr.min + vec2(8.0, 4.0), g, Color32::WHITE);
|
||||
|
||||
+237
-42
@@ -24,7 +24,7 @@
|
||||
//! them (Info panel, eyedroppers, histograms).
|
||||
|
||||
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
|
||||
@@ -152,6 +152,8 @@ struct Cache {
|
||||
gpu_budget: usize,
|
||||
/// The playing preview, if any: evictions spare the frames it shows next.
|
||||
playhead: Option<Playhead>,
|
||||
/// Scale of the frame on screen (the newest urgent request): frames of other scales go first.
|
||||
scale: Option<u32>,
|
||||
}
|
||||
|
||||
impl Cache {
|
||||
@@ -173,8 +175,14 @@ impl Cache {
|
||||
/// recently shown of the frames it doesn't reach, then the one it reaches last. Plain LRU
|
||||
/// evicted the frames just ahead of the playhead first (rendered a while ago, not shown yet)
|
||||
/// to keep the ones just shown, so a preview longer than the cache played into a gap that
|
||||
/// prefetch kept refilling while it evicted the next frames (#595).
|
||||
/// prefetch kept refilling while it evicted the next frames (#595). Frames of another scale
|
||||
/// than the one on screen go first (a zoom or resolution change).
|
||||
fn victim(&self) -> Option<usize> {
|
||||
if let Some(s) = self.scale
|
||||
&& let Some(i) = self.order.iter().position(|k| k.scale != s)
|
||||
{
|
||||
return Some(i);
|
||||
}
|
||||
let Some(p) = &self.playhead else { return (!self.order.is_empty()).then_some(0) };
|
||||
let mut farthest: Option<(i64, usize)> = None;
|
||||
for (i, k) in self.order.iter().enumerate() {
|
||||
@@ -186,10 +194,13 @@ impl Cache {
|
||||
}
|
||||
farthest.map(|(_, i)| i)
|
||||
}
|
||||
/// Video memory: drop the oldest GPU frames past their own budget.
|
||||
/// Video memory: drop the oldest GPU frames past their own budget, frames of another scale
|
||||
/// than the one on screen first (a zoom or resolution change).
|
||||
fn evict_gpu(&mut self) {
|
||||
while self.gpu_bytes > self.gpu_budget {
|
||||
let Some(i) = self.order.iter().position(|k| self.map.get(k).is_some_and(FrameImage::is_gpu)) else { break };
|
||||
let is_gpu = |k: &FrameKey| self.map.get(k).is_some_and(FrameImage::is_gpu);
|
||||
let other_scale = self.order.iter().position(|k| Some(k.scale) != self.scale && is_gpu(k));
|
||||
let Some(i) = other_scale.or_else(|| self.order.iter().position(is_gpu)) else { break };
|
||||
if let Some(old) = self.order.remove(i) {
|
||||
self.remove(&old);
|
||||
}
|
||||
@@ -258,8 +269,10 @@ pub struct RenderSource {
|
||||
/// ([`Frames::identity`]).
|
||||
type Identity = (u64, Option<Arc<Project>>);
|
||||
|
||||
/// Content keys of (project revision, comp), computed once per revision.
|
||||
type ContentKeys = Arc<Mutex<HashMap<(u64, u64), u128>>>;
|
||||
/// Content keys of (project revision, comp, virtual-file generation). The generation is one
|
||||
/// counter bumped when a browser file is replaced, so a lookup stays a map read. Hashing the
|
||||
/// new bytes happens only when that generation misses.
|
||||
type ContentKeys = Arc<Mutex<HashMap<(u64, u64, u64), u128>>>;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[derive(Default)]
|
||||
@@ -268,16 +281,16 @@ struct ContentKeyJobs {
|
||||
pending: VecDeque<(Arc<Project>, u64, u64)>,
|
||||
}
|
||||
|
||||
fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64) -> u128 {
|
||||
if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp)).copied()) {
|
||||
fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64, epoch: u64, virtual_id: Option<disk_cache::VirtualFileId<'_>>) -> u128 {
|
||||
if let Some(k) = keys.lock().ok().and_then(|m| m.get(&(revision, comp, epoch)).copied()) {
|
||||
return k;
|
||||
}
|
||||
let k = disk_cache::comp_content_key(project, ItemId(comp));
|
||||
let k = disk_cache::comp_content_key_with(project, ItemId(comp), virtual_id);
|
||||
if let Ok(mut m) = keys.lock() {
|
||||
if m.len() > 256 {
|
||||
m.clear();
|
||||
}
|
||||
m.insert((revision, comp), k);
|
||||
m.insert((revision, comp, epoch), k);
|
||||
}
|
||||
k
|
||||
}
|
||||
@@ -363,8 +376,15 @@ pub fn opts_hash(o: &RenderOpts) -> u64 {
|
||||
}
|
||||
|
||||
/// Disk key of a viewer frame (render options included: region of interest, draft…).
|
||||
fn frame_disk_key(keys: &ContentKeys, project: &Project, key: &FrameKey, opts_hash: u64) -> u128 {
|
||||
let content = content_key(keys, project, key.revision, key.comp);
|
||||
fn frame_disk_key(
|
||||
keys: &ContentKeys,
|
||||
project: &Project,
|
||||
key: &FrameKey,
|
||||
opts_hash: u64,
|
||||
epoch: u64,
|
||||
virtual_id: Option<disk_cache::VirtualFileId<'_>>,
|
||||
) -> u128 {
|
||||
let content = content_key(keys, project, key.revision, key.comp, epoch, virtual_id);
|
||||
disk_cache::frame_key(content, key.frame, key.scale, key.view ^ opts_hash)
|
||||
}
|
||||
|
||||
@@ -441,6 +461,10 @@ pub struct Frames {
|
||||
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
|
||||
pub last_ms: Arc<Mutex<f64>>,
|
||||
content_keys: ContentKeys,
|
||||
/// Bumped when a virtual file is replaced. Lookups key on it instead of statting footage.
|
||||
virtual_epoch: Arc<AtomicU64>,
|
||||
/// Size and content hash for paths with no native metadata. Called only on a cache miss.
|
||||
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
content_key_jobs: Arc<Mutex<ContentKeyJobs>>,
|
||||
gpu_retired: Arc<AtomicBool>,
|
||||
@@ -462,7 +486,7 @@ impl Default for Frames {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let threads = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(4).clamp(2, 16);
|
||||
Frames {
|
||||
cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None })),
|
||||
cache: Arc::new(Mutex::new(Cache { map: HashMap::new(), order: VecDeque::new(), bytes: 0, gpu_bytes: 0, budget: 3 << 30, gpu_budget: GPU_BUDGET, playhead: None, scale: None })),
|
||||
inflight: Arc::new(Mutex::new(HashSet::new())),
|
||||
queue: Arc::new(Mutex::new(Queue::default())),
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -477,6 +501,8 @@ impl Default for Frames {
|
||||
ctx: None,
|
||||
last_ms: Arc::new(Mutex::new(0.0)),
|
||||
content_keys: Arc::default(),
|
||||
virtual_epoch: Arc::new(AtomicU64::new(0)),
|
||||
virtual_id: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
content_key_jobs: Arc::default(),
|
||||
gpu_retired: Arc::default(),
|
||||
@@ -758,7 +784,12 @@ impl Frames {
|
||||
/// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar.
|
||||
/// `opts` are the viewer's render options (part of the disk key). Native queries return
|
||||
/// no blue frames until the content key is ready; hashing never blocks timeline painting.
|
||||
pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec<i64> {
|
||||
pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, range: std::ops::Range<i64>, opts: &RenderOpts) -> Vec<i64> {
|
||||
let start = range.start.clamp(0, 100_000);
|
||||
let end = range.end.clamp(0, 100_000);
|
||||
if start >= end {
|
||||
return vec![];
|
||||
}
|
||||
let remote = self.remote.as_ref().filter(|r| r.disk());
|
||||
let contains = |k: u128| match (&src.disk, remote) {
|
||||
(Some(dc), _) => dc.contains(disk_cache::Kind::Frame, k),
|
||||
@@ -768,9 +799,10 @@ impl Frames {
|
||||
if src.disk.is_none() && remote.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let epoch = self.virtual_epoch.load(Ordering::Relaxed);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let content = {
|
||||
let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp)).copied();
|
||||
let known = self.content_keys.lock().unwrap_or_else(std::sync::PoisonError::into_inner).get(&(base.revision, base.comp, epoch)).copied();
|
||||
let Some(content) = known else {
|
||||
self.request_content_key(src.project.clone(), base.revision, base.comp);
|
||||
return vec![];
|
||||
@@ -778,13 +810,10 @@ impl Frames {
|
||||
content
|
||||
};
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let content = content_key(&self.content_keys, &src.project, base.revision, base.comp);
|
||||
let content = content_key(&self.content_keys, &src.project, base.revision, base.comp, epoch, self.virtual_id.as_deref());
|
||||
let ram: HashSet<i64> = self.cached_frames(base).into_iter().collect();
|
||||
let oh = opts_hash(opts);
|
||||
(0..frames.clamp(0, 100_000))
|
||||
.filter(|f| !ram.contains(f))
|
||||
.filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh)))
|
||||
.collect()
|
||||
(start..end).filter(|f| !ram.contains(f)).filter(|f| contains(disk_cache::frame_key(content, *f, base.scale, base.view ^ oh))).collect()
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -803,6 +832,7 @@ impl Frames {
|
||||
jobs.running = true;
|
||||
drop(jobs);
|
||||
let (jobs, keys, ctx) = (self.content_key_jobs.clone(), self.content_keys.clone(), self.ctx.clone());
|
||||
let (epoch, id) = (self.virtual_epoch.clone(), self.virtual_id.clone());
|
||||
let work = move || loop {
|
||||
let next = {
|
||||
let mut jobs = jobs.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
@@ -813,7 +843,9 @@ impl Frames {
|
||||
next
|
||||
};
|
||||
let Some((project, revision, comp)) = next else { return };
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp))).is_err() {
|
||||
let epoch = epoch.load(Ordering::Relaxed);
|
||||
let id = id.clone();
|
||||
if std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| content_key(&keys, &project, revision, comp, epoch, id.as_deref()))).is_err() {
|
||||
log::error!("could not compute a preview disk-cache key; the cache indicator will retry");
|
||||
}
|
||||
if let Some(ctx) = &ctx {
|
||||
@@ -893,7 +925,19 @@ impl Frames {
|
||||
}
|
||||
|
||||
fn request_with(&self, src: &RenderSource, key: FrameKey, comp: ItemId, t: Tick, opts: RenderOpts, urgent: bool) {
|
||||
if self.is_cached(&key) {
|
||||
// The frame on screen sets the scale evictions keep: a prefetch or a passive viewer at
|
||||
// another zoom must not evict the active viewer's frames.
|
||||
let cached = self
|
||||
.cache
|
||||
.lock()
|
||||
.map(|mut c| {
|
||||
if urgent {
|
||||
c.scale = Some(key.scale);
|
||||
}
|
||||
c.map.contains_key(&key)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if cached {
|
||||
return;
|
||||
}
|
||||
let Ok(mut q) = self.queue.lock() else { return };
|
||||
@@ -964,10 +1008,22 @@ impl Frames {
|
||||
ctx: self.ctx.clone(),
|
||||
last: self.last_ms.clone(),
|
||||
content_keys: self.content_keys.clone(),
|
||||
virtual_epoch: self.virtual_epoch.clone(),
|
||||
virtual_id: self.virtual_id.clone(),
|
||||
gpu_retired: self.gpu_retired.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Browser file table: `epoch` bumps on each replacement, and `id` is the size and content
|
||||
/// hash used when a content key is rebuilt. Neither runs on a cache hit.
|
||||
pub fn set_virtual_media(&mut self, epoch: Arc<AtomicU64>, id: Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>) {
|
||||
self.virtual_epoch = epoch;
|
||||
self.virtual_id = Some(id);
|
||||
if let Ok(mut keys) = self.content_keys.lock() {
|
||||
keys.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Render frames with `remote` from now on (`None`: on this thread again).
|
||||
pub fn set_remote(&mut self, remote: Option<Arc<dyn RemoteFrames>>) {
|
||||
self.remote = remote;
|
||||
@@ -1036,7 +1092,8 @@ impl Frames {
|
||||
let fw = self.worker();
|
||||
let busy = self.remote_busy.clone();
|
||||
let t0 = web_time::Instant::now();
|
||||
let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts)));
|
||||
let epoch = w.virtual_epoch.load(Ordering::Relaxed);
|
||||
let disk_key = remote.disk().then(|| frame_disk_key(&w.content_keys, &job.src.project, &key, opts_hash(&job.opts), epoch, w.virtual_id.as_deref()));
|
||||
let rj = RemoteJob { project: job.src.project.clone(), revision: key.revision, comp: job.comp, t: job.t, opts: job.opts, disk_key };
|
||||
remote.start(
|
||||
rj,
|
||||
@@ -1093,6 +1150,8 @@ struct Worker {
|
||||
ctx: Option<egui::Context>,
|
||||
last: Arc<Mutex<f64>>,
|
||||
content_keys: ContentKeys,
|
||||
virtual_epoch: Arc<AtomicU64>,
|
||||
virtual_id: Option<Arc<dyn Fn(&str) -> Option<(u64, u64)> + Send + Sync>>,
|
||||
gpu_retired: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
@@ -1147,7 +1206,12 @@ impl Worker {
|
||||
job
|
||||
};
|
||||
let t0 = web_time::Instant::now();
|
||||
let disk = job.src.disk.as_ref().map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts))));
|
||||
let epoch = self.virtual_epoch.load(Ordering::Relaxed);
|
||||
let disk = job
|
||||
.src
|
||||
.disk
|
||||
.as_ref()
|
||||
.map(|dc| (dc.clone(), frame_disk_key(content_keys, &job.src.project, &job.key, opts_hash(&job.opts), epoch, self.virtual_id.as_deref())));
|
||||
let from_disk = disk.as_ref().and_then(|(dc, k)| dc.get_frame(*k));
|
||||
let disk_hit = from_disk.is_some();
|
||||
let ci = match from_disk {
|
||||
@@ -1375,6 +1439,54 @@ impl Queue {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
struct ProbeDisk {
|
||||
keys: Mutex<HashSet<u128>>,
|
||||
probes: std::sync::atomic::AtomicUsize,
|
||||
}
|
||||
|
||||
impl RemoteFrames for ProbeDisk {
|
||||
fn slots(&self) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
fn disk(&self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn disk_contains(&self, key: u128) -> bool {
|
||||
self.probes.fetch_add(1, Ordering::Relaxed);
|
||||
self.keys.lock().unwrap().contains(&key)
|
||||
}
|
||||
|
||||
fn start(&self, _: RemoteJob, _: RemoteDone) {}
|
||||
}
|
||||
|
||||
fn disk_fixture(stored_frames: &[i64]) -> (Frames, RenderSource, FrameKey, RenderOpts, Arc<ProbeDisk>, u128) {
|
||||
let mut session = effectcraft_engine::Session::default();
|
||||
session.execute("comp.new", serde_json::json!({"width": 32, "height": 18, "duration": 1})).unwrap();
|
||||
let comp = session.active_comp_id().unwrap();
|
||||
let opts = RenderOpts::default();
|
||||
let content = 1234;
|
||||
let mut base = key(1, 0, opts_hash(&opts));
|
||||
base.revision = 7;
|
||||
base.comp = comp.0;
|
||||
let disk = Arc::new(ProbeDisk { keys: Mutex::new(HashSet::new()), probes: 0.into() });
|
||||
disk.keys.lock().unwrap().extend(stored_frames.iter().map(|frame| disk_cache::frame_key(content, *frame, base.scale, base.view ^ opts_hash(&opts))));
|
||||
let mut frames = Frames::default();
|
||||
frames.set_remote(Some(disk.clone()));
|
||||
frames.content_keys.lock().unwrap().insert((base.revision, base.comp, 0), content);
|
||||
let source = RenderSource {
|
||||
project: session.project.clone(),
|
||||
footage: session.footage.clone(),
|
||||
expr: None,
|
||||
layer_cache: Arc::default(),
|
||||
gpu: None,
|
||||
gpu_display: false,
|
||||
disk: None,
|
||||
};
|
||||
(frames, source, base, opts, disk, content)
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[test]
|
||||
fn disk_indicator_does_not_hash_on_ui_thread_and_coalesces_edits() {
|
||||
@@ -1422,20 +1534,65 @@ mod tests {
|
||||
base.comp = cid.0;
|
||||
for revision in 1..50 {
|
||||
base.revision = revision;
|
||||
assert!(frames.disk_frames(&src, &base, 30, &opts).is_empty());
|
||||
assert!(frames.disk_frames(&src, &base, 0..30, &opts).is_empty());
|
||||
}
|
||||
assert!(frames.content_keys.lock().unwrap().is_empty(), "painting computed a content hash while the worker was blocked");
|
||||
assert_eq!(frames.content_key_jobs.lock().unwrap().pending.len(), 1);
|
||||
release.send(()).unwrap();
|
||||
let until = Instant::now() + Duration::from_secs(3);
|
||||
while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp)) {
|
||||
while !frames.content_keys.lock().unwrap().contains_key(&(base.revision, base.comp, 0)) {
|
||||
assert!(Instant::now() < until);
|
||||
std::thread::sleep(Duration::from_millis(1));
|
||||
}
|
||||
assert_eq!(frames.disk_frames(&src, &base, 30, &opts), vec![2], "the blue indicator must return once its key is ready");
|
||||
assert_eq!(frames.disk_frames(&src, &base, 0..30, &opts), vec![2], "the blue indicator must return once its key is ready");
|
||||
assert_eq!(frames.content_keys.lock().unwrap().len(), 1, "superseded revisions were hashed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_indicator_range_tracks_live_membership() {
|
||||
let (frames, src, base, opts, disk, _) = disk_fixture(&[10, 12]);
|
||||
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
|
||||
assert_eq!(disk.probes.load(Ordering::Relaxed), 3);
|
||||
|
||||
disk.keys.lock().unwrap().insert(disk_cache::frame_key(1234, 11, base.scale, base.view ^ opts_hash(&opts)));
|
||||
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 11, 12]);
|
||||
|
||||
insert(&frames, FrameKey { frame: 11, ..base });
|
||||
disk.probes.store(0, Ordering::Relaxed);
|
||||
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10, 12]);
|
||||
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
|
||||
|
||||
disk.keys.lock().unwrap().remove(&disk_cache::frame_key(1234, 12, base.scale, base.view ^ opts_hash(&opts)));
|
||||
assert_eq!(frames.disk_frames(&src, &base, 10..13, &opts), vec![10]);
|
||||
|
||||
let mut revised = base;
|
||||
revised.revision += 1;
|
||||
frames.content_keys.lock().unwrap().insert((revised.revision, revised.comp, 0), 5678);
|
||||
assert!(frames.disk_frames(&src, &revised, 10..13, &opts).is_empty());
|
||||
|
||||
let changed = FrameKey { scale: base.scale + 1, ..base };
|
||||
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
|
||||
let changed = FrameKey { view: base.view + 1, ..base };
|
||||
assert!(frames.disk_frames(&src, &changed, 10..13, &opts).is_empty());
|
||||
assert!(frames.disk_frames(&src, &base, 10..13, &RenderOpts { draft: true, ..opts }).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_indicator_range_is_bounded() {
|
||||
let (frames, src, base, opts, disk, _) = disk_fixture(&[0, 1, 99_999]);
|
||||
assert_eq!(frames.disk_frames(&src, &base, -5..2, &opts), vec![0, 1]);
|
||||
assert_eq!(disk.probes.load(Ordering::Relaxed), 2);
|
||||
disk.probes.store(0, Ordering::Relaxed);
|
||||
assert_eq!(frames.disk_frames(&src, &base, 99_999..i64::MAX, &opts), vec![99_999]);
|
||||
assert_eq!(disk.probes.load(Ordering::Relaxed), 1);
|
||||
disk.probes.store(0, Ordering::Relaxed);
|
||||
assert!(frames.disk_frames(&src, &base, 2..2, &opts).is_empty());
|
||||
// Written as a struct literal: `3..2` trips clippy::reversed_empty_ranges.
|
||||
let reversed = std::ops::Range { start: 3, end: 2 };
|
||||
assert!(frames.disk_frames(&src, &base, reversed, &opts).is_empty());
|
||||
assert_eq!(disk.probes.load(Ordering::Relaxed), 0);
|
||||
}
|
||||
|
||||
/// Frames larger than the texture limit are averaged down by a whole factor (#201: an
|
||||
/// 11000×2200 comp at Full resolution against an 8192 limit).
|
||||
#[test]
|
||||
@@ -1795,21 +1952,17 @@ mod tests {
|
||||
assert!(!f.is_cached(&key(1, 13, 0)) && f.is_cached(&key(1, 50, 0)));
|
||||
}
|
||||
|
||||
/// #595: prefetch renders the frames the preview reaches first, whatever order they were
|
||||
/// asked for in (a frame evicted and asked for again waited behind every frame further
|
||||
/// ahead); queued frames the preview no longer reaches are dropped and not left in flight.
|
||||
#[test]
|
||||
fn prefetch_renders_the_frames_the_playhead_reaches_first() {
|
||||
struct Idle;
|
||||
impl RemoteFrames for Idle {
|
||||
fn slots(&self) -> usize {
|
||||
0
|
||||
}
|
||||
fn start(&self, _: RemoteJob, _: RemoteDone) {}
|
||||
/// A frame source that queues jobs and never renders them: the tests check the queue.
|
||||
struct Idle;
|
||||
impl RemoteFrames for Idle {
|
||||
fn slots(&self) -> usize {
|
||||
0
|
||||
}
|
||||
let mut f = Frames::default();
|
||||
f.set_remote(Some(Arc::new(Idle)));
|
||||
let src = RenderSource {
|
||||
fn start(&self, _: RemoteJob, _: RemoteDone) {}
|
||||
}
|
||||
|
||||
fn idle_source() -> RenderSource {
|
||||
RenderSource {
|
||||
project: Arc::default(),
|
||||
footage: Arc::new(effectcraft_engine::render::NoFootage),
|
||||
expr: None,
|
||||
@@ -1817,7 +1970,17 @@ mod tests {
|
||||
gpu: None,
|
||||
gpu_display: false,
|
||||
disk: None,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// #595: prefetch renders the frames the preview reaches first, whatever order they were
|
||||
/// asked for in (a frame evicted and asked for again waited behind every frame further
|
||||
/// ahead); queued frames the preview no longer reaches are dropped and not left in flight.
|
||||
#[test]
|
||||
fn prefetch_renders_the_frames_the_playhead_reaches_first() {
|
||||
let mut f = Frames::default();
|
||||
f.set_remote(Some(Arc::new(Idle)));
|
||||
let src = idle_source();
|
||||
for frame in [40, 30, 20, 5, 12, 11] {
|
||||
f.request(&src, key(1, frame, 0), ItemId(1), Tick::ZERO, RenderOpts::default());
|
||||
}
|
||||
@@ -1834,6 +1997,38 @@ mod tests {
|
||||
assert_eq!(f.inflight(), 6, "frame 5, behind the playhead, is no longer in flight");
|
||||
}
|
||||
|
||||
/// The cache is full. A zoom (or preview-resolution) change left the frames of the old
|
||||
/// size to the plain least-recently-shown order, so the frames of the zoom the viewer is back
|
||||
/// at went before the ones of the abandoned zoom. The other size's frames go first, then the
|
||||
/// order is as before.
|
||||
#[test]
|
||||
fn a_size_change_evicts_frames_of_other_sizes_first() {
|
||||
let mut f = Frames::default();
|
||||
f.set_remote(Some(Arc::new(Idle)));
|
||||
f.set_budget(4 * 16);
|
||||
let at = |frame, scale| FrameKey { scale, ..key(1, frame, 0) };
|
||||
// 100% (frames 0..=1) is cached, then 50% (2..=3): the 100% frames are the oldest.
|
||||
for (frame, scale) in [(0, 1000), (1, 1000), (2, 500), (3, 500)] {
|
||||
insert(&f, at(frame, scale));
|
||||
}
|
||||
// A passive viewer at 50% asks for a frame: that doesn't change which size is kept.
|
||||
f.request(&idle_source(), at(9, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
|
||||
// Back at 100%: the viewer asks for frame 4.
|
||||
f.request_urgent(&idle_source(), at(4, 1000), ItemId(1), Tick::ZERO, RenderOpts::default());
|
||||
f.request(&idle_source(), at(10, 500), ItemId(1), Tick::ZERO, RenderOpts::default());
|
||||
insert(&f, at(4, 1000));
|
||||
assert!(
|
||||
f.is_cached(&at(0, 1000)) && f.is_cached(&at(1, 1000)) && !f.is_cached(&at(2, 500)) && f.is_cached(&at(3, 500)),
|
||||
"the first frame of the old size goes, not the cached ones of the current size"
|
||||
);
|
||||
insert(&f, at(5, 1000));
|
||||
assert!(!f.is_cached(&at(3, 500)), "then the last frame of the old size");
|
||||
assert_eq!(f.cached_frames(&at(0, 1000)), [0, 1, 4, 5]);
|
||||
// No other size left: least recently shown first again.
|
||||
insert(&f, at(6, 1000));
|
||||
assert_eq!(f.cached_frames(&at(0, 1000)), [1, 4, 5, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_are_keyed_by_content_and_series_are_kept_apart() {
|
||||
let f = Frames::default();
|
||||
|
||||
@@ -259,7 +259,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if mresp.clicked() {
|
||||
widgets::open_popup(ui, egui::Id::new("ws-pop"));
|
||||
}
|
||||
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| s.to_string()).collect();
|
||||
let names: Vec<String> = crate::dock::WORKSPACES.iter().map(|s| crate::i18n::command_label("window.workspace", s).to_string()).collect();
|
||||
if let Some(i) = widgets::popup_menu(
|
||||
ui,
|
||||
egui::Id::new("ws-pop"),
|
||||
@@ -274,7 +274,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Small Screen, Standard.
|
||||
let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"];
|
||||
for name in shown {
|
||||
let g = p.layout_no_wrap(name.to_string(), Tokens::ui(12.0), t.text);
|
||||
let g = p.layout_no_wrap(crate::i18n::command_label("window.workspace", name).to_string(), Tokens::ui(12.0), t.text);
|
||||
let w = g.size().x + 16.0;
|
||||
let r = Rect::from_min_max(pos2(rx - w, cy - 13.0), pos2(rx, cy + 13.0));
|
||||
if r.min.x < x + 120.0 {
|
||||
@@ -639,9 +639,15 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter
|
||||
r.max.x + 16.0
|
||||
}
|
||||
|
||||
/// A workspace's drawn name: built-ins read the menu bar's Window ▸ Workspace row; names the user
|
||||
/// saved stay verbatim, even when one matches a catalog source such as "Composition".
|
||||
fn workspace_label(name: &str) -> &str {
|
||||
if crate::dock::WORKSPACES.contains(&name) { crate::i18n::command_label("window.workspace", name) } else { name }
|
||||
}
|
||||
|
||||
fn ws_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
|
||||
for w in app.workspace_names() {
|
||||
if ui.selectable_label(app.ui.workspace == w, &w).clicked() {
|
||||
if ui.selectable_label(app.ui.workspace == w, workspace_label(&w)).clicked() {
|
||||
app.set_workspace(&w);
|
||||
ui.close();
|
||||
}
|
||||
@@ -692,6 +698,15 @@ mod tests {
|
||||
out.textures_delta.clear();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saved_workspace_names_stay_verbatim() {
|
||||
crate::i18n::set_current("zh-hans");
|
||||
assert_ne!(workspace_label("Default"), "Default");
|
||||
assert_ne!(crate::i18n::tr("Composition"), "Composition");
|
||||
assert_eq!(workspace_label("Composition"), "Composition");
|
||||
crate::i18n::set_current("en");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_press_type_keeps_the_tool_until_vertical_is_selected() {
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
|
||||
+784
-8
@@ -10,10 +10,12 @@ pub(crate) mod effects;
|
||||
/// Translations for the panels, tooltips and dialogs below the menu bar (see the module docs).
|
||||
mod ui;
|
||||
use effectcraft_engine::menus::{MenuEntry, MenuNode};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(crate) use ui::catalog_characters;
|
||||
pub(crate) use ui::{set_current, tr, tr_args};
|
||||
|
||||
/// Every language with a catalog, in the order Settings ▸ General ▸ Language lists them.
|
||||
const CATALOG_CODES: [&str; 10] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs"];
|
||||
const CATALOG_CODES: [&str; 11] = ["ja", "zh-hans", "zh-hant", "uk", "es", "pt-br", "ru", "fr", "de", "cs", "ka"];
|
||||
|
||||
/// The active interface language: the stored preference when it names a catalog, otherwise `en`
|
||||
/// (English is the source of every label and needs no lookup).
|
||||
@@ -64,22 +66,64 @@ pub(crate) fn czech(app: &EffectcraftApp) -> bool {
|
||||
language(app) == "cs"
|
||||
}
|
||||
|
||||
/// The UI is in Georgian.
|
||||
pub(crate) fn georgian(app: &EffectcraftApp) -> bool {
|
||||
language(app) == "ka"
|
||||
}
|
||||
|
||||
/// Settings ▸ General ▸ Language ▸ Match System: the operating system's interface language
|
||||
/// where EffectCraft has it, else English (#229), as After Effects installs in the system's
|
||||
/// language. The browser build stays in English because it has no system-locale probe.
|
||||
/// language. The browser build follows the locales its page passes in ([`set_system_locales`]).
|
||||
fn system_language() -> &'static str {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
static LANGUAGE: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
|
||||
LANGUAGE.get_or_init(|| supported(sys_locale::get_locale().as_deref()))
|
||||
}
|
||||
// No system locale in the browser build: English.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
supported(None)
|
||||
{
|
||||
let (requested, browser) = SYSTEM_LOCALES.get().map(|(r, b)| (r.as_deref(), b.as_slice())).unwrap_or_default();
|
||||
first_supported(requested, browser, web_draws)
|
||||
}
|
||||
}
|
||||
|
||||
/// The page's `?lang=`, then the browser's preferred languages, most preferred first
|
||||
/// (`navigator.languages`), for Match System in the web build.
|
||||
static SYSTEM_LOCALES: std::sync::OnceLock<(Option<String>, Vec<String>)> = std::sync::OnceLock::new();
|
||||
|
||||
/// Tell Match System the language the page asks for (`?lang=`) and the browser's preferred
|
||||
/// languages (the web build has no locale probe of its own). Set before the first frame; only
|
||||
/// the first call counts.
|
||||
pub fn set_system_locales(requested: Option<String>, browser: Vec<String>) {
|
||||
let _ = SYSTEM_LOCALES.set((requested, browser));
|
||||
}
|
||||
|
||||
/// `requested` when EffectCraft has a catalog for it, else the first of `browser` it has a
|
||||
/// catalog for and `drawable` accepts (English counts), else English: someone who prefers Dutch,
|
||||
/// then Czech, gets Czech.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn first_supported(requested: Option<&str>, browser: &[String], drawable: impl Fn(&str) -> bool) -> &'static str {
|
||||
let english = |t: &str| t.split(['-', '_']).next().is_some_and(|p| p.eq_ignore_ascii_case("en"));
|
||||
requested
|
||||
.map(|t| (t, true))
|
||||
.into_iter()
|
||||
.chain(browser.iter().map(|t| (t.as_str(), false)))
|
||||
.map(|(t, asked)| (t, asked, supported(Some(t))))
|
||||
.find(|(t, asked, code)| (*code != "en" && (*asked || drawable(code))) || english(t))
|
||||
.map_or("en", |(_, _, code)| code)
|
||||
}
|
||||
|
||||
/// Whether the web build can draw `language`'s interface. It installs no system fonts and its
|
||||
/// bundled faces have no CJK or Georgian glyphs, so Match System skips those browser languages
|
||||
/// rather than draw boxes; `?lang=` and a language chosen in Settings still select them.
|
||||
#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
|
||||
fn web_draws(language: &str) -> bool {
|
||||
!matches!(language, "ja" | "zh-hans" | "zh-hant" | "ka")
|
||||
}
|
||||
|
||||
/// The language EffectCraft shows for a BCP 47 locale (`ja-JP` → `ja`, `zh-Hans-CN` → `zh-hans`,
|
||||
/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`).
|
||||
/// `zh-TW` → `zh-hant`, `uk-UA` → `uk`, `es-ES` → `es`, `pt-BR` → `pt-br`, `cs-CZ` → `cs`,
|
||||
/// `ka-GE` → `ka`).
|
||||
fn supported(locale: Option<&str>) -> &'static str {
|
||||
let Some(locale) = locale else { return "en" };
|
||||
let mut parts = locale.split(['-', '_']);
|
||||
@@ -90,6 +134,7 @@ fn supported(locale: Option<&str>) -> &'static str {
|
||||
"uk" => "uk",
|
||||
"es" => "es",
|
||||
"cs" => "cs",
|
||||
"ka" => "ka",
|
||||
// The only Portuguese catalog: Brazil's, also for Portugal rather than English.
|
||||
"pt" => "pt-br",
|
||||
"zh" => {
|
||||
@@ -125,6 +170,13 @@ pub(crate) fn label<'a>(app: &EffectcraftApp, command: &str, source: &'a str) ->
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if language == "ka" && matches!(command, "file.revealInFinder" | "layer.revealInFinder") {
|
||||
match source {
|
||||
"Reveal in Explorer" => return "Explorer-ში ჩვენება",
|
||||
"Reveal in File Manager" => return "ფაილების მენეჯერში ჩვენება",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
menu_catalog(language).map_or(source, |catalog| lookup(catalog, command, source))
|
||||
}
|
||||
|
||||
@@ -141,10 +193,28 @@ fn menu_catalog(language: &str) -> Option<&'static [(&'static str, &'static str,
|
||||
"fr" => FRENCH,
|
||||
"de" => GERMAN,
|
||||
"cs" => CZECH,
|
||||
"ka" => GEORGIAN,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A command's label drawn outside the menu bar (the dock's tab titles, the workspace bar), in the
|
||||
/// language the panels draw in: the menu bar's row for (`command`, `source`), else the panel
|
||||
/// catalog's row for `source`, else `source`. `…` also finds a menu bar row written `...`.
|
||||
pub(crate) fn command_label<'a>(command: &str, source: &'a str) -> &'a str {
|
||||
let dotted = source.strip_suffix('…').map(|stem| format!("{stem}..."));
|
||||
let row = |key: &str| menu_catalog(ui::current())?.iter().find(|(id, en, _)| *id == command && *en == key).map(|(_, _, translated)| *translated);
|
||||
row(source).or_else(|| row(dotted.as_deref()?)).unwrap_or_else(|| tr(source))
|
||||
}
|
||||
|
||||
/// A Settings page's title as the menu bar's Settings submenu draws it, without the ellipsis; the
|
||||
/// panel catalog's row for pages that have no menu row. The panel catalog's "Type" is the kind
|
||||
/// (Camera Settings, the Project panel's column), while the Type page is about text.
|
||||
pub(crate) fn settings_page_title(title: &str) -> &str {
|
||||
let menu = menu_catalog(ui::current()).and_then(|c| c.iter().find(|(id, en, _)| *id == "app.settings" && en.strip_suffix("...") == Some(title)));
|
||||
menu.and_then(|(_, _, translated)| translated.strip_suffix("...")).unwrap_or_else(|| tr(title))
|
||||
}
|
||||
|
||||
/// `source` looked up in `catalog` by (command, English label); the source itself when the pair is
|
||||
/// absent, so computed labels and user names stay verbatim.
|
||||
fn lookup<'a>(catalog: &[(&str, &str, &'a str)], command: &str, source: &'a str) -> &'a str {
|
||||
@@ -191,6 +261,8 @@ pub(crate) fn entry(app: &EffectcraftApp, e: &MenuEntry, shown: String) -> Strin
|
||||
"es" => ("Deshacer", "No se puede deshacer", "Rehacer", "No se puede rehacer"),
|
||||
"pt-br" => ("Desfazer", "Não há nada para desfazer", "Refazer", "Não há nada para refazer"),
|
||||
"cs" => ("Zpět", "Není co vrátit", "Znovu", "Není co zopakovat"),
|
||||
// A colon before the history's (untranslated) action name: "დაბრუნება: Comp1".
|
||||
"ka" => ("დაბრუნება:", "დაბრუნება შეუძლებელია", "ხელახლა შესრულება:", "ხელახლა შესრულება შეუძლებელია"),
|
||||
_ => return shown,
|
||||
};
|
||||
match e.command.as_str() {
|
||||
@@ -300,6 +372,17 @@ pub(crate) fn submenu(app: &EffectcraftApp, source: &str, shown: String) -> Stri
|
||||
return format!("Atribuir atalho à visualização 3D “{name}”");
|
||||
}
|
||||
}
|
||||
"ka" => {
|
||||
if source == "Assign Shortcut to Workspace" {
|
||||
if let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix("” Workspace")) {
|
||||
return format!("მალსახმობის მინიჭება სამუშაო სივრცისთვის „{name}“");
|
||||
}
|
||||
} else if source == "Assign Shortcut to 3D View"
|
||||
&& let Some(name) = shown.strip_prefix("Assign Shortcut to “").and_then(|s| s.strip_suffix('”'))
|
||||
{
|
||||
return format!("მალსახმობის მინიჭება 3D ხედისთვის „{name}“");
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
label(app, "", &shown).into()
|
||||
@@ -6642,6 +6725,641 @@ const CZECH: &[(&str, &str, &str)] = &[
|
||||
("file.openDemoProject", "Open Demo Project", "Otevřít ukázkový projekt"),
|
||||
];
|
||||
|
||||
/// Georgian (ka): the same (command, English label) keys as `JAPANESE`, one entry per fixed menu row.
|
||||
/// Original translations written from the meaning of the English labels, sharing the terminology of
|
||||
/// the other Craft apps' Georgian catalogs (შრე, საკვანძო კადრი, კომპოზიცია, ნიღაბი); commands are
|
||||
/// verbal nouns (შენახვა, გახსნა) and Georgian has no capital letters.
|
||||
const GEORGIAN: &[(&str, &str, &str)] = &[
|
||||
("", "EffectCraft", "EffectCraft"),
|
||||
("app.about", "About EffectCraft...", "EffectCraft-ის შესახებ..."),
|
||||
("", "Settings...", "პარამეტრები..."),
|
||||
("app.settings", "General...", "ზოგადი..."),
|
||||
("app.settings", "Startup & Repair...", "გაშვება და აღდგენა..."),
|
||||
("app.settings", "Project...", "პროექტი..."),
|
||||
("app.settings", "Composition...", "კომპოზიცია..."),
|
||||
("app.settings", "Previews...", "წინასწარი ნახვა..."),
|
||||
("app.settings", "Appearance...", "იერსახე..."),
|
||||
("app.settings", "Grids & Guides...", "ბადე და დამხმარე ხაზები..."),
|
||||
("app.settings", "Labels...", "იარლიყები..."),
|
||||
("app.settings", "Type...", "ტექსტი..."),
|
||||
("app.settings", "Import...", "იმპორტი..."),
|
||||
("app.settings", "Export...", "ექსპორტი..."),
|
||||
("app.settings", "Audio...", "აუდიო..."),
|
||||
("app.settings", "Disk...", "დისკი..."),
|
||||
("app.settings", "Memory & CPU...", "მეხსიერება და CPU..."),
|
||||
("app.settings", "Video...", "ვიდეო..."),
|
||||
("app.settings", "3D...", "3D..."),
|
||||
("app.settings", "Scripting & Expressions...", "სკრიპტები და ექსპრესიები..."),
|
||||
("app.hide", "Hide EffectCraft", "EffectCraft-ის დამალვა"),
|
||||
("app.hideOthers", "Hide Others", "სხვების დამალვა"),
|
||||
("app.showAll", "Show All", "ყველას ჩვენება"),
|
||||
("app.quit", "Quit EffectCraft", "EffectCraft-იდან გასვლა"),
|
||||
("", "File", "ფაილი"),
|
||||
("", "New", "ახალი"),
|
||||
("file.newProject", "New Project", "ახალი პროექტი"),
|
||||
("file.newFromTemplate", "New Project from Template...", "ახალი პროექტი შაბლონიდან..."),
|
||||
("project.newFolder", "New Folder", "ახალი საქაღალდე"),
|
||||
("file.open", "Open Project...", "პროექტის გახსნა..."),
|
||||
("", "Open Recent", "ბოლოს გახსნილი ფაილების გახსნა"),
|
||||
("file.clearRecent", "Clear Recent Projects", "ბოლო პროექტების სიის გასუფთავება"),
|
||||
("file.close", "Close", "დახურვა"),
|
||||
("file.closeProject", "Close Project", "პროექტის დახურვა"),
|
||||
("file.save", "Save", "შენახვა"),
|
||||
("", "Save As", "შენახვა როგორც"),
|
||||
("file.saveAs", "Save As...", "შენახვა როგორც..."),
|
||||
("file.saveCopy", "Save a Copy...", "ასლის შენახვა..."),
|
||||
("file.saveCopyAsXml", "Save a Copy As XML...", "ასლის შენახვა XML-ად..."),
|
||||
("templates.saveAs", "Save as Template...", "შაბლონად შენახვა..."),
|
||||
("file.incrementAndSave", "Increment and Save", "ვერსიის გაზრდით შენახვა"),
|
||||
("file.revert", "Revert", "წინა ვერსიაზე დაბრუნება"),
|
||||
("", "Import", "იმპორტი"),
|
||||
("file.import", "File...", "ფაილი..."),
|
||||
("file.importMultiple", "Multiple Files...", "რამდენიმე ფაილი..."),
|
||||
("file.importTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."),
|
||||
("file.importProject", "EffectCraft Project...", "EffectCraft-ის პროექტი..."),
|
||||
("file.importPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."),
|
||||
("file.importSolid", "Solid...", "სოლიდი..."),
|
||||
("file.importLottie", "Lottie...", "Lottie..."),
|
||||
("file.importVanishingPoint", "Vanishing Point (.vpe)...", "Vanishing Point (.vpe)..."),
|
||||
("essential.importTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."),
|
||||
("", "Import Recent Footage", "ბოლო ვიდეომასალის იმპორტი"),
|
||||
("file.clearRecentFootage", "Clear Recent Footage", "ბოლო ვიდეომასალის სიის გასუფთავება"),
|
||||
("", "Export", "ექსპორტი"),
|
||||
("renderQueue.add", "Add to Render Queue", "რენდერის რიგში დამატება"),
|
||||
("file.exportTimeline", "Adobe Premiere Pro Project...", "Adobe Premiere Pro-ს პროექტი..."),
|
||||
("file.exportLottie", "Lottie JSON...", "Lottie JSON..."),
|
||||
("essential.exportTemplate", "Essential Graphics Template...", "ძირითადი გრაფიკის შაბლონი..."),
|
||||
("app.find", "Find", "ძებნა"),
|
||||
("layer.addItem", "Add Footage to Comp", "ვიდეომასალის კომპოზიციაში დამატება"),
|
||||
("file.newCompFromSelection", "New Comp from Selection...", "ახალი კომპოზიცია მონიშნულიდან..."),
|
||||
("", "Dependencies", "დამოკიდებულებები"),
|
||||
("file.collectFiles", "Collect Files...", "ფაილების შეგროვება..."),
|
||||
("file.consolidateFootage", "Consolidate All Footage", "მთელი ვიდეომასალის კონსოლიდაცია"),
|
||||
("file.removeUnusedFootage", "Remove Unused Footage", "გამოუყენებელი ვიდეომასალის წაშლა"),
|
||||
("file.reduceProject", "Reduce Project", "პროექტის შემცირება"),
|
||||
("file.findMissing", "Find Missing Effects", "დაკარგული ეფექტების ძებნა"),
|
||||
("file.findMissing", "Find Missing Fonts", "დაკარგული შრიფტების ძებნა"),
|
||||
("file.findMissing", "Find Missing Footage", "დაკარგული ვიდეომასალის ძებნა"),
|
||||
("file.relinkFootage", "Relink Missing Footage...", "დაკარგული ვიდეომასალის ხელახლა დაკავშირება..."),
|
||||
("file.watchFolder", "Watch Folder...", "საქაღალდეზე თვალყურის დევნება..."),
|
||||
("", "Scripts", "სკრიპტები"),
|
||||
("file.installScript", "Install Script File...", "სკრიპტის ფაილის დაყენება..."),
|
||||
("file.installScriptUIPanel", "Install ScriptUI Panel...", "ScriptUI პანელის დაყენება..."),
|
||||
("file.runScript", "Run Script File...", "სკრიპტის ფაილის გაშვება..."),
|
||||
("", "Create Proxy", "პროქსის შექმნა"),
|
||||
("file.createProxy", "Still...", "სტატიკური კადრი..."),
|
||||
("file.createProxy", "Movie...", "ვიდეო..."),
|
||||
("", "Set Proxy", "პროქსის მინიჭება"),
|
||||
("file.setProxy", "File...", "ფაილი..."),
|
||||
("file.setProxyNone", "None", "არცერთი"),
|
||||
("", "Interpret Footage", "ვიდეომასალის ინტერპრეტაცია"),
|
||||
("file.interpretFootage", "Main...", "ძირითადი..."),
|
||||
("file.interpretProxy", "Proxy...", "პროქსი..."),
|
||||
("file.rememberInterpretation", "Remember Interpretation", "ინტერპრეტაციის დამახსოვრება"),
|
||||
("file.applyInterpretation", "Apply Interpretation", "ინტერპრეტაციის გამოყენება"),
|
||||
("", "Replace Footage", "ვიდეომასალის ჩანაცვლება"),
|
||||
("file.replaceFootage", "File...", "ფაილი..."),
|
||||
("file.replaceWithPlaceholder", "Placeholder...", "ჩანაცვლების ობიექტი..."),
|
||||
("file.replaceWithSolid", "Solid...", "სოლიდი..."),
|
||||
("file.replaceWithLayeredComp", "With Layered Comp", "შრეებიანი კომპოზიციით"),
|
||||
("file.reloadFootage", "Reload Footage", "ვიდეომასალის ხელახლა ჩატვირთვა"),
|
||||
("file.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"),
|
||||
("file.projectSettings", "Project Settings...", "პროექტის პარამეტრები..."),
|
||||
("app.quit", "Exit", "გასვლა"),
|
||||
("", "Edit", "რედაქტირება"),
|
||||
("edit.undo", "Undo", "დაბრუნება"),
|
||||
("edit.redo", "Redo", "ხელახლა შესრულება"),
|
||||
("", "History", "ისტორია"),
|
||||
("app.commandPalette", "Quick Apply...", "სწრაფი გამოყენება..."),
|
||||
("edit.cut", "Cut", "ამოჭრა"),
|
||||
("edit.copy", "Copy", "კოპირება"),
|
||||
("edit.copyWithPropertyLinks", "Copy with Property Links", "კოპირება თვისებების ბმულებით"),
|
||||
("edit.copyWithRelativePropertyLinks", "Copy with Relative Property Links", "კოპირება თვისებების ფარდობითი ბმულებით"),
|
||||
("edit.copyExpressionOnly", "Copy Expression Only", "მხოლოდ ექსპრესიის კოპირება"),
|
||||
("edit.paste", "Paste", "ჩასმა"),
|
||||
("edit.pasteReversedKeyframes", "Paste Reversed Keyframes", "საკვანძო კადრების შებრუნებული ჩასმა"),
|
||||
("edit.pasteTextMatchFormatting", "Paste Text and Match Formatting", "ტექსტის ჩასმა ფორმატის შეთანხმებით"),
|
||||
("edit.pasteTextFormattingOnly", "Paste Text Formatting Only", "მხოლოდ ტექსტის ფორმატის ჩასმა"),
|
||||
("edit.clear", "Clear", "გასუფთავება"),
|
||||
("edit.duplicate", "Duplicate", "დუბლირება"),
|
||||
("edit.splitLayer", "Split Layer", "შრის დაყოფა"),
|
||||
("edit.liftWorkArea", "Lift Work Area", "სამუშაო არის აწევა"),
|
||||
("edit.extractWorkArea", "Extract Work Area", "სამუშაო არის ამოღება"),
|
||||
("edit.selectAll", "Select All", "ყველაფრის მონიშვნა"),
|
||||
("edit.deselectAll", "Deselect All", "ყველა მონიშვნის მოხსნა"),
|
||||
("", "Label", "იარლიყი"),
|
||||
("edit.selectLabelGroup", "Select Label Group", "იარლიყის ჯგუფის მონიშვნა"),
|
||||
("edit.label", "None", "არცერთი"),
|
||||
("edit.label", "Red", "წითელი"),
|
||||
("edit.label", "Yellow", "ყვითელი"),
|
||||
("edit.label", "Aqua", "ზღვისფერი"),
|
||||
("edit.label", "Pink", "ვარდისფერი"),
|
||||
("edit.label", "Lavender", "ლავანდისფერი"),
|
||||
("edit.label", "Peach", "ატმისფერი"),
|
||||
("edit.label", "Sea Foam", "ზღვის ქაფი"),
|
||||
("edit.label", "Blue", "ლურჯი"),
|
||||
("edit.label", "Green", "მწვანე"),
|
||||
("edit.label", "Purple", "იისფერი"),
|
||||
("edit.label", "Orange", "ნარინჯისფერი"),
|
||||
("edit.label", "Brown", "ყავისფერი"),
|
||||
("edit.label", "Fuchsia", "ფუქსია"),
|
||||
("edit.label", "Cyan", "ცისფერი"),
|
||||
("edit.label", "Sandstone", "ქვიშაქვა"),
|
||||
("edit.label", "Dark Green", "მუქი მწვანე"),
|
||||
("app.settings", "Edit Label Colors...", "იარლიყის ფერების რედაქტირება..."),
|
||||
("", "Select Keyframe Label Group", "საკვანძო კადრების იარლიყის ჯგუფის მონიშვნა"),
|
||||
("keys.selectLabelGroup", "On Selected Layers", "მონიშნულ შრეებზე"),
|
||||
("keys.selectLabelGroup", "On All Layers", "ყველა შრეზე"),
|
||||
("keys.selectLabelGroup", "Visible Keyframes on Selected Layers", "ხილული საკვანძო კადრები მონიშნულ შრეებზე"),
|
||||
("keys.selectLabelGroup", "Visible Keyframes on All Layers", "ხილული საკვანძო კადრები ყველა შრეზე"),
|
||||
("", "Purge", "გაწმენდა"),
|
||||
("edit.purge", "All Cache...", "მთელი ქეში..."),
|
||||
("edit.purge", "All Memory & Disk Cache...", "მთელი მეხსიერება და დისკის ქეში..."),
|
||||
("edit.purge", "All Memory", "მთელი მეხსიერება"),
|
||||
("edit.purge", "All Disk Cache...", "მთელი დისკის ქეში..."),
|
||||
("edit.purge", "All 3D Cache...", "მთელი 3D ქეში..."),
|
||||
("edit.purgeUndo", "Undo", "დაბრუნების ისტორია"),
|
||||
("edit.purge", "Image Cache Memory", "გამოსახულების ქეშის მეხსიერება"),
|
||||
("edit.purge", "Snapshot", "მომენტალური ასლი"),
|
||||
("edit.editOriginal", "Edit Original...", "ორიგინალის რედაქტირება..."),
|
||||
("", "Templates", "შაბლონები"),
|
||||
("app.templates", "Render Settings...", "რენდერის პარამეტრები..."),
|
||||
("app.templates", "Output Module...", "გამოტანის მოდული..."),
|
||||
("app.keyboardShortcuts", "Keyboard Shortcuts", "კლავიატურის მალსახმობები"),
|
||||
("", "Preferences", "პარამეტრები"),
|
||||
("", "Composition", "კომპოზიცია"),
|
||||
("comp.new", "New Composition...", "ახალი კომპოზიცია..."),
|
||||
("comp.settings", "Composition Settings...", "კომპოზიციის პარამეტრები..."),
|
||||
("comp.setPosterTime", "Set Poster Time", "პოსტერის დროის დაყენება"),
|
||||
("comp.trimToWorkArea", "Trim Comp to Work Area", "კომპოზიციის მოკვეთა სამუშაო არემდე"),
|
||||
("comp.cropToRegionOfInterest", "Crop Comp to Region of Interest", "კომპოზიციის ჩამოჭრა ინტერესის არემდე"),
|
||||
("comp.cropToLayerBounds", "Crop Comp to Selected Layer(s) Bounds", "კომპოზიციის ჩამოჭრა მონიშნული შრ(ეებ)ის საზღვრებამდე"),
|
||||
("render.addOutputModule", "Add Output Module", "გამოტანის მოდულის დამატება"),
|
||||
("", "Preview", "წინასწარი ნახვა"),
|
||||
("playback.toggle", "Play Current Preview", "მიმდინარე წინასწარი ნახვის დაკვრა"),
|
||||
("playback.cacheWhenIdle", "Cache Frames When Idle", "კადრების ქეშირება უქმობისას"),
|
||||
("playback.audio", "Audio", "აუდიო"),
|
||||
("", "Save Frame As", "კადრის შენახვა როგორც"),
|
||||
("comp.saveFrameAs", "File...", "ფაილი..."),
|
||||
("comp.saveFrameAsPsd", "Photoshop Layers...", "Photoshop-ის შრეები..."),
|
||||
("comp.saveFrameAsExr", "ProEXR...", "ProEXR..."),
|
||||
("comp.copyFrame", "Copy Frame to Clipboard", "კადრის კოპირება გაცვლის ბუფერში"),
|
||||
("render.preRender", "Pre-render...", "წინასწარი რენდერი..."),
|
||||
("render.saveCurrentPreview", "Save Current Preview...", "მიმდინარე წინასწარი ნახვის შენახვა..."),
|
||||
("comp.openInEssentialGraphics", "Open in Essential Graphics", "ძირითად გრაფიკაში გახსნა"),
|
||||
("", "Responsive Design — Time", "რესპონსიული დიზაინი — დრო"),
|
||||
("comp.responsiveTime", "Create Intro", "შესავლის შექმნა"),
|
||||
("comp.responsiveTime", "Create Outro", "დასასრულის შექმნა"),
|
||||
("comp.responsiveTime", "Create Protected Region from Work Area", "დაცული არის შექმნა სამუშაო არიდან"),
|
||||
("comp.flowchart", "Composition Flowchart", "კომპოზიციის ბლოკ-სქემა"),
|
||||
("comp.miniFlowchart", "Composition Mini-Flowchart", "კომპოზიციის მინი-ბლოკ-სქემა"),
|
||||
("", "VR", "VR"),
|
||||
("comp.vr.createEnvironment", "Create VR Environment...", "VR გარემოს შექმნა..."),
|
||||
("comp.vr.extractCubemap", "Extract Cubemap...", "კუბური რუკის ამოღება..."),
|
||||
("", "Layer", "შრე"),
|
||||
("layer.newText", "Text", "ტექსტი"),
|
||||
("layer.newSolid", "Solid...", "სოლიდი..."),
|
||||
("layer.newLight", "Light...", "სინათლე..."),
|
||||
("layer.newCamera", "Camera...", "კამერა..."),
|
||||
("layer.newNull", "Null Object", "ნულოვანი ობიექტი"),
|
||||
("layer.newShape", "Shape Layer", "ფიგურის შრე"),
|
||||
("layer.newAdjustment", "Adjustment Layer", "კორექციის შრე"),
|
||||
("layer.newContentAwareFill", "Content-Aware Fill Layer...", "შიგთავსის გათვალისწინებით შევსების შრე..."),
|
||||
("layer.new3dPrimitive", "Cube", "კუბი"),
|
||||
("layer.new3dPrimitive", "Sphere", "სფერო"),
|
||||
("layer.new3dPrimitive", "Plane", "სიბრტყე"),
|
||||
("layer.new3dPrimitive", "Torus", "ტორი"),
|
||||
("layer.new3dPrimitive", "Cone", "კონუსი"),
|
||||
("layer.new3dPrimitive", "Cylinder", "ცილინდრი"),
|
||||
("layer.settings", "Layer Settings...", "შრის პარამეტრები..."),
|
||||
("layer.openLayer", "Open Layer", "შრის გახსნა"),
|
||||
("layer.openSource", "Open Layer Source", "შრის წყაროს გახსნა"),
|
||||
("layer.revealInFinder", "Reveal in Finder", "Finder-ში ჩვენება"),
|
||||
("", "Mask", "ნიღაბი"),
|
||||
("layer.addMask", "New Mask", "ახალი ნიღაბი"),
|
||||
("layer.mask.shape", "Mask Shape...", "ნიღბის ფორმა..."),
|
||||
("layer.mask.set", "Mask Feather...", "ნიღბის კიდეების შერბილება..."),
|
||||
("layer.mask.set", "Mask Opacity...", "ნიღბის გაუმჭვირვალობა..."),
|
||||
("layer.mask.set", "Mask Expansion...", "ნიღბის გაფართოება..."),
|
||||
("layer.mask.reset", "Reset Mask", "ნიღბის ჩამოყრა"),
|
||||
("layer.mask.remove", "Remove Mask", "ნიღბის წაშლა"),
|
||||
("layer.mask.removeAll", "Remove All Masks", "ყველა ნიღბის წაშლა"),
|
||||
("track.mask", "Track Mask", "ნიღბის ტრეკინგი"),
|
||||
("", "Mode", "რეჟიმი"),
|
||||
("layer.mask.mode", "None", "არცერთი"),
|
||||
("layer.mask.mode", "Add", "დამატება"),
|
||||
("layer.mask.mode", "Subtract", "გამოკლება"),
|
||||
("layer.mask.mode", "Intersect", "გადაკვეთა"),
|
||||
("layer.mask.mode", "Lighten", "გაღიავება"),
|
||||
("layer.mask.mode", "Darken", "ჩამუქება"),
|
||||
("layer.mask.mode", "Difference", "სხვაობა"),
|
||||
("layer.mask.invert", "Inverted", "შებრუნებული"),
|
||||
("layer.mask.lock", "Locked", "ჩაკეტილი"),
|
||||
("", "Motion Blur", "მოძრაობის გაბუნდოვნება"),
|
||||
("layer.mask.motionBlur", "Same As Layer", "შრის მსგავსად"),
|
||||
("layer.mask.motionBlur", "On", "ჩართული"),
|
||||
("layer.mask.motionBlur", "Off", "გამორთული"),
|
||||
("", "Feather Falloff", "კიდეების შერბილების მილევა"),
|
||||
("layer.mask.featherFalloff", "Smooth", "გლუვი"),
|
||||
("layer.mask.featherFalloff", "Linear", "წრფივი"),
|
||||
("layer.mask.unlockAll", "Unlock All Masks", "ყველა ნიღბის განბლოკვა"),
|
||||
("layer.mask.lockOthers", "Lock Other Masks", "სხვა ნიღბების ჩაკეტვა"),
|
||||
("layer.mask.hideLocked", "Hide Locked Masks", "ჩაკეტილი ნიღბების დამალვა"),
|
||||
("", "Mask and Shape Path", "ნიღაბი და ფიგურის ტრაექტორია"),
|
||||
("path.rotoBezier", "RotoBezier", "RotoBezier"),
|
||||
("mask.setClosed", "Closed", "შეკრული"),
|
||||
("path.convertToBezier", "Convert To Bezier Path", "ბეზიეს ტრაექტორიად გარდაქმნა"),
|
||||
("path.setFirstVertex", "Set First Vertex", "პირველი წვეროს დაყენება"),
|
||||
("path.freeTransform", "Free Transform Points", "წერტილების თავისუფალი გარდაქმნა"),
|
||||
("", "Quality", "ხარისხი"),
|
||||
("layer.quality", "Best", "საუკეთესო"),
|
||||
("layer.quality", "Draft", "მონახაზი"),
|
||||
("layer.quality", "Wireframe", "კარკასი"),
|
||||
("layer.sampling", "Bilinear", "ბიწრფივი"),
|
||||
("layer.sampling", "Bicubic", "ბიკუბური"),
|
||||
("", "Switches", "გადამრთველები"),
|
||||
("layer.hideOtherVideo", "Hide Other Video", "სხვა ვიდეოს დამალვა"),
|
||||
("layer.showAllVideo", "Show All Video", "მთელი ვიდეოს ჩვენება"),
|
||||
("layer.unlockAll", "Unlock All Layers", "ყველა შრის განბლოკვა"),
|
||||
("layer.expressions", "Enable Expressions", "ექსპრესიების ჩართვა"),
|
||||
("layer.expressions", "Disable Expressions", "ექსპრესიების გამორთვა"),
|
||||
("layer.setSwitch", "Shy", "მორცხვი"),
|
||||
("layer.setSwitch", "Lock", "ჩაკეტვა"),
|
||||
("layer.setSwitch", "Audio", "აუდიო"),
|
||||
("layer.setSwitch", "Video", "ვიდეო"),
|
||||
("layer.setSwitch", "Solo", "სოლო"),
|
||||
("layer.setSwitch", "Effect", "ეფექტი"),
|
||||
("layer.setSwitch", "Collapse", "ჩაკეცვა"),
|
||||
("layer.setSwitch", "Motion Blur", "მოძრაობის გაბუნდოვნება"),
|
||||
("layer.setSwitch", "Adjustment Layer", "კორექციის შრე"),
|
||||
("", "Transform", "გარდაქმნა"),
|
||||
("layer.transform", "Reset", "ჩამოყრა"),
|
||||
("layer.setTransform", "Anchor Point...", "საყრდენი წერტილი..."),
|
||||
("layer.setTransform", "Position...", "პოზიცია..."),
|
||||
("layer.setTransform", "Scale...", "მასშტაბი..."),
|
||||
("layer.setTransform", "Orientation...", "ორიენტაცია..."),
|
||||
("layer.setTransform", "Rotation...", "შემობრუნება..."),
|
||||
("layer.setTransform", "Opacity...", "გაუმჭვირვალობა..."),
|
||||
("layer.transform", "Flip Horizontal", "ჰორიზონტალურად არეკვლა"),
|
||||
("layer.transform", "Flip Vertical", "ვერტიკალურად არეკვლა"),
|
||||
("layer.transform", "Center In View", "ხედის ცენტრში მოთავსება"),
|
||||
("layer.centerAnchor", "Center Anchor Point in Layer Content", "საყრდენი წერტილის შრის შიგთავსის ცენტრში მოთავსება"),
|
||||
("layer.transform", "Fit to Comp", "კომპოზიციაში ჩატევა"),
|
||||
("layer.transform", "Fit to Comp Width", "კომპოზიციის სიგანეში ჩატევა"),
|
||||
("layer.transform", "Fit to Comp Height", "კომპოზიციის სიმაღლეში ჩატევა"),
|
||||
("layer.autoOrient", "Auto-Orient...", "ავტოორიენტაცია..."),
|
||||
("", "Time", "დრო"),
|
||||
("layer.enableTimeRemap", "Enable Time Remapping", "დროის გადანაწილების ჩართვა"),
|
||||
("layer.timeReverse", "Time-Reverse Layer", "შრის დროში შებრუნება"),
|
||||
("layer.timeStretch", "Time Stretch...", "დროში გაწელვა..."),
|
||||
("layer.freezeFrame", "Freeze Frame", "კადრის გაყინვა"),
|
||||
("layer.freezeOnLastFrame", "Freeze On Last Frame", "ბოლო კადრზე გაყინვა"),
|
||||
("layer.alignVideoToData", "Align Video to Data", "ვიდეოს მონაცემებთან სწორება"),
|
||||
("", "Frame Blending", "კადრების შერევა"),
|
||||
("layer.frameBlending", "Off", "გამორთული"),
|
||||
("layer.frameBlending", "Frame Mix", "კადრების ნაზავი"),
|
||||
("layer.frameBlending", "Pixel Motion", "პიქსელების მოძრაობა"),
|
||||
("layer.setSwitch", "3D Layer", "3D შრე"),
|
||||
("layer.setSwitch", "Guide Layer", "დამხმარე შრე"),
|
||||
("layer.environment", "Environment Layer", "გარემოს შრე"),
|
||||
("", "Markers", "მარკერები"),
|
||||
("layer.addMarker", "Add Marker", "მარკერის დამატება"),
|
||||
("layer.updateMarkersFromSource", "Update Markers From Source", "მარკერების განახლება წყაროდან"),
|
||||
("layer.markersLock", "Lock Markers", "მარკერების ჩაკეტვა"),
|
||||
("layer.deleteAllMarkers", "Delete All Markers", "ყველა მარკერის წაშლა"),
|
||||
("layer.setSwitch", "Preserve Transparency", "გამჭვირვალობის შენარჩუნება"),
|
||||
("", "Blending Mode", "შერევის რეჟიმი"),
|
||||
("layer.setBlendMode", "Normal", "ჩვეულებრივი"),
|
||||
("layer.setBlendMode", "Dissolve", "გაფანტვა"),
|
||||
("layer.setBlendMode", "Dancing Dissolve", "მოცეკვავე გაფანტვა"),
|
||||
("layer.setBlendMode", "Darken", "ჩამუქება"),
|
||||
("layer.setBlendMode", "Multiply", "გამრავლება"),
|
||||
("layer.setBlendMode", "Color Burn", "ფერით ჩამუქება"),
|
||||
("layer.setBlendMode", "Classic Color Burn", "კლასიკური ფერით ჩამუქება"),
|
||||
("layer.setBlendMode", "Linear Burn", "წრფივი ჩამუქება"),
|
||||
("layer.setBlendMode", "Darker Color", "უფრო მუქი ფერი"),
|
||||
("layer.setBlendMode", "Add", "დამატება"),
|
||||
("layer.setBlendMode", "Lighten", "გაღიავება"),
|
||||
("layer.setBlendMode", "Screen", "ეკრანი"),
|
||||
("layer.setBlendMode", "Color Dodge", "ფერით გაღიავება"),
|
||||
("layer.setBlendMode", "Classic Color Dodge", "კლასიკური ფერით გაღიავება"),
|
||||
("layer.setBlendMode", "Linear Dodge", "წრფივი გაღიავება"),
|
||||
("layer.setBlendMode", "Lighter Color", "უფრო ღია ფერი"),
|
||||
("layer.setBlendMode", "Overlay", "გადაფარვა"),
|
||||
("layer.setBlendMode", "Soft Light", "რბილი სინათლე"),
|
||||
("layer.setBlendMode", "Hard Light", "მკვეთრი სინათლე"),
|
||||
("layer.setBlendMode", "Linear Light", "წრფივი სინათლე"),
|
||||
("layer.setBlendMode", "Vivid Light", "კაშკაშა სინათლე"),
|
||||
("layer.setBlendMode", "Pin Light", "წერტილოვანი სინათლე"),
|
||||
("layer.setBlendMode", "Hard Mix", "მკვეთრი შერევა"),
|
||||
("layer.setBlendMode", "Difference", "სხვაობა"),
|
||||
("layer.setBlendMode", "Classic Difference", "კლასიკური სხვაობა"),
|
||||
("layer.setBlendMode", "Exclusion", "გამორიცხვა"),
|
||||
("layer.setBlendMode", "Subtract", "გამოკლება"),
|
||||
("layer.setBlendMode", "Divide", "გაყოფა"),
|
||||
("layer.setBlendMode", "Hue", "ფერის ტონი"),
|
||||
("layer.setBlendMode", "Saturation", "გაჯერება"),
|
||||
("layer.setBlendMode", "Color", "ფერი"),
|
||||
("layer.setBlendMode", "Luminosity", "ნათება"),
|
||||
("layer.setBlendMode", "Stencil Alpha", "ალფა-ტრაფარეტი"),
|
||||
("layer.setBlendMode", "Stencil Luma", "ლუმა-ტრაფარეტი"),
|
||||
("layer.setBlendMode", "Silhouette Alpha", "ალფა-სილუეტი"),
|
||||
("layer.setBlendMode", "Silhouette Luma", "ლუმა-სილუეტი"),
|
||||
("layer.setBlendMode", "Alpha Add", "ალფას დამატება"),
|
||||
("layer.setBlendMode", "Luminescent Premul", "მანათობელი წინასწარ გამრავლებული"),
|
||||
("layer.setBlendMode", "Next Blending Mode", "შემდეგი შერევის რეჟიმი"),
|
||||
("layer.setBlendMode", "Previous Blending Mode", "წინა შერევის რეჟიმი"),
|
||||
("", "Track Matte", "ტრეკ-მატი"),
|
||||
("layer.trackMatte", "No Track Matte", "ტრეკ-მატის გარეშე"),
|
||||
("layer.trackMatte", "Alpha Matte", "ალფა-მატი"),
|
||||
("layer.trackMatte", "Alpha Inverted Matte", "შებრუნებული ალფა-მატი"),
|
||||
("layer.trackMatte", "Luma Matte", "ლუმა-მატი"),
|
||||
("layer.trackMatte", "Luma Inverted Matte", "შებრუნებული ლუმა-მატი"),
|
||||
("layer.trackMatte", "Matte with Layer Above", "მატი ზედა შრით"),
|
||||
("layer.trackMatte", "Matte with Layer Below", "მატი ქვედა შრით"),
|
||||
("", "Layer Styles", "შრის სტილები"),
|
||||
("layer.style.options", "Layer Style Options...", "შრის სტილის პარამეტრები..."),
|
||||
("layer.style.convertToEditable", "Convert to Editable Styles", "რედაქტირებად სტილებად გარდაქმნა"),
|
||||
("layer.style.showAll", "Show All", "ყველას ჩვენება"),
|
||||
("layer.style.removeAll", "Remove All", "ყველას წაშლა"),
|
||||
("layer.style.dropShadow", "Drop Shadow", "ჩრდილი"),
|
||||
("layer.style.innerShadow", "Inner Shadow", "შიდა ჩრდილი"),
|
||||
("layer.style.outerGlow", "Outer Glow", "გარე ნათება"),
|
||||
("layer.style.innerGlow", "Inner Glow", "შიდა ნათება"),
|
||||
("layer.style.bevelEmboss", "Bevel and Emboss", "ფასკა და რელიეფი"),
|
||||
("layer.style.satin", "Satin", "ატლასი"),
|
||||
("layer.style.colorOverlay", "Color Overlay", "ფერის გადაფარვა"),
|
||||
("layer.style.gradientOverlay", "Gradient Overlay", "გრადიენტის გადაფარვა"),
|
||||
("layer.style.stroke", "Stroke", "კონტური"),
|
||||
("path.groupShapes", "Group Shapes", "ფიგურების დაჯგუფება"),
|
||||
("path.ungroupShapes", "Ungroup Shapes", "ფიგურების დაჯგუფების მოხსნა"),
|
||||
("", "Arrange", "დალაგება"),
|
||||
("layer.arrange", "Bring Layer to Front", "შრის წინა პლანზე გადმოტანა"),
|
||||
("layer.arrange", "Bring Layer Forward", "შრის წინ გადმოტანა"),
|
||||
("layer.arrange", "Send Layer Backward", "შრის უკან გადატანა"),
|
||||
("layer.arrange", "Send Layer to Back", "შრის უკანა პლანზე გადატანა"),
|
||||
("", "Reveal", "ჩვენება"),
|
||||
("layer.revealSource", "Reveal Layer Source in Project", "შრის წყაროს ჩვენება პროექტში"),
|
||||
("comp.flowchart", "Reveal Layer in Project Flowchart", "შრის ჩვენება პროექტის ბლოკ-სქემაში"),
|
||||
("comp.revealInProject", "Reveal Composition in Project", "კომპოზიციის ჩვენება პროექტში"),
|
||||
("layer.revealExpressionErrors", "Reveal Expression Errors", "ექსპრესიის შეცდომების ჩვენება"),
|
||||
("", "Create", "შექმნა"),
|
||||
("layer.create", "Convert to Editable Text", "რედაქტირებად ტექსტად გარდაქმნა"),
|
||||
("layer.create", "Create Shapes from Text", "ფიგურების შექმნა ტექსტიდან"),
|
||||
("layer.create", "Create Masks from Text", "ნიღბების შექმნა ტექსტიდან"),
|
||||
("layer.create", "Create Shapes from Vector Layer", "ფიგურების შექმნა ვექტორული შრიდან"),
|
||||
("layer.create", "Create Keyframes from Data", "საკვანძო კადრების შექმნა მონაცემებიდან"),
|
||||
("layer.create", "Null Controllers for Positional Points", "ნულოვანი მართვის ობიექტები პოზიციის წერტილებისთვის"),
|
||||
("layer.create", "Null Controllers for Path Points", "ნულოვანი მართვის ობიექტები ტრაექტორიის წერტილებისთვის"),
|
||||
("layer.create", "Nulls Following Path Points", "ტრაექტორიის წერტილებს მიმყოლი ნულოვანი ობიექტები"),
|
||||
("layer.create", "Null Tracing Along Path", "ტრაექტორიის გასწვრივ მოძრავი ნულოვანი ობიექტი"),
|
||||
("layer.create", "Create 3D Layer Instance", "3D შრის ეგზემპლარის შექმნა"),
|
||||
("", "Camera", "კამერა"),
|
||||
("camera.fromView", "Create Camera from 3D View", "კამერის შექმნა 3D ხედიდან"),
|
||||
("camera.stereoRig", "Create Stereo 3D Rig", "სტერეო 3D სისტემის შექმნა"),
|
||||
("camera.orbitNull", "Create Orbit Null", "ორბიტის ნულოვანი ობიექტის შექმნა"),
|
||||
("camera.fromModel", "Create Cameras from 3D Model", "კამერების შექმნა 3D მოდელიდან"),
|
||||
("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", "ფოკუსური მანძილის მიბმა ინტერესის წერტილზე"),
|
||||
("camera.linkFocusToLayer", "Link Focus Distance to Layer", "ფოკუსური მანძილის მიბმა შრეზე"),
|
||||
("camera.setFocusToLayer", "Set Focus Distance to Layer", "ფოკუსური მანძილის დაყენება შრეზე"),
|
||||
("layer.cameraSettings", "Camera Settings...", "კამერის პარამეტრები..."),
|
||||
("view.reset3DView", "Reset 3D View", "3D ხედის ჩამოყრა"),
|
||||
("", "Light", "სინათლე"),
|
||||
("light.fromModel", "Create Lights from 3D Model", "სინათლის წყაროების შექმნა 3D მოდელიდან"),
|
||||
("light.controlWithCamera", "Control Light with Camera", "სინათლის მართვა კამერით"),
|
||||
("light.environmentBackground", "Create Environment Light Background Layer", "გარემოს სინათლის ფონური შრის შექმნა"),
|
||||
("", "Material", "მასალა"),
|
||||
("material.revealSource", "Reveal Material Source in Project", "მასალის წყაროს ჩვენება პროექტში"),
|
||||
("material.reset", "Reset Material", "მასალის ჩამოყრა"),
|
||||
("material.duplicateAssign", "Duplicate and Assign Material", "მასალის დუბლირება და მინიჭება"),
|
||||
("layer.autoTrace", "Auto-trace...", "ავტომატური ვექტორიზაცია..."),
|
||||
("layer.precompose", "Pre-compose...", "კომპოზიციაში მოთავსება..."),
|
||||
("layer.sceneEditDetection", "Scene Edit Detection...", "სცენების ჭრილების ამოცნობა..."),
|
||||
("", "Effect", "ეფექტი"),
|
||||
("window.panel", "Effect Controls", "ეფექტის მართვა"),
|
||||
("effect.applyLast", "Last Effect", "ბოლო ეფექტი"),
|
||||
("effect.removeAll", "Remove All", "ყველას წაშლა"),
|
||||
("effect.manage", "Manage Effects...", "ეფექტების მართვა..."),
|
||||
("effect.plugins.load", "Load Effect Plug-in...", "ეფექტის პლაგინის ჩატვირთვა..."),
|
||||
("", "Animation", "ანიმაცია"),
|
||||
("anim.savePreset", "Save Animation Preset...", "ანიმაციის პრესეტის შენახვა..."),
|
||||
("anim.applyPreset", "Apply Animation Preset...", "ანიმაციის პრესეტის გამოყენება..."),
|
||||
("", "Recent Animation Presets", "ბოლო ანიმაციის პრესეტები"),
|
||||
("anim.clearRecentPresets", "Clear Recent Presets", "ბოლო პრესეტების სიის გასუფთავება"),
|
||||
("anim.browsePresets", "Browse Presets...", "პრესეტების დათვალიერება..."),
|
||||
("", "Text Animation Presets", "ტექსტის ანიმაციის პრესეტები"),
|
||||
("layer.applyTextPreset", "Typewriter", "საბეჭდი მანქანა"),
|
||||
("layer.applyTextPreset", "Fade Up Characters", "ასოების ამოსვლა გამოჩენით"),
|
||||
("layer.applyTextPreset", "Bounce In Words", "სიტყვების ხტუნვით შემოსვლა"),
|
||||
("layer.applyTextPreset", "Tracking In", "ასოების შეკუმშვით შემოსვლა"),
|
||||
("layer.applyTextPreset", "Scramble", "არევა"),
|
||||
("layer.applyTextPreset", "Blur In", "გამოჩენა გაბუნდოვნებიდან"),
|
||||
("layer.applyTextPreset", "Jitter", "კანკალი"),
|
||||
("layer.applyTextPreset", "Drop In Lines", "სტრიქონების ჩამოვარდნა"),
|
||||
("anim.addKeyframe", "Add Keyframe", "საკვანძო კადრის დამატება"),
|
||||
("keys.toggleHold", "Toggle Hold Keyframe", "ფიქსირებული საკვანძო კადრის გადართვა"),
|
||||
("keys.interpolation", "Keyframe Interpolation...", "საკვანძო კადრის ინტერპოლაცია..."),
|
||||
("keys.velocity", "Keyframe Velocity...", "საკვანძო კადრის სიჩქარე..."),
|
||||
("", "Keyframe Assistant", "საკვანძო კადრების ასისტენტი"),
|
||||
("keys.audioToKeyframes", "Convert Audio to Keyframes", "აუდიოს საკვანძო კადრებად გარდაქმნა"),
|
||||
("prop.convertExpressionToKeyframes", "Convert Expression to Keyframes", "ექსპრესიის საკვანძო კადრებად გარდაქმნა"),
|
||||
("keys.easyEase", "Easy Ease", "რბილი შენელება"),
|
||||
("keys.easyEaseIn", "Easy Ease In", "რბილი შენელება შესვლისას"),
|
||||
("keys.easyEaseOut", "Easy Ease Out", "რბილი შენელება გამოსვლისას"),
|
||||
("keys.exponentialScale", "Exponential Scale", "ექსპონენციალური მასშტაბი"),
|
||||
("keys.rpfCameraImport", "RPF Camera Import", "RPF კამერის იმპორტი"),
|
||||
("layer.sequence", "Sequence Layers...", "შრეების თანმიმდევრობით განლაგება..."),
|
||||
("keys.timeReverse", "Time-Reverse Keyframes", "საკვანძო კადრების დროში შებრუნება"),
|
||||
("", "Animate Text", "ტექსტის ანიმირება"),
|
||||
("layer.enablePerChar3D", "Enable Per-character 3D", "თითოეული ასოს 3D-ს ჩართვა"),
|
||||
("layer.addTextAnimator", "Anchor Point", "საყრდენი წერტილი"),
|
||||
("layer.addTextAnimator", "Position", "პოზიცია"),
|
||||
("layer.addTextAnimator", "Scale", "მასშტაბი"),
|
||||
("layer.addTextAnimator", "Skew", "დახრა"),
|
||||
("layer.addTextAnimator", "Rotation", "შემობრუნება"),
|
||||
("layer.addTextAnimator", "Opacity", "გაუმჭვირვალობა"),
|
||||
("layer.addTextAnimator", "All Transform Properties", "გარდაქმნის ყველა თვისება"),
|
||||
("", "Fill Color", "შევსების ფერი"),
|
||||
("layer.addTextAnimator", "RGB", "RGB"),
|
||||
("layer.addTextAnimator", "Hue", "ფერის ტონი"),
|
||||
("layer.addTextAnimator", "Saturation", "გაჯერება"),
|
||||
("layer.addTextAnimator", "Brightness", "სიკაშკაშე"),
|
||||
("", "Stroke Color", "კონტურის ფერი"),
|
||||
("layer.addTextAnimator", "Stroke Width", "კონტურის სისქე"),
|
||||
("", "Front", "წინა მხარე"),
|
||||
("", "Bevel", "წაკვეთა"),
|
||||
("", "Side", "გვერდითი მხარე"),
|
||||
("", "Back", "უკანა მხარე"),
|
||||
("layer.addTextAnimator", "Color", "ფერი"),
|
||||
("layer.addTextAnimator", "Tracking", "ასოთაშორისი ინტერვალი"),
|
||||
("layer.addTextAnimator", "Line Anchor", "სტრიქონის საყრდენი"),
|
||||
("layer.addTextAnimator", "Line Spacing", "სტრიქონთა დაშორება"),
|
||||
("layer.addTextAnimator", "Character Offset", "ასოს წანაცვლება"),
|
||||
("layer.addTextAnimator", "Character Value", "ასოს მნიშვნელობა"),
|
||||
("layer.addTextAnimator", "Blur", "გაბუნდოვნება"),
|
||||
("text.animatorFontAxes", "Variable Font Axes", "ცვლადი შრიფტის ღერძები"),
|
||||
("", "Add Text Selector", "ტექსტის სელექტორის დამატება"),
|
||||
("text.addSelector", "Range", "დიაპაზონი"),
|
||||
("text.addSelector", "Wiggly", "რხევითი"),
|
||||
("text.addSelector", "Expression", "ექსპრესია"),
|
||||
("text.removeAllAnimators", "Remove All Text Animators", "ტექსტის ყველა ანიმატორის წაშლა"),
|
||||
("prop.setExpression", "Add Expression", "ექსპრესიის დამატება"),
|
||||
("essential.addProperty", "Add Property to Essential Graphics", "თვისების დამატება ძირითად გრაფიკაში"),
|
||||
("prop.separateDimensions", "Separate Dimensions", "განზომილებების გაყოფა"),
|
||||
("track.camera", "Track Camera", "კამერის ტრეკინგი"),
|
||||
("track.warpStabilizer", "Warp Stabilizer VFX", "Warp Stabilizer VFX"),
|
||||
("track.motion", "Track Motion", "მოძრაობის ტრეკინგი"),
|
||||
("track.stabilize", "Stabilize Motion", "მოძრაობის სტაბილიზაცია"),
|
||||
("track.property", "Track this Property", "ამ თვისების ტრეკინგი"),
|
||||
("anim.reveal", "Reveal Properties with Keyframes", "საკვანძო კადრების მქონე თვისებების ჩვენება"),
|
||||
("anim.reveal", "Reveal Properties with Animation", "ანიმაციის მქონე თვისებების ჩვენება"),
|
||||
("anim.reveal", "Reveal All Modified Properties", "ყველა შეცვლილი თვისების ჩვენება"),
|
||||
("", "View", "ხედი"),
|
||||
("view.newViewer", "New Viewer", "ახალი დამთვალიერებელი"),
|
||||
("view.splitLockedViewer", "Split with New Locked Viewer", "დაყოფა ახალი ჩაკეტილი დამთვალიერებელით"),
|
||||
("view.zoomIn", "Zoom In", "გადიდება"),
|
||||
("view.zoomOut", "Zoom Out", "დაპატარავება"),
|
||||
("", "Resolution", "გარჩევადობა"),
|
||||
("view.res.full", "Full", "სრული"),
|
||||
("view.res.half", "Half", "ნახევარი"),
|
||||
("view.res.third", "Third", "მესამედი"),
|
||||
("view.res.quarter", "Quarter", "მეოთხედი"),
|
||||
("view.res.custom", "Custom...", "მორგებული..."),
|
||||
("view.displayColorManagement", "Use Display Color Management", "ეკრანის ფერთა მართვის გამოყენება"),
|
||||
("", "Simulate Output", "გამოტანის იმიტაცია"),
|
||||
("view.simulateOutput", "No Output Simulation", "გამოტანის იმიტაციის გარეშე"),
|
||||
("view.simulateOutput", "HDTV (Rec. 709)", "HDTV (Rec. 709)"),
|
||||
("view.simulateOutput", "SDTV NTSC", "SDTV NTSC"),
|
||||
("view.simulateOutput", "SDTV PAL", "SDTV PAL"),
|
||||
("view.simulateOutput", "Legacy Macintosh RGB (Gamma 1.8)", "ძველი Macintosh RGB (გამა 1.8)"),
|
||||
("view.simulateOutput", "Internet Standard RGB (sRGB)", "ინტერნეტის სტანდარტული RGB (sRGB)"),
|
||||
("view.simulateOutput", "UHDTV (Rec. 2020)", "UHDTV (Rec. 2020)"),
|
||||
("view.simulateOutput", "Display P3", "Display P3"),
|
||||
("view.simulateOutput", "Linear (1.0 Gamma)", "წრფივი (გამა 1.0)"),
|
||||
("view.customRgb", "My Custom RGB...", "ჩემი მორგებული RGB..."),
|
||||
("view.simulateOutput", "Custom...", "მორგებული..."),
|
||||
("view.rulers", "Show Rulers", "სახაზავების ჩვენება"),
|
||||
("", "Panel Background Color", "პანელის ფონის ფერი"),
|
||||
("view.panelBackground", "Black", "შავი"),
|
||||
("view.panelBackground", "Dark Gray", "მუქი ნაცრისფერი"),
|
||||
("view.panelBackground", "Medium Gray (Default)", "საშუალო ნაცრისფერი (ნაგულისხმევი)"),
|
||||
("view.panelBackground", "Light Gray", "ღია ნაცრისფერი"),
|
||||
("view.panelBackground", "White", "თეთრი"),
|
||||
("view.panelBackground", "Custom", "მორგებული"),
|
||||
("view.panelBackground", "Select Custom Background Color...", "მორგებული ფონის ფერის არჩევა..."),
|
||||
("view.guides", "Show Guides", "დამხმარე ხაზების ჩვენება"),
|
||||
("view.snapToGuides", "Snap to Guides", "დამხმარე ხაზებზე მიბმა"),
|
||||
("view.lockGuides", "Lock Guides", "დამხმარე ხაზების ჩაკეტვა"),
|
||||
("view.addGuide", "Add Guide...", "დამხმარე ხაზის დამატება..."),
|
||||
("view.addPercentGuide", "Add Percentage Guide...", "პროცენტული დამხმარე ხაზის დამატება..."),
|
||||
("view.convertGuides", "Convert Guides to Percentage", "დამხმარე ხაზების პროცენტულად გარდაქმნა"),
|
||||
("view.convertGuides", "Convert Guides to Pixels", "დამხმარე ხაზების პიქსელებად გარდაქმნა"),
|
||||
("view.clearGuides", "Clear Guides", "დამხმარე ხაზების გასუფთავება"),
|
||||
("view.importGuides", "Import Guides...", "დამხმარე ხაზების იმპორტი..."),
|
||||
("view.exportGuides", "Export Guides...", "დამხმარე ხაზების ექსპორტი..."),
|
||||
("view.grid", "Show Grid", "ბადის ჩვენება"),
|
||||
("view.snapToGrid", "Snap to Grid", "ბადეზე მიბმა"),
|
||||
("view.snapping", "Snapping", "მიბმა"),
|
||||
("view.options", "View Options...", "ხედის პარამეტრები..."),
|
||||
("view.layerControls", "Show Layer Controls", "შრის მართვის ელემენტების ჩვენება"),
|
||||
("", "Switch View Layout", "ხედების განლაგების გადართვა"),
|
||||
("view.layout", "1 View", "1 ხედი"),
|
||||
("view.layout", "2 Views", "2 ხედი"),
|
||||
("view.layout", "4 Views", "4 ხედი"),
|
||||
("view.shareViewOptions", "Share View Options", "ხედის პარამეტრების გაზიარება"),
|
||||
("", "Switch 3D View", "3D ხედის გადართვა"),
|
||||
("view.3d.activeCamera", "Active Camera", "აქტიური კამერა"),
|
||||
("view.3d.default", "Default", "ნაგულისხმევი"),
|
||||
("view.3d.front", "Front", "წინიდან"),
|
||||
("view.3d.left", "Left", "მარცხნიდან"),
|
||||
("view.3d.top", "Top", "ზემოდან"),
|
||||
("view.3d.back", "Back", "უკნიდან"),
|
||||
("view.3d.right", "Right", "მარჯვნიდან"),
|
||||
("view.3d.bottom", "Bottom", "ქვემოდან"),
|
||||
("view.3d.custom1", "Custom View 1", "მორგებული ხედი 1"),
|
||||
("view.3d.custom2", "Custom View 2", "მორგებული ხედი 2"),
|
||||
("view.3d.custom3", "Custom View 3", "მორგებული ხედი 3"),
|
||||
("", "Assign Shortcut to 3D View", "მალსახმობის მინიჭება 3D ხედისთვის"),
|
||||
("view.3d.last", "Switch to Last 3D View", "ბოლო 3D ხედზე გადართვა"),
|
||||
("view.lookAtSelected", "Look at Selected Layers", "მონიშნული შრეების დათვალიერება"),
|
||||
("view.lookAtAll", "Look at All Layers", "ყველა შრის დათვალიერება"),
|
||||
("time.set", "Go to Time...", "დროზე გადასვლა..."),
|
||||
("view.fullScreen", "Enter Full Screen", "სრულეკრანიან რეჟიმში გადასვლა"),
|
||||
("", "Window", "ფანჯარა"),
|
||||
("", "Workspace", "სამუშაო სივრცე"),
|
||||
("window.workspace", "Default", "ნაგულისხმევი"),
|
||||
("window.workspace", "Review", "განხილვა"),
|
||||
("window.workspace", "Learn", "სწავლა"),
|
||||
("window.workspace", "Small Screen", "პატარა ეკრანი"),
|
||||
("window.workspace", "Standard", "სტანდარტული"),
|
||||
("window.workspace", "All Panels", "ყველა პანელი"),
|
||||
("window.workspace", "Animation", "ანიმაცია"),
|
||||
("window.workspace", "Color", "ფერი"),
|
||||
("window.workspace", "Effects", "ეფექტები"),
|
||||
("window.workspace", "Essential Graphics", "ძირითადი გრაფიკა"),
|
||||
("window.workspace", "Minimal", "მინიმალური"),
|
||||
("window.workspace", "Motion Tracking", "მოძრაობის ტრეკინგი"),
|
||||
("window.workspace", "Paint", "ხატვა"),
|
||||
("window.workspace", "Text", "ტექსტი"),
|
||||
("window.workspace", "Undocked Panels", "მოხსნილი პანელები"),
|
||||
("window.resetWorkspace", "Reset to Saved Layout", "შენახულ განლაგებაზე დაბრუნება"),
|
||||
("window.saveWorkspace", "Save Changes to this Workspace", "ამ სამუშაო სივრცეში ცვლილებების შენახვა"),
|
||||
("window.saveWorkspaceAs", "Save as New Workspace...", "ახალ სამუშაო სივრცედ შენახვა..."),
|
||||
("window.editWorkspaces", "Edit Workspaces...", "სამუშაო სივრცეების რედაქტირება..."),
|
||||
("", "Assign Shortcut to Workspace", "მალსახმობის მინიჭება სამუშაო სივრცისთვის"),
|
||||
("window.panel", "Align", "სწორება"),
|
||||
("window.panel", "Audio", "აუდიო"),
|
||||
("window.panel", "Brushes", "ფუნჯები"),
|
||||
("window.panel", "Character", "სიმბოლო"),
|
||||
("window.panel", "Content-Aware Fill", "შიგთავსის გათვალისწინებით შევსება"),
|
||||
("window.panel", "Effects & Presets", "ეფექტები და პრესეტები"),
|
||||
("window.panel", "Essential Graphics", "ძირითადი გრაფიკა"),
|
||||
("window.panel", "Info", "ინფორმაცია"),
|
||||
("help.inAppTutorials", "Learn", "სწავლა"),
|
||||
("window.panel", "Lumetri Scopes", "Lumetri Scopes"),
|
||||
("window.panel", "Mask Interpolation", "ნიღბის ინტერპოლაცია"),
|
||||
("window.panel", "Media Browser", "მედიის ბრაუზერი"),
|
||||
("window.panel", "Metadata", "მეტამონაცემები"),
|
||||
("window.panel", "Motion Sketch", "მოძრაობის ესკიზი"),
|
||||
("window.panel", "Paint", "ხატვა"),
|
||||
("window.panel", "Paragraph", "აბზაცი"),
|
||||
("window.panel", "Preview", "წინასწარი ნახვა"),
|
||||
("window.panel", "Progress", "პროგრესი"),
|
||||
("window.panel", "Properties", "თვისებები"),
|
||||
("window.panel", "Script Console", "სკრიპტის კონსოლი"),
|
||||
("window.panel", "Smoother", "დამგლუვებელი"),
|
||||
("window.panel", "Tools", "ხელსაწყოები"),
|
||||
("window.panel", "Tracker", "ტრეკერი"),
|
||||
("window.panel", "Wiggler", "რხევის გენერატორი"),
|
||||
("window.panel", "Composition", "კომპოზიცია"),
|
||||
("window.panel", "Flowchart", "ბლოკ-სქემა"),
|
||||
("window.panel", "Footage", "ვიდეომასალა"),
|
||||
("window.panel", "Layer", "შრე"),
|
||||
("window.panel", "Project", "პროექტი"),
|
||||
("window.panel", "Render Queue", "რენდერის რიგი"),
|
||||
("window.panel", "Timeline", "დროის ხაზი"),
|
||||
("window.panel", "Create Nulls From Paths", "ნულოვანი ობიექტების შექმნა ტრაექტორიებიდან"),
|
||||
("window.panel", "VR Comp Editor", "VR კომპოზიციის რედაქტორი"),
|
||||
("", "Help", "დახმარება"),
|
||||
("help.docs", "EffectCraft Help...", "EffectCraft-ის დახმარება..."),
|
||||
("help.docs", "Scripting Help...", "სკრიპტების დახმარება..."),
|
||||
("help.docs", "Expression Reference...", "ექსპრესიების ცნობარი..."),
|
||||
("help.docs", "Effect Reference...", "ეფექტების ცნობარი..."),
|
||||
("anim.browsePresets", "Animation Presets...", "ანიმაციის პრესეტები..."),
|
||||
("app.keyboardShortcuts", "Keyboard Shortcuts...", "კლავიატურის მალსახმობები..."),
|
||||
("help.inAppTutorials", "In-App Tutorials...", "აპის შიგნით გაკვეთილები..."),
|
||||
("help.onlineTutorials", "Online Tutorials...", "ონლაინ გაკვეთილები..."),
|
||||
("help.systemReport", "System Compatibility Report...", "სისტემის თავსებადობის ანგარიში..."),
|
||||
("help.enableLogging", "Enable Logging", "ჟურნალის ჩართვა"),
|
||||
("help.revealLogFile", "Reveal Logging File", "ჟურნალის ფაილის ჩვენება"),
|
||||
("help.discord", "Join the ArtCraft Discord...", "შემოუერთდით ArtCraft-ის Discord-ს..."),
|
||||
("help.reportIssue", "Provide Feedback...", "გამოხმაურების გაგზავნა..."),
|
||||
("help.website", "ArtCraft Website", "ArtCraft-ის ვებსაიტი"),
|
||||
("help.appPage", "EffectCraft Home Page", "EffectCraft-ის საწყისი გვერდი"),
|
||||
("help.github", "EffectCraft on GitHub", "EffectCraft GitHub-ზე"),
|
||||
("file.openDemoProject", "Open Demo Project", "სადემონსტრაციო პროექტის გახსნა"),
|
||||
];
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -6649,6 +7367,55 @@ mod tests {
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
/// Labels drawn outside the menu bar take the menu bar's row for their command (with `…` for
|
||||
/// `...`), else the panel catalog's row, else stay as they are.
|
||||
#[test]
|
||||
fn command_label_reads_the_menu_row_then_the_panel_row() {
|
||||
set_current("zh-hans");
|
||||
assert_eq!(command_label("window.panel", "Project"), lookup(SIMPLIFIED_CHINESE, "window.panel", "Project"));
|
||||
let (command, en, translated) = SIMPLIFIED_CHINESE.iter().find(|(c, en, _)| !c.is_empty() && en.ends_with("...")).expect("a menu row ending in ...");
|
||||
assert_eq!(command_label(command, &format!("{}…", en.trim_end_matches("..."))), *translated);
|
||||
assert_eq!(command_label("no.such.command", "Cancel"), tr("Cancel"));
|
||||
assert_eq!(command_label("window.panel", "Comp 1"), "Comp 1");
|
||||
set_current("en");
|
||||
assert_eq!(command_label("window.panel", "Project"), "Project");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_page_titles_read_the_menu_row() {
|
||||
set_current("zh-hans");
|
||||
let menu_row = lookup(SIMPLIFIED_CHINESE, "app.settings", "Type...");
|
||||
assert_eq!(settings_page_title("Type"), menu_row.trim_end_matches("..."));
|
||||
assert_ne!(settings_page_title("Type"), tr("Type"), "the page is about text, the panel row is the kind");
|
||||
assert_eq!(settings_page_title("Roto Brush"), tr("Roto Brush"));
|
||||
set_current("en");
|
||||
assert_eq!(settings_page_title("Type"), "Type");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn match_system_takes_the_first_browser_language_with_a_catalog() {
|
||||
let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
let any = |_: &str| true;
|
||||
assert_eq!(super::first_supported(None, &tags(&["cs-CZ", "cs", "en"]), any), "cs");
|
||||
assert_eq!(super::first_supported(None, &tags(&["nl-NL", "cs"]), any), "cs");
|
||||
assert_eq!(super::first_supported(None, &tags(&["en-US", "cs"]), any), "en");
|
||||
assert_eq!(super::first_supported(None, &tags(&["nl-NL"]), any), "en");
|
||||
assert_eq!(super::first_supported(None, &tags(&[]), any), "en");
|
||||
assert_eq!(super::first_supported(Some("uk"), &tags(&["cs"]), any), "uk");
|
||||
assert_eq!(super::first_supported(Some("nl"), &tags(&["cs"]), any), "cs");
|
||||
}
|
||||
|
||||
/// The web build installs no CJK or Georgian font: Match System passes over those browser
|
||||
/// languages, while `?lang=` still selects them.
|
||||
#[test]
|
||||
fn web_match_system_skips_languages_without_a_font() {
|
||||
let tags = |t: &[&str]| t.iter().map(|s| s.to_string()).collect::<Vec<_>>();
|
||||
assert_eq!(super::first_supported(None, &tags(&["ja-JP", "zh-CN", "zh-TW", "ka-GE", "cs"]), super::web_draws), "cs");
|
||||
assert_eq!(super::first_supported(None, &tags(&["ka"]), super::web_draws), "en");
|
||||
assert_eq!(super::first_supported(Some("ka"), &tags(&["cs"]), super::web_draws), "ka");
|
||||
assert_eq!(super::first_supported(Some("ja"), &tags(&[]), super::web_draws), "ja");
|
||||
}
|
||||
|
||||
fn keys(nodes: &[MenuNode], out: &mut BTreeSet<(String, String)>) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
@@ -6780,6 +7547,9 @@ mod tests {
|
||||
(Some("cs-CZ"), "cs"),
|
||||
(Some("cs_CZ.UTF-8"), "cs"),
|
||||
(Some("CS_cz.UTF-8"), "cs"),
|
||||
(Some("ka"), "ka"),
|
||||
(Some("ka-GE"), "ka"),
|
||||
(Some("ka_GE.UTF-8"), "ka"),
|
||||
(Some("en-US"), "en"),
|
||||
(Some("jv-ID"), "en"),
|
||||
(Some("de-DE"), "en"),
|
||||
@@ -6813,9 +7583,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn every_translated_entry_keeps_the_english_shape() {
|
||||
for (code, catalog) in
|
||||
[("zh-hans", SIMPLIFIED_CHINESE), ("zh-hant", TRADITIONAL_CHINESE), ("uk", UKRAINIAN), ("es", SPANISH), ("pt-br", PORTUGUESE), ("cs", CZECH)]
|
||||
{
|
||||
for (code, catalog) in [
|
||||
("zh-hans", SIMPLIFIED_CHINESE),
|
||||
("zh-hant", TRADITIONAL_CHINESE),
|
||||
("uk", UKRAINIAN),
|
||||
("es", SPANISH),
|
||||
("pt-br", PORTUGUESE),
|
||||
("cs", CZECH),
|
||||
("ka", GEORGIAN),
|
||||
] {
|
||||
for (command, en, translated) in catalog {
|
||||
assert_eq!(en.contains("..."), translated.contains("..."), "{code} {command} / {en}: {translated}");
|
||||
let brackets = |s: &str| s.chars().filter(|c| *c == '(' || *c == ')').count();
|
||||
|
||||
@@ -70,12 +70,14 @@ fn catalog(language: &str) -> Option<&'static Catalog> {
|
||||
static SIMPLIFIED: Catalog = Catalog { rows: SIMPLIFIED_CHINESE, categories: SIMPLIFIED_CHINESE_CATEGORIES, index: OnceLock::new() };
|
||||
static TRADITIONAL: Catalog = Catalog { rows: TRADITIONAL_CHINESE, categories: TRADITIONAL_CHINESE_CATEGORIES, index: OnceLock::new() };
|
||||
static JAPANESE: Catalog = Catalog { rows: JAPANESE_EFFECTS, categories: JAPANESE_CATEGORIES, index: OnceLock::new() };
|
||||
static GEORGIAN_CATALOG: Catalog = Catalog { rows: GEORGIAN, categories: GEORGIAN_CATEGORIES, index: OnceLock::new() };
|
||||
match language {
|
||||
"zh-hans" => Some(&SIMPLIFIED),
|
||||
"zh-hant" => Some(&TRADITIONAL),
|
||||
// Japanese: the slot the Japanese data point on #821 asked for, so both languages share
|
||||
// this one catalog instead of inventing a second mechanism.
|
||||
"ja" => Some(&JAPANESE),
|
||||
"ka" => Some(&GEORGIAN_CATALOG),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -503,6 +505,346 @@ const JAPANESE_CATEGORIES: &[(&str, &str)] = &[
|
||||
("Utility", "ユーティリティ"),
|
||||
];
|
||||
|
||||
/// Georgian (`ka`): one row per effect, ids from [`SIMPLIFIED_CHINESE`]. Written from the meaning
|
||||
/// of the English names, with the terms the Georgian menus and panels use (FilmCraft's Georgian
|
||||
/// for the effects the two apps share). The `CC` series and the brand names (`Cryptomatte`,
|
||||
/// `EXtractoR`, `IDentifier`, `Key Light`, `Unmult`, `Lumetri Color`) stay in English, as
|
||||
/// Adobe's Japanese interface keeps the `CC` series; `VR` and `OCIO` keep their prefix.
|
||||
const GEORGIAN: &[Row] = &[
|
||||
("ec.3d.channelextract", "3D Channel Extract", "3D არხის ამოღება"),
|
||||
("ec.3d.cryptomatte", "Cryptomatte", "Cryptomatte"),
|
||||
("ec.3d.depthmatte", "Depth Matte", "სიღრმის მატი"),
|
||||
("ec.3d.depthoffield", "Depth of Field", "ველის სიღრმე"),
|
||||
("ec.3d.extractor", "EXtractoR", "EXtractoR"),
|
||||
("ec.3d.fog3d", "Fog 3D", "3D ნისლი"),
|
||||
("ec.3d.identifier", "IDentifier", "IDentifier"),
|
||||
("ec.3d.idmatte", "ID Matte", "ID მატი"),
|
||||
("ec.audio.backwards", "Backwards", "უკუღმა"),
|
||||
("ec.audio.basstreble", "Bass & Treble", "ბასი და ტრებლი"),
|
||||
("ec.audio.compressor", "Compressor", "კომპრესორი"),
|
||||
("ec.audio.delay", "Delay", "დაყოვნება"),
|
||||
("ec.audio.distortion", "Distortion", "დამახინჯება"),
|
||||
("ec.audio.flangechorus", "Flange & Chorus", "ფლენჯერი და ქორუსი"),
|
||||
("ec.audio.gate", "Gate", "გეიტი"),
|
||||
("ec.audio.highlowpass", "High-Low Pass", "მაღალი/დაბალი სიხშირეების ფილტრი"),
|
||||
("ec.audio.modulator", "Modulator", "მოდულატორი"),
|
||||
("ec.audio.parametriceq", "Parametric EQ", "პარამეტრული ეკვალაიზერი"),
|
||||
("ec.audio.reverb", "Reverb", "რევერბი"),
|
||||
("ec.audio.stereomixer", "Stereo Mixer", "სტერეომიქშერი"),
|
||||
("ec.audio.tone", "Tone", "ტონი"),
|
||||
("ec.blur.bilateral", "Bilateral Blur", "ბილატერალური გაბუნდოვნება"),
|
||||
("ec.blur.cameralens", "Camera Lens Blur", "კამერის ობიექტივის გაბუნდოვნება"),
|
||||
("ec.blur.camerashakedeblur", "Camera-Shake Deblur", "კამერის რყევით გაბუნდოვნების მოცილება"),
|
||||
("ec.blur.cccross", "CC Cross Blur", "CC Cross Blur"),
|
||||
("ec.blur.ccradial", "CC Radial Blur", "CC Radial Blur"),
|
||||
("ec.blur.ccradialfast", "CC Radial Fast Blur", "CC Radial Fast Blur"),
|
||||
("ec.blur.ccvector", "CC Vector Blur", "CC Vector Blur"),
|
||||
("ec.blur.channel", "Channel Blur", "არხის გაბუნდოვნება"),
|
||||
("ec.blur.compound", "Compound Blur", "შედგენილი გაბუნდოვნება"),
|
||||
("ec.blur.directional", "Directional Blur", "მიმართული გაბუნდოვნება"),
|
||||
("ec.blur.fastbox", "Fast Box Blur", "სწრაფი ბლოკური გაბუნდოვნება"),
|
||||
("ec.blur.gaussian", "Gaussian Blur", "გაუსის გაბუნდოვნება"),
|
||||
("ec.blur.radial", "Radial Blur", "რადიალური გაბუნდოვნება"),
|
||||
("ec.blur.reduceflicker", "Reduce Interlace Flicker", "ნახევარკადრული ციმციმის შემცირება"),
|
||||
("ec.blur.sharpen", "Sharpen", "სიმკვეთრე"),
|
||||
("ec.blur.smart", "Smart Blur", "ჭკვიანი გაბუნდოვნება"),
|
||||
("ec.blur.unsharp", "Unsharp Mask", "არამკვეთრი ნიღაბი"),
|
||||
("ec.channel.arithmetic", "Arithmetic", "არითმეტიკა"),
|
||||
("ec.channel.blend", "Blend", "შერევა"),
|
||||
("ec.channel.calculations", "Calculations", "გამოთვლები"),
|
||||
("ec.channel.cccomposite", "CC Composite", "CC Composite"),
|
||||
("ec.channel.combiner", "Channel Combiner", "არხების გამაერთიანებელი"),
|
||||
("ec.channel.compoundarithmetic", "Compound Arithmetic", "შედგენილი არითმეტიკა"),
|
||||
("ec.channel.invert", "Invert", "ინვერსია"),
|
||||
("ec.channel.minimax", "Minimax", "მინიმაქსი"),
|
||||
("ec.channel.removecolormatting", "Remove Color Matting", "ფერადი მატინგის მოცილება"),
|
||||
("ec.channel.setchannels", "Set Channels", "არხების დაყენება"),
|
||||
("ec.channel.setmatte", "Set Matte", "მატის დაყენება"),
|
||||
("ec.channel.shiftchannels", "Shift Channels", "არხების გადანაცვლება"),
|
||||
("ec.channel.solidcomposite", "Solid Composite", "სოლიდთან კომპოზიტი"),
|
||||
("ec.color.autocolor", "Auto Color", "ავტოფერი"),
|
||||
("ec.color.autocontrast", "Auto Contrast", "ავტოკონტრასტი"),
|
||||
("ec.color.autolevels", "Auto Levels", "ავტოდონეები"),
|
||||
("ec.color.blackwhite", "Black & White", "შავ-თეთრი"),
|
||||
("ec.color.brightnesscontrast", "Brightness & Contrast", "სიკაშკაშე და კონტრასტი"),
|
||||
("ec.color.broadcast", "Broadcast Colors", "სამაუწყებლო ფერები"),
|
||||
("ec.color.cccolorneutralizer", "CC Color Neutralizer", "CC Color Neutralizer"),
|
||||
("ec.color.cccoloroffset", "CC Color Offset", "CC Color Offset"),
|
||||
("ec.color.cckernel", "CC Kernel", "CC Kernel"),
|
||||
("ec.color.cctoner", "CC Toner", "CC Toner"),
|
||||
("ec.color.changecolor", "Change Color", "ფერის შეცვლა"),
|
||||
("ec.color.changetocolor", "Change to Color", "ფერად შეცვლა"),
|
||||
("ec.color.channelmixer", "Channel Mixer", "არხების მიქშერი"),
|
||||
("ec.color.colorama", "Colorama", "კოლორამა"),
|
||||
("ec.color.colorbalance", "Color Balance", "ფერთა ბალანსი"),
|
||||
("ec.color.colorbalancehls", "Color Balance (HLS)", "ფერთა ბალანსი (HLS)"),
|
||||
("ec.color.colorlink", "Color Link", "ფერის ბმა"),
|
||||
("ec.color.colorstabilizer", "Color Stabilizer", "ფერის სტაბილიზატორი"),
|
||||
("ec.color.curves", "Curves", "მრუდები"),
|
||||
("ec.color.equalize", "Equalize", "გათანაბრება"),
|
||||
("ec.color.exposure", "Exposure", "ექსპოზიცია"),
|
||||
("ec.color.gammapedestalgain", "Gamma/Pedestal/Gain", "გამა/საფეხური/გაძლიერება"),
|
||||
("ec.color.huesaturation", "Hue/Saturation", "ფერის ტონი/გაჯერება"),
|
||||
("ec.color.leavecolor", "Leave Color", "ფერის დატოვება"),
|
||||
("ec.color.levels", "Levels", "დონეები"),
|
||||
("ec.color.levelsic", "Levels (Individual Controls)", "დონეები (ცალკეული მართვა)"),
|
||||
("ec.color.lumetri", "Lumetri Color", "Lumetri Color"),
|
||||
("ec.color.ociocdl", "OCIO CDL Transform", "OCIO CDL გარდაქმნა"),
|
||||
("ec.color.ociocolorspace", "OCIO Color Space Transform", "OCIO ფერთა სივრცის გარდაქმნა"),
|
||||
("ec.color.ociodisplay", "OCIO Display Transform", "OCIO ეკრანის გარდაქმნა"),
|
||||
("ec.color.ociofile", "OCIO File Transform", "OCIO ფაილის გარდაქმნა"),
|
||||
("ec.color.ociolook", "OCIO Look Transform", "OCIO ვიზუალური სტილის გარდაქმნა"),
|
||||
("ec.color.photofilter", "Photo Filter", "ფოტოფილტრი"),
|
||||
("ec.color.psarbitrarymap", "PS Arbitrary Map", "PS ნებისმიერი რუკა"),
|
||||
("ec.color.selectivecolor", "Selective Color", "შერჩევითი ფერი"),
|
||||
("ec.color.shadowhighlight", "Shadow/Highlight", "ჩრდილები/ღია ტონები"),
|
||||
("ec.color.tint", "Tint", "ტონირება"),
|
||||
("ec.color.tritone", "Tritone", "სამტონიანი"),
|
||||
("ec.color.vibrance", "Vibrance", "სიხალასე"),
|
||||
("ec.color.videolimiter", "Video Limiter", "ვიდეოლიმიტერი"),
|
||||
("ec.control.angle", "Angle Control", "კუთხის მართვა"),
|
||||
("ec.control.checkbox", "Checkbox Control", "მოსანიშნი ველის მართვა"),
|
||||
("ec.control.color", "Color Control", "ფერის მართვა"),
|
||||
("ec.control.dropdown", "Dropdown Menu Control", "ჩამოსაშლელი მენიუს მართვა"),
|
||||
("ec.control.layer", "Layer Control", "შრის მართვა"),
|
||||
("ec.control.point", "Point Control", "წერტილის მართვა"),
|
||||
("ec.control.point3d", "3D Point Control", "3D წერტილის მართვა"),
|
||||
("ec.control.slider", "Slider Control", "სლაიდერის მართვა"),
|
||||
("ec.control.stereo3d", "Stereo 3D Controls", "სტერეო 3D მართვა"),
|
||||
("ec.distort.bezierwarp", "Bezier Warp", "ბეზიეს დეფორმაცია"),
|
||||
("ec.distort.bulge", "Bulge", "გამობერილობა"),
|
||||
("ec.distort.ccbender", "CC Bender", "CC Bender"),
|
||||
("ec.distort.ccbendit", "CC Bend It", "CC Bend It"),
|
||||
("ec.distort.ccblobbylize", "CC Blobbylize", "CC Blobbylize"),
|
||||
("ec.distort.ccflomotion", "CC Flo Motion", "CC Flo Motion"),
|
||||
("ec.distort.ccgriddler", "CC Griddler", "CC Griddler"),
|
||||
("ec.distort.cclens", "CC Lens", "CC Lens"),
|
||||
("ec.distort.ccpageturn", "CC Page Turn", "CC Page Turn"),
|
||||
("ec.distort.ccpowerpin", "CC Power Pin", "CC Power Pin"),
|
||||
("ec.distort.ccripplepulse", "CC Ripple Pulse", "CC Ripple Pulse"),
|
||||
("ec.distort.ccslant", "CC Slant", "CC Slant"),
|
||||
("ec.distort.ccsmear", "CC Smear", "CC Smear"),
|
||||
("ec.distort.ccsplit", "CC Split", "CC Split"),
|
||||
("ec.distort.ccsplit2", "CC Split 2", "CC Split 2"),
|
||||
("ec.distort.cctiler", "CC Tiler", "CC Tiler"),
|
||||
("ec.distort.cornerpin", "Corner Pin", "კუთხეების მიმაგრება"),
|
||||
("ec.distort.displacementmap", "Displacement Map", "წანაცვლების რუკა"),
|
||||
("ec.distort.liquify", "Liquify", "გათხევადება"),
|
||||
("ec.distort.magnify", "Magnify", "გადიდება"),
|
||||
("ec.distort.meshwarp", "Mesh Warp", "ბადისებრი დეფორმაცია"),
|
||||
("ec.distort.mirror", "Mirror", "სარკე"),
|
||||
("ec.distort.offset", "Offset", "წანაცვლება"),
|
||||
("ec.distort.opticscompensation", "Optics Compensation", "ოპტიკის კომპენსაცია"),
|
||||
("ec.distort.polar", "Polar Coordinates", "პოლარული კოორდინატები"),
|
||||
("ec.distort.puppet", "Puppet", "მარიონეტი"),
|
||||
("ec.distort.reshape", "Reshape", "ფორმის შეცვლა"),
|
||||
("ec.distort.ripple", "Ripple", "ტალღა"),
|
||||
("ec.distort.rollingshutterrepair", "Rolling Shutter Repair", "მცოცავი ჩამკეტის გასწორება"),
|
||||
("ec.distort.smear", "Smear", "გადღაბნა"),
|
||||
("ec.distort.spherize", "Spherize", "სფერიზება"),
|
||||
("ec.distort.transform", "Transform", "გარდაქმნა"),
|
||||
("ec.distort.turbulentdisplace", "Turbulent Displace", "ტურბულენტური წანაცვლება"),
|
||||
("ec.distort.twirl", "Twirl", "გრიგალი"),
|
||||
("ec.distort.twirllegacy", "Twirl (Legacy)", "გრიგალი (მოძველებული)"),
|
||||
("ec.distort.upscale", "Detail-preserving Upscale", "გადიდება დეტალების შენარჩუნებით"),
|
||||
("ec.distort.warp", "Warp", "დეფორმაცია"),
|
||||
("ec.distort.warpstabilizer", "Warp Stabilizer", "დეფორმაციის სტაბილიზატორი"),
|
||||
("ec.distort.wavewarp", "Wave Warp", "ტალღური დეფორმაცია"),
|
||||
("ec.generate.advancedlightning", "Advanced Lightning", "გაფართოებული ელვა"),
|
||||
("ec.generate.audiospectrum", "Audio Spectrum", "აუდიოს სპექტრი"),
|
||||
("ec.generate.audiowaveform", "Audio Waveform", "აუდიოს ტალღის ფორმა"),
|
||||
("ec.generate.beam", "Beam", "სხივი"),
|
||||
("ec.generate.ccgluegun", "CC Glue Gun", "CC Glue Gun"),
|
||||
("ec.generate.cclightburst", "CC Light Burst 2.5", "CC Light Burst 2.5"),
|
||||
("ec.generate.cclightrays", "CC Light Rays", "CC Light Rays"),
|
||||
("ec.generate.cclightsweep", "CC Light Sweep", "CC Light Sweep"),
|
||||
("ec.generate.ccthreads", "CC Threads", "CC Threads"),
|
||||
("ec.generate.cellpattern", "Cell Pattern", "უჯრედების პატერნი"),
|
||||
("ec.generate.checkerboard", "Checkerboard", "ჭადრაკის დაფა"),
|
||||
("ec.generate.circle", "Circle", "წრე"),
|
||||
("ec.generate.ellipse", "Ellipse", "ელიფსი"),
|
||||
("ec.generate.eyedropperfill", "Eyedropper Fill", "პიპეტით შევსება"),
|
||||
("ec.generate.fill", "Fill", "შევსება"),
|
||||
("ec.generate.fourcolor", "4-Color Gradient", "4-ფერიანი გრადიენტი"),
|
||||
("ec.generate.fractal", "Fractal", "ფრაქტალი"),
|
||||
("ec.generate.gradientramp", "Gradient Ramp", "გრადიენტული გადასვლა"),
|
||||
("ec.generate.grid", "Grid", "ბადე"),
|
||||
("ec.generate.lensflare", "Lens Flare", "ობიექტივის ანარეკლი"),
|
||||
("ec.generate.paintbucket", "Paint Bucket", "საღებავის ვედრო"),
|
||||
("ec.generate.radiowaves", "Radio Waves", "რადიოტალღები"),
|
||||
("ec.generate.scribble", "Scribble", "ჯღაბნა"),
|
||||
("ec.generate.stroke", "Stroke", "კონტური"),
|
||||
("ec.generate.vegas", "Vegas", "ვეგასი"),
|
||||
("ec.generate.writeon", "Write-on", "თანდათანობითი წერა"),
|
||||
("ec.key.advancedspill", "Advanced Spill Suppressor", "ფერის გადაღვრის გაფართოებული ჩამხშობი"),
|
||||
("ec.key.ccsimplewireremoval", "CC Simple Wire Removal", "CC Simple Wire Removal"),
|
||||
("ec.key.colordifference", "Color Difference Key", "ფერთა სხვაობის გასაღები"),
|
||||
("ec.key.colorkey", "Color Key", "ფერის გასაღები"),
|
||||
("ec.key.colorrange", "Color Range", "ფერების დიაპაზონი"),
|
||||
("ec.key.differencematte", "Difference Matte", "სხვაობის მატი"),
|
||||
("ec.key.extract", "Extract", "ამოღება"),
|
||||
("ec.key.innerouter", "Inner/Outer Key", "შიდა/გარე გასაღები"),
|
||||
("ec.key.keycleaner", "Key Cleaner", "გასაღების გამწმენდი"),
|
||||
("ec.key.linearcolor", "Linear Color Key", "წრფივი ფერის გასაღები"),
|
||||
("ec.key.luma", "Luma Key", "სიკაშკაშის გასაღები"),
|
||||
("ec.key.screen", "Screen Key", "ეკრანის გასაღები"),
|
||||
("ec.key.spill", "Spill Suppressor", "ფერის გადაღვრის ჩამხშობი"),
|
||||
("ec.key.unmult", "Unmult", "Unmult"),
|
||||
("ec.keying.keylight", "Key Light", "Key Light"),
|
||||
("ec.matte.mattechoker", "Matte Choker", "მატის შემკუმშველი"),
|
||||
("ec.matte.objectmatte", "Object Matte", "ობიექტის მატი"),
|
||||
("ec.matte.refinehard", "Refine Hard Matte", "მკვეთრი მატის დახვეწა"),
|
||||
("ec.matte.refinesoft", "Refine Soft Matte", "რბილი მატის დახვეწა"),
|
||||
("ec.matte.rotobrush", "Roto Brush & Refine Edge", "როტო-ფუნჯი და კიდის დახვეწა"),
|
||||
("ec.matte.simplechoker", "Simple Choker", "მარტივი შემკუმშველი"),
|
||||
("ec.noise.addgrain", "Add Grain", "მარცვლოვნების დამატება"),
|
||||
("ec.noise.curlnoise", "Curl Noise", "გრიგალური ხმაური"),
|
||||
("ec.noise.dustscratches", "Dust & Scratches", "მტვერი და ნაკაწრები"),
|
||||
("ec.noise.fractal", "Fractal Noise", "ფრაქტალური ხმაური"),
|
||||
("ec.noise.matchgrain", "Match Grain", "მარცვლოვნების მისადაგება"),
|
||||
("ec.noise.median", "Median", "მედიანა"),
|
||||
("ec.noise.medianlegacy", "Median (Legacy)", "მედიანა (მოძველებული)"),
|
||||
("ec.noise.noise", "Noise", "ხმაური"),
|
||||
("ec.noise.noisealpha", "Noise Alpha", "ალფა-ხმაური"),
|
||||
("ec.noise.noisehls", "Noise HLS", "HLS ხმაური"),
|
||||
("ec.noise.noisehlsauto", "Noise HLS Auto", "HLS ხმაური (ავტომატური)"),
|
||||
("ec.noise.removegrain", "Remove Grain", "მარცვლოვნების მოცილება"),
|
||||
("ec.noise.turbulent", "Turbulent Noise", "ტურბულენტური ხმაური"),
|
||||
("ec.obsolete.basic3d", "Basic 3D", "საბაზო 3D"),
|
||||
("ec.obsolete.basictext", "Basic Text", "საბაზო ტექსტი"),
|
||||
("ec.obsolete.gaussianlegacy", "Gaussian Blur (Legacy)", "გაუსის გაბუნდოვნება (მოძველებული)"),
|
||||
("ec.obsolete.lightning", "Lightning", "ელვა"),
|
||||
("ec.obsolete.mochashape", "Mocha shape", "Mocha-ს ფიგურა"),
|
||||
("ec.obsolete.pathtext", "Path Text", "ტექსტი ტრაექტორიაზე"),
|
||||
("ec.paint.paint", "Paint", "ხატვა"),
|
||||
("ec.perspective.3dglasses", "3D Glasses", "3D სათვალე"),
|
||||
("ec.perspective.bevelalpha", "Bevel Alpha", "ალფას ფასკა"),
|
||||
("ec.perspective.beveledges", "Bevel Edges", "კიდეების ფასკა"),
|
||||
("ec.perspective.cameratracker", "3D Camera Tracker", "3D კამერის ტრეკერი"),
|
||||
("ec.perspective.cccylinder", "CC Cylinder", "CC Cylinder"),
|
||||
("ec.perspective.ccenvironment", "CC Environment", "CC Environment"),
|
||||
("ec.perspective.ccsphere", "CC Sphere", "CC Sphere"),
|
||||
("ec.perspective.ccspotlight", "CC Spotlight", "CC Spotlight"),
|
||||
("ec.perspective.dropshadow", "Drop Shadow", "ჩრდილი"),
|
||||
("ec.perspective.radialshadow", "Radial Shadow", "რადიალური ჩრდილი"),
|
||||
("ec.sim.carddance", "Card Dance", "მოცეკვავე ბარათები"),
|
||||
("ec.sim.caustics", "Caustics", "კაუსტიკა"),
|
||||
("ec.sim.ccballaction", "CC Ball Action", "CC Ball Action"),
|
||||
("ec.sim.ccbubbles", "CC Bubbles", "CC Bubbles"),
|
||||
("ec.sim.ccdrizzle", "CC Drizzle", "CC Drizzle"),
|
||||
("ec.sim.cchair", "CC Hair", "CC Hair"),
|
||||
("ec.sim.ccmrmercury", "CC Mr. Mercury", "CC Mr. Mercury"),
|
||||
("ec.sim.ccparticlesystems2", "CC Particle Systems II", "CC Particle Systems II"),
|
||||
("ec.sim.ccparticleworld", "CC Particle World", "CC Particle World"),
|
||||
("ec.sim.ccpixelpolly", "CC Pixel Polly", "CC Pixel Polly"),
|
||||
("ec.sim.ccrainfall", "CC Rainfall", "CC Rainfall"),
|
||||
("ec.sim.ccscatterize", "CC Scatterize", "CC Scatterize"),
|
||||
("ec.sim.ccsnowfall", "CC Snowfall", "CC Snowfall"),
|
||||
("ec.sim.ccstarburst", "CC Star Burst", "CC Star Burst"),
|
||||
("ec.sim.foam", "Foam", "ქაფი"),
|
||||
("ec.sim.particleplayground", "Particle Playground", "ნაწილაკების სიმულაცია"),
|
||||
("ec.sim.shatter", "Shatter", "დამსხვრევა"),
|
||||
("ec.sim.waveworld", "Wave World", "ტალღების სამყარო"),
|
||||
("ec.stylize.brushstrokes", "Brush Strokes", "ფუნჯის შტრიხები"),
|
||||
("ec.stylize.cartoon", "Cartoon", "მულტფილმის სტილი"),
|
||||
("ec.stylize.ccblockload", "CC Block Load", "CC Block Load"),
|
||||
("ec.stylize.ccburnfilm", "CC Burn Film", "CC Burn Film"),
|
||||
("ec.stylize.ccglass", "CC Glass", "CC Glass"),
|
||||
("ec.stylize.cchextile", "CC HexTile", "CC HexTile"),
|
||||
("ec.stylize.cckaleida", "CC Kaleida", "CC Kaleida"),
|
||||
("ec.stylize.ccmrsmoothie", "CC Mr. Smoothie", "CC Mr. Smoothie"),
|
||||
("ec.stylize.ccplastic", "CC Plastic", "CC Plastic"),
|
||||
("ec.stylize.ccrepetile", "CC RepeTile", "CC RepeTile"),
|
||||
("ec.stylize.ccthreshold", "CC Threshold", "CC Threshold"),
|
||||
("ec.stylize.ccthresholdrgb", "CC Threshold RGB", "CC Threshold RGB"),
|
||||
("ec.stylize.ccvignette", "CC Vignette", "CC Vignette"),
|
||||
("ec.stylize.coloremboss", "Color Emboss", "ფერადი რელიეფი"),
|
||||
("ec.stylize.emboss", "Emboss", "რელიეფი"),
|
||||
("ec.stylize.findedges", "Find Edges", "კიდეების პოვნა"),
|
||||
("ec.stylize.glow", "Glow", "ნათება"),
|
||||
("ec.stylize.mosaic", "Mosaic", "მოზაიკა"),
|
||||
("ec.stylize.motiontile", "Motion Tile", "მოძრავი ფილები"),
|
||||
("ec.stylize.posterize", "Posterize", "პოსტერიზაცია"),
|
||||
("ec.stylize.roughenedges", "Roughen Edges", "კიდეების დაუხეშება"),
|
||||
("ec.stylize.scatter", "Scatter", "გაბნევა"),
|
||||
("ec.stylize.strobe", "Strobe Light", "სტრობოსკოპი"),
|
||||
("ec.stylize.texturize", "Texturize", "ტექსტურირება"),
|
||||
("ec.stylize.threshold", "Threshold", "ზღვარი"),
|
||||
("ec.text.numbers", "Numbers", "რიცხვები"),
|
||||
("ec.text.timecode", "Timecode", "თაიმკოდი"),
|
||||
("ec.time.ccforcemotionblur", "CC Force Motion Blur", "CC Force Motion Blur"),
|
||||
("ec.time.ccwidetime", "CC Wide Time", "CC Wide Time"),
|
||||
("ec.time.echo", "Echo", "ექო"),
|
||||
("ec.time.pixelmotionblur", "Pixel Motion Blur", "პიქსელების მოძრაობის გაბუნდოვნება"),
|
||||
("ec.time.posterizetime", "Posterize Time", "დროის პოსტერიზაცია"),
|
||||
("ec.time.timedifference", "Time Difference", "დროითი სხვაობა"),
|
||||
("ec.time.timedisplacement", "Time Displacement", "დროითი წანაცვლება"),
|
||||
("ec.time.timewarp", "Timewarp", "Timewarp"),
|
||||
("ec.transition.blockdissolve", "Block Dissolve", "ბლოკური გადადნობა"),
|
||||
("ec.transition.cardwipe", "Card Wipe", "ბარათებით წმენდა"),
|
||||
("ec.transition.ccglasswipe", "CC Glass Wipe", "CC Glass Wipe"),
|
||||
("ec.transition.ccgridwipe", "CC Grid Wipe", "CC Grid Wipe"),
|
||||
("ec.transition.ccimagewipe", "CC Image Wipe", "CC Image Wipe"),
|
||||
("ec.transition.ccjaws", "CC Jaws", "CC Jaws"),
|
||||
("ec.transition.cclightwipe", "CC Light Wipe", "CC Light Wipe"),
|
||||
("ec.transition.cclinesweep", "CC Line Sweep", "CC Line Sweep"),
|
||||
("ec.transition.ccradialscalewipe", "CC Radial ScaleWipe", "CC Radial ScaleWipe"),
|
||||
("ec.transition.ccscalewipe", "CC Scale Wipe", "CC Scale Wipe"),
|
||||
("ec.transition.cctwister", "CC Twister", "CC Twister"),
|
||||
("ec.transition.ccwarpomatic", "CC WarpoMatic", "CC WarpoMatic"),
|
||||
("ec.transition.gradientwipe", "Gradient Wipe", "გრადიენტული წმენდა"),
|
||||
("ec.transition.iriswipe", "Iris Wipe", "დიაფრაგმული წმენდა"),
|
||||
("ec.transition.linearwipe", "Linear Wipe", "წრფივი წმენდა"),
|
||||
("ec.transition.radialwipe", "Radial Wipe", "რადიალური წმენდა"),
|
||||
("ec.transition.venetian", "Venetian Blinds", "ჟალუზი"),
|
||||
("ec.utility.applylut", "Apply Color LUT", "ფერის LUT-ის გამოყენება"),
|
||||
("ec.utility.ccoverbrights", "CC Overbrights", "CC Overbrights"),
|
||||
("ec.utility.cineon", "Cineon Converter", "Cineon-ის გარდამქმნელი"),
|
||||
("ec.utility.colorprofileconverter", "Color Profile Converter", "ფერის პროფილის გარდამქმნელი"),
|
||||
("ec.utility.facemeasurements", "Face Measurements", "სახის გაზომვები"),
|
||||
("ec.utility.facetrackpoints", "Face Track Points", "სახის ტრეკინგის წერტილები"),
|
||||
("ec.utility.growbounds", "Grow Bounds", "საზღვრების გაზრდა"),
|
||||
("ec.utility.hdrcompander", "HDR Compander", "HDR კომპანდერი"),
|
||||
("ec.utility.hdrcompression", "HDR Highlight Compression", "HDR ღია ტონების შეკუმშვა"),
|
||||
("ec.vr.blur", "VR Blur", "VR გაბუნდოვნება"),
|
||||
("ec.vr.chromaticaberrations", "VR Chromatic Aberrations", "VR ქრომატული აბერაციები"),
|
||||
("ec.vr.colorgradients", "VR Color Gradients", "VR ფერადი გრადიენტები"),
|
||||
("ec.vr.converter", "VR Converter", "VR გარდამქმნელი"),
|
||||
("ec.vr.denoise", "VR De-Noise", "VR ხმაურის მოცილება"),
|
||||
("ec.vr.digitalglitch", "VR Digital Glitch", "VR ციფრული ხარვეზი"),
|
||||
("ec.vr.fractalnoise", "VR Fractal Noise", "VR ფრაქტალური ხმაური"),
|
||||
("ec.vr.glow", "VR Glow", "VR ნათება"),
|
||||
("ec.vr.planetosphere", "VR Plane to Sphere", "VR სიბრტყიდან სფეროზე"),
|
||||
("ec.vr.rotatesphere", "VR Rotate Sphere", "VR სფეროს შემობრუნება"),
|
||||
("ec.vr.sharpen", "VR Sharpen", "VR სიმკვეთრე"),
|
||||
("ec.vr.spheretoplane", "VR Sphere to Plane", "VR სფეროდან სიბრტყეზე"),
|
||||
];
|
||||
|
||||
/// Georgian (`ka`) category labels, keyed by the registry's English category.
|
||||
const GEORGIAN_CATEGORIES: &[(&str, &str)] = &[
|
||||
("3D Channel", "3D არხი"),
|
||||
("Audio", "აუდიო"),
|
||||
("Blur & Sharpen", "გაბუნდოვნება და სიმკვეთრე"),
|
||||
("Channel", "არხი"),
|
||||
("Color Correction", "ფერის კორექცია"),
|
||||
("Distort", "დამახინჯება"),
|
||||
("Expression Controls", "ექსპრესიის მართვა"),
|
||||
("Generate", "გენერაცია"),
|
||||
("Immersive Video", "იმერსიული ვიდეო"),
|
||||
("Keying", "კეიინგი"),
|
||||
("Matte", "მატი"),
|
||||
("Noise & Grain", "ხმაური და მარცვლოვნება"),
|
||||
("Obsolete", "მოძველებული"),
|
||||
("Paint", "ხატვა"),
|
||||
("Perspective", "პერსპექტივა"),
|
||||
("Simulation", "სიმულაცია"),
|
||||
("Stylize", "სტილიზაცია"),
|
||||
("Text", "ტექსტი"),
|
||||
("Time", "დრო"),
|
||||
("Transition", "გადასვლა"),
|
||||
("Utility", "დამხმარე"),
|
||||
];
|
||||
|
||||
/// The 21 category folder names the generated Effect menu and Effects & Presets draw, in
|
||||
/// Simplified Chinese. Read from the installed After Effects 2026 (26.5.0.89) Simplified Chinese
|
||||
/// interface (the checklist's `07-类别` sheet); two of them are the interface's own wording where
|
||||
@@ -566,7 +908,7 @@ mod tests {
|
||||
const PENDING: &[(&str, &[Row])] = &[("zh-hant", TRADITIONAL_CHINESE)];
|
||||
|
||||
/// The languages whose tables are being filled in: guarded like [`PENDING`] row by row.
|
||||
const PARTIAL: &[(&str, &[Row])] = &[("ja", JAPANESE_EFFECTS)];
|
||||
const PARTIAL: &[(&str, &[Row])] = &[("ja", JAPANESE_EFFECTS), ("ka", GEORGIAN)];
|
||||
|
||||
/// A pending language may lag Simplified Chinese but may not invent ids or disagree about
|
||||
/// which effect an id names: a translator fills these tables row by row. While they are
|
||||
@@ -595,7 +937,9 @@ mod tests {
|
||||
#[test]
|
||||
fn every_category_row_names_a_category() {
|
||||
let categories = effects::categories();
|
||||
for (language, rows) in [("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES)] {
|
||||
for (language, rows) in
|
||||
[("zh-hans", SIMPLIFIED_CHINESE_CATEGORIES), ("zh-hant", TRADITIONAL_CHINESE_CATEGORIES), ("ja", JAPANESE_CATEGORIES), ("ka", GEORGIAN_CATEGORIES)]
|
||||
{
|
||||
for (english, translated) in rows {
|
||||
assert!(categories.contains(english), "{language} lists {english}, which is not a category");
|
||||
assert!(!translated.is_empty(), "empty {language} translation for {english}");
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+25
-17
@@ -1238,26 +1238,34 @@ impl EffectcraftApp {
|
||||
}
|
||||
let step = plan.map_or(1, |p| p.step);
|
||||
// Never further ahead than the RAM preview holds: those frames would evict the ones the
|
||||
// preview shows first, or be evicted before it gets there (#595).
|
||||
let window = (ahead * 3).min(self.frames_that_fit(&c, scale) - 1);
|
||||
for k in 0..window {
|
||||
// preview shows first, or be evicted before it gets there (#595). The window starts at the
|
||||
// first uncached frame ahead: after a loop or a restart the frames just ahead are cached,
|
||||
// and a window from the playhead left the workers idle until it came near the end of the
|
||||
// cached run. It stays ahead × 3 frames wide: workers spread over the whole preview render
|
||||
// frames with different layers at once (on the GPU, an atlas each: #1812).
|
||||
let fit_ahead = self.frames_that_fit(&c, scale) - 1;
|
||||
let frame = |k: i64| match plan {
|
||||
Some(p) => p.frame_after(cur, k),
|
||||
None => {
|
||||
let f = cur + k;
|
||||
Some(if f > wb { wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1)) } else { f })
|
||||
}
|
||||
};
|
||||
let cached = self.frames.cached_frames(&series);
|
||||
let uncached = |f: i64| cached.binary_search(&f).is_err();
|
||||
let first = match plan.filter(|_| self.playback.playing) {
|
||||
Some(p) => (0..fit_ahead.min((p.end - p.start) / p.step.max(1) + 1))
|
||||
.map_while(|k| frame(k).map(|f| (k, f)))
|
||||
.find(|&(_, f)| uncached(f))
|
||||
.map_or(0, |(k, _)| k),
|
||||
None => 0,
|
||||
};
|
||||
for k in first..(first + ahead * 3).min(fit_ahead) {
|
||||
if queued >= ahead as usize || !video {
|
||||
break;
|
||||
}
|
||||
let f = match plan {
|
||||
Some(p) => match p.frame_after(cur, k) {
|
||||
Some(f) => f,
|
||||
None => break,
|
||||
},
|
||||
None => {
|
||||
let mut f = cur + k;
|
||||
if f > wb {
|
||||
f = wa + (f - wb - 1).rem_euclid((wb - wa + 1).max(1));
|
||||
}
|
||||
f
|
||||
}
|
||||
};
|
||||
if !self.frames.is_cached(&FrameKey { frame: f, ..series }) {
|
||||
let Some(f) = frame(k) else { break };
|
||||
if uncached(f) {
|
||||
self.request_frame(cid, f, scale);
|
||||
queued += 1;
|
||||
}
|
||||
|
||||
@@ -939,6 +939,18 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
|
||||
}
|
||||
// View ▸ New Viewer.
|
||||
"view.newViewer" => json!({"viewer": crate::panels::viewers::new_viewer(app)}),
|
||||
// Reveal Layer Source / Reveal Composition: show the Project panel on the item, opening
|
||||
// its folders and clearing the search so the row is visible.
|
||||
"project.reveal" => {
|
||||
let ids: Vec<effectcraft_engine::project::ItemId> = p
|
||||
.get("items")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| items.iter().filter_map(Value::as_u64).map(effectcraft_engine::project::ItemId).collect())
|
||||
.or_else(|| p.get("item").and_then(Value::as_u64).map(|item| vec![effectcraft_engine::project::ItemId(item)]))
|
||||
.unwrap_or_default();
|
||||
crate::panels::project::reveal(app, ctx, &ids);
|
||||
json!({"items": ids.iter().map(|item| item.0).collect::<Vec<_>>()})
|
||||
}
|
||||
// Run by id (control channel, MCP): the Project panel's selection, whatever has focus.
|
||||
"project.deleteWithoutConfirmation" => crate::panels::delete_items::delete_confirmed(app, ctx, json!({}))?,
|
||||
// A gradient property's "Edit Gradient…" (the Timeline's value): the Gradient Editor.
|
||||
|
||||
@@ -206,6 +206,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
|
||||
"Serviços"
|
||||
} else if crate::i18n::czech(app) {
|
||||
"Služby"
|
||||
} else if crate::i18n::georgian(app) {
|
||||
"სერვისები"
|
||||
} else {
|
||||
"Services"
|
||||
}
|
||||
@@ -490,6 +492,27 @@ mod tests {
|
||||
assert_eq!(translated, ["Služby", "Skrýt EffectCraft", "Skrýt ostatní", "Zobrazit vše"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn georgian_translates_macos_predefined_items() {
|
||||
if !cfg!(target_os = "macos") {
|
||||
return;
|
||||
}
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
app.session.execute("prefs.set", json!({"key": "general.language", "value": "ka"})).unwrap();
|
||||
fn labels(nodes: &[NativeNode], out: &mut Vec<String>) {
|
||||
for node in nodes {
|
||||
match node {
|
||||
NativeNode::Predefined { label, .. } => out.push(label.clone()),
|
||||
NativeNode::Submenu { children, .. } => labels(children, out),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut translated = vec![];
|
||||
labels(&build(&app).menus, &mut translated);
|
||||
assert_eq!(translated, ["სერვისები", "EffectCraft-ის დამალვა", "სხვების დამალვა", "ყველას ჩვენება"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hide_items_are_predefined_on_macos() {
|
||||
let spec = build(&app());
|
||||
|
||||
@@ -224,6 +224,11 @@ fn focused_row_id() -> egui::Id {
|
||||
egui::Id::new("project-focused-row")
|
||||
}
|
||||
|
||||
/// The item whose selection the panel last scrolled into view.
|
||||
fn selection_seen_id() -> egui::Id {
|
||||
egui::Id::new("proj-sel-seen")
|
||||
}
|
||||
|
||||
/// The focused widget is one of the panel's rows, or nothing has keyboard focus: the panel's keys
|
||||
/// apply. Any other focused widget (a text field, a button reached with Tab) keeps them.
|
||||
fn keys_reach_rows(app: &EffectcraftApp, ctx: &egui::Context) -> bool {
|
||||
@@ -347,6 +352,33 @@ pub(crate) fn visible_rows(app: &EffectcraftApp) -> Vec<(ItemId, usize)> {
|
||||
rows
|
||||
}
|
||||
|
||||
/// Bring `items` into view: clear the panel's search, open every folder holding them, select
|
||||
/// them, and show and focus the Project panel. Used by Reveal Layer Source in Project and
|
||||
/// Reveal Composition in Project, so the item is visible even in a large, filtered project.
|
||||
pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[ItemId]) {
|
||||
let items: Vec<ItemId> = items.iter().copied().filter(|item| app.session.project.item(*item).is_some()).collect();
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
for item in &items {
|
||||
let mut parent = app.session.project.item(*item).and_then(|item| item.parent);
|
||||
let mut depth = 0;
|
||||
while let Some(folder) = parent {
|
||||
app.ui.project_open_folders.insert(folder.0);
|
||||
parent = app.session.project.item(folder).and_then(|item| item.parent);
|
||||
depth += 1;
|
||||
if depth > 256 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
app.ui.project_search.clear();
|
||||
app.session.state.project_selection = items;
|
||||
// Force the panel's selection scroll on its next draw, even when the item was already selected.
|
||||
ctx.data_mut(|d| d.remove_temp::<Option<u64>>(selection_seen_id()));
|
||||
app.show_panel(crate::dock::PanelKind::Project);
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let p = ui.painter().clone();
|
||||
@@ -437,13 +469,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
} else {
|
||||
let hint = "Select an item to see its details";
|
||||
let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
|
||||
let r = widgets::text_fit(&p, head.center(), Align2::CENTER_CENTER, crate::i18n::tr(hint), Tokens::ui(11.0), head.width() - 20.0, t.text_faint);
|
||||
app.auto.add("project.details.hint", r, hint);
|
||||
}
|
||||
// Search.
|
||||
let sr = Rect::from_min_size(pos2(rect.min.x + 8.0, head.max.y + 2.0), vec2(rect.width() - 16.0, 22.0));
|
||||
let mut q = app.ui.project_search.clone();
|
||||
widgets::search_field(ui, sr, &mut q, "Search project", &t);
|
||||
widgets::search_field(ui, sr, &mut q, crate::i18n::tr("Search project"), &t);
|
||||
app.ui.project_search = q;
|
||||
app.auto.add("project.search", sr, "Search");
|
||||
// Column header: Name, Label, then the visible optional columns.
|
||||
@@ -475,7 +507,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
let resp = ui.interact(hr.intersect(hdr), egui::Id::new(("proj-sort", *key)), Sense::click());
|
||||
hp.with_clip_rect(hr).text(pos2(*x, hdr.center().y), Align2::LEFT_CENTER, *label, Tokens::ui(11.0), if resp.hovered() { t.text } else { t.text_dim });
|
||||
hp.with_clip_rect(hr).text(
|
||||
pos2(*x, hdr.center().y),
|
||||
Align2::LEFT_CENTER,
|
||||
crate::i18n::tr(label),
|
||||
Tokens::ui(11.0),
|
||||
if resp.hovered() { t.text } else { t.text_dim },
|
||||
);
|
||||
if *key == "label" {
|
||||
icons::paint(&hp, Rect::from_center_size(pos2(label_x, hdr.center().y), vec2(10.0, 10.0)), Icon::Keyframe, t.text_dim);
|
||||
}
|
||||
@@ -562,7 +600,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
// A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
|
||||
let sel_key = egui::Id::new("proj-sel-seen");
|
||||
let sel_key = selection_seen_id();
|
||||
let sel_now = app.session.state.project_selection.last().map(|i| i.0);
|
||||
if ctx.data(|d| d.get_temp::<Option<u64>>(sel_key)) != Some(sel_now) {
|
||||
ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
|
||||
@@ -1106,7 +1144,7 @@ fn column_menu(app: &mut EffectcraftApp, resp: &egui::Response) {
|
||||
ui.label(egui::RichText::new(crate::i18n::tr("Columns")).weak());
|
||||
for (key, label, _) in COLUMNS {
|
||||
let mut on = app.ui.project_columns.iter().any(|c| c == key);
|
||||
if ui.checkbox(&mut on, label).changed() {
|
||||
if ui.checkbox(&mut on, crate::i18n::tr(label)).changed() {
|
||||
set_column(&mut app.ui.project_columns, key, on);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
divider(&p, x0, w, y - 4.0, &t);
|
||||
section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
|
||||
y += ROW_H + 10.0;
|
||||
let br = Rect::from_min_size(pos2(x0, y), vec2(116.0, 24.0));
|
||||
// Both buttons grow with their (translated) labels; English keeps its 116 / 84 px.
|
||||
let text_w = |s: &str| p.layout_no_wrap(s.to_string(), Tokens::ui(12.0), t.text).size().x;
|
||||
let br = Rect::from_min_size(pos2(x0, y), vec2(116f32.max(text_w(crate::i18n::tr("Add Animator")) + 36.0), 24.0));
|
||||
let resp = ui.interact(br, egui::Id::new("props-add-animator"), Sense::click());
|
||||
p.rect_stroke(br, 4.0, Stroke::new(1.0, if resp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
icons::paint(&p, Rect::from_center_size(pos2(br.min.x + 14.0, br.center().y), vec2(10.0, 10.0)), Icon::Plus, t.text);
|
||||
@@ -142,7 +144,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
}
|
||||
// Our own presets (original work, see presets/text_animators.json).
|
||||
let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84.0, 24.0));
|
||||
let pr = Rect::from_min_size(pos2(br.max.x + 8.0, y), vec2(84f32.max(text_w(crate::i18n::tr("Presets")) + 16.0), 24.0));
|
||||
let presp = ui.interact(pr, egui::Id::new("props-text-presets"), Sense::click());
|
||||
p.rect_stroke(pr, 4.0, Stroke::new(1.0, if presp.hovered() { t.text_dim } else { t.field_border }), egui::StrokeKind::Inside);
|
||||
p.text(pr.center(), Align2::CENTER_CENTER, crate::i18n::tr("Presets"), Tokens::ui(12.0), t.text);
|
||||
|
||||
@@ -677,7 +677,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
ui.painter().text(
|
||||
r.left_center() + vec2(10.0, 0.0),
|
||||
egui::Align2::LEFT_CENTER,
|
||||
tr(p.title),
|
||||
crate::i18n::settings_page_title(p.title),
|
||||
Tokens::ui(12.5),
|
||||
if sel { Color32::WHITE } else { t.text },
|
||||
);
|
||||
@@ -694,7 +694,7 @@ pub fn show(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
|
||||
// dialog scrolled sideways a little (#284).
|
||||
ui.set_width(ui.available_width());
|
||||
let p = &all[idx];
|
||||
ui.label(RichText::new(tr(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
|
||||
ui.label(RichText::new(crate::i18n::settings_page_title(p.title)).font(Tokens::semibold(14.0)).color(t.tab_text_active));
|
||||
ui.add_space(6.0);
|
||||
egui::ScrollArea::vertical().max_height((body_h - 30.0).max(0.0)).auto_shrink([false, false]).show(ui, |ui| {
|
||||
page_ui(app, ui, p, &cur, t, &mut acts);
|
||||
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
use effectcraft_engine::color::BlendMode;
|
||||
use effectcraft_engine::keyframe::{Interp, Value};
|
||||
use effectcraft_engine::project::{Comp, GroupKind, ItemId, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, PropGroup, Property};
|
||||
use effectcraft_engine::project::{
|
||||
Comp, FootageKind, GroupKind, ItemId, ItemKind, Layer, LayerId, LayerSource, MatteKind, Node, ParamUi, Project, PropGroup, Property,
|
||||
};
|
||||
use effectcraft_engine::render::EvalCtx;
|
||||
use effectcraft_engine::time::Tick;
|
||||
use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
|
||||
@@ -515,6 +517,19 @@ impl TMap {
|
||||
}
|
||||
}
|
||||
|
||||
fn disk_bar_range(tm: &TMap, clip: Rect, fd: f64, frames: i64) -> std::ops::Range<i64> {
|
||||
if clip.width() <= 0.0 || clip.height() <= 0.0 || !fd.is_finite() || fd <= 0.0 || !tm.pps.is_finite() || tm.pps <= 0.0 {
|
||||
return 0..0;
|
||||
}
|
||||
let start = tm.t(clip.min.x) / fd;
|
||||
let end = tm.t(clip.max.x) / fd;
|
||||
if !start.is_finite() || !end.is_finite() {
|
||||
return 0..0;
|
||||
}
|
||||
let limit = frames.clamp(0, 100_000) as f64;
|
||||
(start.clamp(0.0, limit).floor() as i64)..(end.clamp(0.0, limit).ceil() as i64)
|
||||
}
|
||||
|
||||
/// A Timeline ruler tick label for comp frame `fno`: seconds:frames (`00:15f`, AE) or, when
|
||||
/// the project's Time Display Style is Frames, the frame number the current-time display shows
|
||||
/// (`00015`, counted from Start Numbering Frames At) (#710).
|
||||
@@ -1314,6 +1329,39 @@ fn switch_gesture(ui: &egui::Ui, resp: &egui::Response, row: Rect, switch: &'sta
|
||||
(a.min(b) < row.max.y && a.max(b) >= row.min.y).then_some((d.value, d.merge))
|
||||
}
|
||||
|
||||
/// The layers an edit made on `layer`'s row applies to (a switch, its label, its blending mode):
|
||||
/// the selection when the row is selected, else that layer alone, as in After Effects.
|
||||
fn edited_layers(layer: LayerId, selected: &[LayerId]) -> Vec<u64> {
|
||||
if selected.contains(&layer) { selected.iter().map(|l| l.0).collect() } else { vec![layer.0] }
|
||||
}
|
||||
|
||||
/// Whether the Timeline shows switch `name`'s cell on `layer`'s row (A/V Features and Switches
|
||||
/// columns): a layer that has no such cell isn't one a click gives the switch to either.
|
||||
fn switch_applies(layer: &Layer, name: &str, project: &Project) -> bool {
|
||||
match name {
|
||||
"video" | "lock" | "shy" => true,
|
||||
"audio" => layer.props.sub("audio").is_some(),
|
||||
"solo" | "quality" | "motionBlur" | "adjustment" => layer.source.is_av(),
|
||||
"collapse" => collapse_applies(layer, project),
|
||||
"fx" => layer.effects().is_some_and(|f| !f.children.is_empty()),
|
||||
"frameBlend" => matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }),
|
||||
"threeD" => layer.source.is_av() || matches!(layer.source, LayerSource::Null),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The layers a click on the `name` switch of `layer`'s row acts on: [`edited_layers`] without
|
||||
/// the layers the switch doesn't apply to.
|
||||
fn switch_targets(comp: &Comp, project: &Project, selected: &[LayerId], layer: u64, name: &str) -> Vec<u64> {
|
||||
edited_layers(LayerId(layer), selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| switch_applies(l, name, project))).collect()
|
||||
}
|
||||
|
||||
/// The layers a Modes column edit on `layer`'s row (its blending mode, Preserve Transparency)
|
||||
/// acts on: [`edited_layers`] that have video.
|
||||
fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec<u64> {
|
||||
edited_layers(layer, selected).into_iter().filter(|l| comp.layer(LayerId(*l)).is_some_and(|l| l.source.is_av())).collect()
|
||||
}
|
||||
|
||||
/// A layer's switch (A/V Features and Switches columns) in `row`, with the
|
||||
/// [`switch_gesture`].
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1328,20 +1376,29 @@ fn layer_switch(
|
||||
layer: u64,
|
||||
name: &'static str,
|
||||
others: bool,
|
||||
comp: &Comp,
|
||||
project: &Project,
|
||||
selected: &[LayerId],
|
||||
actions: &mut Vec<(String, serde_json::Value)>,
|
||||
) {
|
||||
let resp = widgets::icon_toggle(ui, br, icon, on, t, id, Sense::click_and_drag());
|
||||
// Alt-click on an A/V feature sets the layer apart: the other layers go off (or locked),
|
||||
// and come back on the next Alt-click.
|
||||
// and come back on the next Alt-click. With several layers selected, the whole selection is
|
||||
// set apart.
|
||||
if others && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed() && i.modifiers.alt) {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "others": true})));
|
||||
let layers = edited_layers(LayerId(layer), selected);
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "others": true})));
|
||||
return;
|
||||
}
|
||||
// A press on a selected layer's switch gives the switch to every selected layer, as in After
|
||||
// Effects; the targets are picked when the press happens, never while the row is drawn.
|
||||
if let Some((value, merge)) = switch_gesture(ui, &resp, row, name, on) {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name, "value": value, "merge": merge})));
|
||||
let layers = switch_targets(comp, project, selected, layer, name);
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": value, "merge": merge})));
|
||||
} else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
|
||||
// Keyboard or accessibility activation (a pointer press was handled above).
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer], "switch": name})));
|
||||
let layers = switch_targets(comp, project, selected, layer, name);
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": layers, "switch": name, "value": !on})));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1382,6 +1439,26 @@ fn layer_drop_id() -> egui::Id {
|
||||
egui::Id::new("tl-layer-drop")
|
||||
}
|
||||
|
||||
/// Whether a Project item can replace a layer's source (the same set `layer.replaceSource`
|
||||
/// accepts): compositions, footage and solids; not folders, data files or 3D models.
|
||||
fn replaceable_item(it: &effectcraft_engine::project::Item) -> bool {
|
||||
match &it.kind {
|
||||
ItemKind::Comp(_) | ItemKind::Solid(_) => true,
|
||||
ItemKind::Footage(f) => !matches!(f.kind, FootageKind::Data | FootageKind::Model),
|
||||
ItemKind::Folder => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Alt/Option-drag target: the layer under the pointer can take this item as its source, and
|
||||
/// the item wouldn't put the composition inside itself.
|
||||
fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: LayerId, comp: &Comp) -> bool {
|
||||
app.session.project.item(item).is_some_and(replaceable_item)
|
||||
&& !app.session.project.comp_contains(item, cid)
|
||||
&& comp
|
||||
.layer(lid)
|
||||
.is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }))
|
||||
}
|
||||
|
||||
pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let t = app.tokens;
|
||||
let ctx = ui.ctx().clone();
|
||||
@@ -1674,11 +1751,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if app.session.disk_cache.is_some() || app.frames.remote_disk() {
|
||||
let frames = comp.frame_rate.frame_at(comp.duration);
|
||||
let opts = app.frame_opts(cid, series.scale as f64 / 1000.0);
|
||||
let disk = app.frames.disk_frames(&app.render_source(), &series, frames, &opts);
|
||||
let clip = wa_row.intersect(p.clip_rect());
|
||||
let range = disk_bar_range(&tm, clip, fd, frames);
|
||||
let disk = app.frames.disk_frames(&app.render_source(), &series, range, &opts);
|
||||
let blue_painter = p.with_clip_rect(clip);
|
||||
let draw_blue = |a: i64, b: i64| {
|
||||
let x0 = tm.x(a as f64 * fd);
|
||||
let x1 = tm.x((b + 1) as f64 * fd);
|
||||
p.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue);
|
||||
blue_painter.rect_filled(Rect::from_min_max(pos2(x0, cy0), pos2(x1, cy0 + 2.5)), 0.0, t.cache_blue);
|
||||
};
|
||||
let mut run: Option<(i64, i64)> = None;
|
||||
for f in disk {
|
||||
@@ -1786,7 +1866,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Column headers scroll with the outline and never spill into the time ruler.
|
||||
let hp = p.with_clip_rect(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)));
|
||||
let hic = |icon: Icon, x: f32| icons::paint(&hp, Rect::from_center_size(pos2(x, hy), vec2(12.0, 12.0)), icon, t.text_dim);
|
||||
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, s, Tokens::ui(11.0), t.text_dim);
|
||||
let htext = |x: f32, s: &str| hp.text(pos2(x, hy), Align2::LEFT_CENTER, crate::i18n::tr(s), Tokens::ui(11.0), t.text_dim);
|
||||
if vis.av {
|
||||
hic(Icon::Eye, cw.av + 10.0);
|
||||
hic(Icon::Speaker, cw.av + 28.0);
|
||||
@@ -1870,7 +1950,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
for (id, label) in COLUMNS {
|
||||
let label = if id == "name" { if app.ui.timeline.source_name { "Source Name" } else { "Layer Name" } } else { label };
|
||||
let on = column_visible(&app.ui.timeline, id);
|
||||
if ui.add_enabled(id != "name", egui::Button::new(label).selected(on)).clicked() {
|
||||
if ui.add_enabled(id != "name", egui::Button::new(crate::i18n::tr(label)).selected(on)).clicked() {
|
||||
actions.push(("timeline.column".into(), json!({"column": id, "visible": !on})));
|
||||
ui.close();
|
||||
}
|
||||
@@ -2000,10 +2080,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// as in After Effects: it hides a null's outline in the viewer and turns a camera or
|
||||
// light off.
|
||||
let av_items: [(Icon, bool, &str, bool); 4] = [
|
||||
(Icon::Eye, sw.video, "video", true),
|
||||
(Icon::Speaker, sw.audio, "audio", layer.props.sub("audio").is_some()),
|
||||
(Icon::Solo, sw.solo, "solo", layer.source.is_av()),
|
||||
(Icon::Lock, sw.locked, "lock", true),
|
||||
(Icon::Eye, sw.video, "video", switch_applies(layer, "video", &app.session.project)),
|
||||
(Icon::Speaker, sw.audio, "audio", switch_applies(layer, "audio", &app.session.project)),
|
||||
(Icon::Solo, sw.solo, "solo", switch_applies(layer, "solo", &app.session.project)),
|
||||
(Icon::Lock, sw.locked, "lock", switch_applies(layer, "lock", &app.session.project)),
|
||||
];
|
||||
for (i, (icon, on, name, applicable)) in av_items.into_iter().enumerate() {
|
||||
let br = Rect::from_center_size(pos2(cw.av + 10.0 + 18.0 * i as f32, cy), vec2(15.0, 15.0));
|
||||
@@ -2011,7 +2091,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
continue;
|
||||
}
|
||||
lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
|
||||
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("av", layer.id.0, i)), layer.id.0, name, true, &mut actions);
|
||||
layer_switch(
|
||||
ui,
|
||||
&t,
|
||||
r,
|
||||
br,
|
||||
icon,
|
||||
on,
|
||||
egui::Id::new(("av", layer.id.0, i)),
|
||||
layer.id.0,
|
||||
name,
|
||||
true,
|
||||
&comp,
|
||||
&app.session.project,
|
||||
&selected,
|
||||
&mut actions,
|
||||
);
|
||||
app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
|
||||
}
|
||||
// Label swatch.
|
||||
@@ -2026,7 +2121,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
widgets::open_popup(ui, pop);
|
||||
}
|
||||
if let Some(lab) = widgets::label_popup(ui, pop, lr.left_bottom(), layer.label, |l| app.session.prefs.label_name(l), &t) {
|
||||
actions.push(("edit.label".into(), json!({"layers": [layer.id.0], "label": lab.name()})));
|
||||
actions.push(("edit.label".into(), json!({"layers": edited_layers(layer.id, &selected), "label": lab.name()})));
|
||||
}
|
||||
}
|
||||
if vis.num {
|
||||
@@ -2122,19 +2217,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
layer_context_menu(&row_resp, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions);
|
||||
// Switches.
|
||||
let sws: [(Icon, bool, &str, bool); 8] = [
|
||||
(Icon::Shy, sw.shy, "shy", true),
|
||||
(Icon::Collapse, sw.collapse, "collapse", collapse_applies(layer, &app.session.project)),
|
||||
(Icon::Quality, sw.quality == effectcraft_engine::project::Quality::Best, "quality", layer.source.is_av()),
|
||||
(Icon::Fx, sw.effects, "fx", layer.effects().is_some_and(|f| !f.children.is_empty())),
|
||||
(Icon::Shy, sw.shy, "shy", switch_applies(layer, "shy", &app.session.project)),
|
||||
(Icon::Collapse, sw.collapse, "collapse", switch_applies(layer, "collapse", &app.session.project)),
|
||||
(
|
||||
Icon::Quality,
|
||||
sw.quality == effectcraft_engine::project::Quality::Best,
|
||||
"quality",
|
||||
switch_applies(layer, "quality", &app.session.project),
|
||||
),
|
||||
(Icon::Fx, sw.effects, "fx", switch_applies(layer, "fx", &app.session.project)),
|
||||
(
|
||||
Icon::FrameBlend,
|
||||
sw.frame_blend != effectcraft_engine::project::FrameBlend::Off,
|
||||
"frameBlend",
|
||||
matches!(layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. }),
|
||||
switch_applies(layer, "frameBlend", &app.session.project),
|
||||
),
|
||||
(Icon::MotionBlur, sw.motion_blur, "motionBlur", layer.source.is_av()),
|
||||
(Icon::Adjustment, sw.adjustment, "adjustment", layer.source.is_av()),
|
||||
(Icon::Cube, layer.is_3d(), "threeD", layer.source.is_av() || matches!(layer.source, LayerSource::Null)),
|
||||
(Icon::MotionBlur, sw.motion_blur, "motionBlur", switch_applies(layer, "motionBlur", &app.session.project)),
|
||||
(Icon::Adjustment, sw.adjustment, "adjustment", switch_applies(layer, "adjustment", &app.session.project)),
|
||||
(Icon::Cube, layer.is_3d(), "threeD", switch_applies(layer, "threeD", &app.session.project)),
|
||||
];
|
||||
for (i, (icon, on, name, applicable)) in sws.into_iter().enumerate() {
|
||||
if !applicable || !vis.switches {
|
||||
@@ -2142,7 +2242,22 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
let br = Rect::from_center_size(pos2(cw.switches + SW * i as f32 + SW / 2.0, cy), vec2(16.0, 16.0));
|
||||
lp.rect_filled(br.shrink(1.0), 1.0, t.switch_well);
|
||||
layer_switch(ui, &t, r, br, icon, on, egui::Id::new(("sw", layer.id.0, i)), layer.id.0, name, false, &mut actions);
|
||||
layer_switch(
|
||||
ui,
|
||||
&t,
|
||||
r,
|
||||
br,
|
||||
icon,
|
||||
on,
|
||||
egui::Id::new(("sw", layer.id.0, i)),
|
||||
layer.id.0,
|
||||
name,
|
||||
false,
|
||||
&comp,
|
||||
&app.session.project,
|
||||
&selected,
|
||||
&mut actions,
|
||||
);
|
||||
app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
|
||||
}
|
||||
// Modes.
|
||||
@@ -2159,11 +2274,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
.flat_map(|m| if m.ends_group() { vec![m.label().to_string(), "-".to_string()] } else { vec![m.label().to_string()] })
|
||||
.collect();
|
||||
if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, opts.iter().position(|o| o == layer.blend_mode.label())) {
|
||||
actions.push(("layer.setBlendMode".into(), json!({"layers": [layer.id.0], "mode": opts[i]})));
|
||||
actions.push(("layer.setBlendMode".into(), json!({"layers": mode_targets(&comp, layer.id, &selected), "mode": opts[i]})));
|
||||
}
|
||||
let tr = Rect::from_center_size(pos2(cw.mode + 76.0 + 6.0, cy), vec2(14.0, 14.0));
|
||||
app.auto.add(&format!("timeline.layer.{}.preserveTransparency", layer.id.0), tr, "Preserve Transparency");
|
||||
if widgets::checkbox(ui, tr, layer.preserve_transparency, &t, egui::Id::new(("pt", layer.id.0))).clicked() {
|
||||
actions.push(("layer.setSwitch".into(), json!({"layers": [layer.id.0], "switch": "preserveTransparency"})));
|
||||
// The click gives the selection the state the clicked row toggles to,
|
||||
// whatever the first selected layer's value (as the switches do).
|
||||
actions.push((
|
||||
"layer.setSwitch".into(),
|
||||
json!({
|
||||
"layers": mode_targets(&comp, layer.id, &selected),
|
||||
"switch": "preserveTransparency",
|
||||
"value": !layer.preserve_transparency
|
||||
}),
|
||||
));
|
||||
}
|
||||
// Track matte: layer picker + kind.
|
||||
let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0));
|
||||
@@ -3330,8 +3455,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
super::markers_ui::comp_markers(app, ui, &comp, tm, mstrip, mrows);
|
||||
super::markers_ui::marker_bin(app, ui, &comp, tm, bin, mstrip);
|
||||
|
||||
// Spacebar held: the Hand tool. Dragging the ruler or the time graph scrolls it in time when
|
||||
// zoomed in (registered last, so it takes the drag from the bars, keys and markers there).
|
||||
// Spacebar held: the Hand tool. Dragging pans the rows vertically as well as scrolling the
|
||||
// time graph in time when zoomed in (registered last, so it takes the drag from the bars,
|
||||
// keys and markers there).
|
||||
if super::space_hand(&ctx) {
|
||||
let area = Rect::from_min_max(pos2(graph_x0, ruler.min.y), pos2(rect.max.x, rows_rect.max.y));
|
||||
let hand = ui.interact(area, egui::Id::new("tl-hand"), Sense::drag());
|
||||
@@ -3339,10 +3465,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
if hand.hovered() || hand.dragged() {
|
||||
ctx.set_cursor_icon(if hand.dragged() { egui::CursorIcon::Grabbing } else { egui::CursorIcon::Grab });
|
||||
}
|
||||
if hand.dragged() && app.ui.timeline.pps.is_some() {
|
||||
let start = app.ui.timeline.start;
|
||||
let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0);
|
||||
app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last);
|
||||
if hand.dragged() {
|
||||
let dy = ui.input(|i| i.pointer.delta().y);
|
||||
app.ui.timeline.scroll_y = (app.ui.timeline.scroll_y - dy).clamp(0.0, max_scroll);
|
||||
if app.ui.timeline.pps.is_some() {
|
||||
let start = app.ui.timeline.start;
|
||||
let last = (comp.duration.seconds() - (tm.t(graph_x1) - tm.start)).max(start).max(0.0);
|
||||
app.ui.timeline.start = (start - hand.drag_delta().x as f64 / pps).clamp(0.0, last);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3356,7 +3486,27 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
&& let Some(ptr) = ctx.input(|i| i.pointer.hover_pos()).filter(|_| ui.rect_contains_pointer(rows_rect))
|
||||
{
|
||||
let released = ctx.input(|i| i.pointer.any_released());
|
||||
if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
|
||||
// Alt/Option-drag from the Project panel replaces the source of the layer under the
|
||||
// pointer (After Effects). The shortcut replaces every selected layer instead.
|
||||
let alt_item = ctx.input(|i| i.modifiers.alt) && matches!(payload.as_ref(), crate::panels::DragPayload::Item(_));
|
||||
if alt_item {
|
||||
if let crate::panels::DragPayload::Item(id) = payload.as_ref() {
|
||||
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
|
||||
if let Some(l) = target
|
||||
&& replace_source_target(app, cid, ItemId(*id), l, &comp)
|
||||
&& let Some(span) = hit_rows.iter().filter(|(_, row)| row.layer == l).map(|(r, _)| *r).reduce(|a, b| a.union(b))
|
||||
{
|
||||
let lit = span.intersect(rows_rect);
|
||||
let rp = p.with_clip_rect(rows_rect);
|
||||
rp.rect_filled(lit, 0.0, t.accent.gamma_multiply(0.18));
|
||||
rp.rect_stroke(lit, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
|
||||
app.auto.add("timeline.replaceSourceTarget", lit, "Replace the layer's source");
|
||||
if released {
|
||||
actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
|
||||
let target = hit_rows.iter().find(|(r, _)| ptr.y >= r.min.y && ptr.y < r.max.y).map(|(_, row)| row.layer);
|
||||
// The layer the effect would land on (its row and its open properties) is the one
|
||||
// that lights up, not the whole timeline.
|
||||
@@ -3883,18 +4033,25 @@ fn collapse_applies(layer: &Layer, project: &effectcraft_engine::project::Projec
|
||||
}
|
||||
}
|
||||
|
||||
/// Which Create ▸ conversions the layer context menu offers, from the targeted layer's kind.
|
||||
/// Which Create ▸ conversions and Reveal ▸ actions the layer context menu offers, from the
|
||||
/// targeted layer's kind.
|
||||
#[derive(Clone, Copy, Default)]
|
||||
pub(crate) struct LayerMenuCaps {
|
||||
/// SVG / PDF / AI / EPS footage: Create Shapes from Vector Layer.
|
||||
pub vector: bool,
|
||||
/// A text layer: Create Shapes from Text / Create Masks from Text.
|
||||
pub text: bool,
|
||||
/// The layer has a source item to reveal in the Project panel.
|
||||
pub source: bool,
|
||||
}
|
||||
|
||||
impl LayerMenuCaps {
|
||||
pub(crate) fn of(project: &effectcraft_engine::project::Project, layer: &Layer) -> Self {
|
||||
Self { vector: vector_footage_layer(project, layer), text: matches!(layer.source, LayerSource::Text) }
|
||||
Self {
|
||||
vector: vector_footage_layer(project, layer),
|
||||
text: matches!(layer.source, LayerSource::Text),
|
||||
source: layer.source.item().is_some_and(|item| project.item(item).is_some()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3916,6 +4073,10 @@ fn layer_context_menu(
|
||||
actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
|
||||
}
|
||||
let targets: Vec<u64> = if in_selection { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
|
||||
// The menu acts on the whole selection: offer Reveal when any target has a source, even if
|
||||
// the layer under the pointer is a text, shape or null layer.
|
||||
let any_source = targets.iter().any(|id| comp.layer(LayerId(*id)).is_some_and(|l| l.source.item().is_some()));
|
||||
let caps = LayerMenuCaps { source: caps.source || any_source, ..caps };
|
||||
crate::widgets::context_menu(resp, |ui| layer_menu(ui, &targets, caps, (auto, "timeline.layerMenu"), actions));
|
||||
}
|
||||
|
||||
@@ -3951,6 +4112,9 @@ pub(crate) fn layer_menu(
|
||||
item(ui, "Adjustment Layer", "layer.newAdjustment", json!({}));
|
||||
});
|
||||
item(ui, "Layer Settings…", "layer.settings", json!({}));
|
||||
if caps.source {
|
||||
item(ui, "Reveal Layer Source in Project", "layer.revealSource", json!({"layers": layers}));
|
||||
}
|
||||
if caps.vector {
|
||||
item(ui, "Create Shapes from Vector Layer", "layer.create", json!({"op": "shapesFromVector", "layers": layers}));
|
||||
}
|
||||
@@ -4117,7 +4281,9 @@ fn value_editor(
|
||||
set(actions, json!(nv));
|
||||
}
|
||||
} else {
|
||||
let suffix = if matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity" { "%" } else { "" };
|
||||
// A Range Selector's Start, End and Offset count characters with Units = Index (#1814).
|
||||
let pct = (matches!(prop.ui, ParamUi::Percent) || prop.match_id == "opacity") && !effectcraft_engine::render::text::in_index_units(layer, uid);
|
||||
let suffix = if pct { "%" } else { "" };
|
||||
let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, y), egui::Id::new(("v", uid, 0)), *v, speed, (min, max), dec.max(1), suffix, &t);
|
||||
app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
|
||||
if let Some(nv) = nv {
|
||||
@@ -4264,6 +4430,26 @@ fn value_editor(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Alt/Option-drag takes the same Project items `layer.replaceSource` does: compositions,
|
||||
/// footage and solids, never folders, data files or 3D models.
|
||||
#[test]
|
||||
fn replace_source_takes_visual_items_only() {
|
||||
use effectcraft_engine::color::Label;
|
||||
use effectcraft_engine::project::{Comp, Footage, Project, Solid};
|
||||
use effectcraft_engine::time::{FrameRate, Tick};
|
||||
let mut p = Project::default();
|
||||
let l = Label::None;
|
||||
let comp = p.add_item("C", l, None, ItemKind::Comp(Comp::new(10, 10, FrameRate::FPS_30, Tick::from_seconds_f64(1.0)).into()));
|
||||
let folder = p.add_item("F", l, None, ItemKind::Folder);
|
||||
let still = p.add_item("S", l, None, ItemKind::Footage(Footage { kind: FootageKind::Still, ..Default::default() }));
|
||||
let data = p.add_item("D", l, None, ItemKind::Footage(Footage { kind: FootageKind::Data, ..Default::default() }));
|
||||
let model = p.add_item("M", l, None, ItemKind::Footage(Footage { kind: FootageKind::Model, ..Default::default() }));
|
||||
let solid = p.add_item("So", l, None, ItemKind::Solid(Solid { color: [0.0; 3], width: 10, height: 10, pixel_aspect: 1.0 }));
|
||||
for (id, ok) in [(comp, true), (still, true), (solid, true), (folder, false), (data, false), (model, false)] {
|
||||
assert_eq!(replaceable_item(p.item(id).unwrap()), ok, "{id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Alt/Cmd-click twirls Audio's Waveform row (not in the property tree) with the rest.
|
||||
#[test]
|
||||
fn the_waveform_row_twirls_with_its_tree() {
|
||||
@@ -4656,20 +4842,21 @@ mod tests {
|
||||
/// the categories the Effect menu does; the actions it records stay the same.
|
||||
#[test]
|
||||
fn the_layer_context_menu_draws_in_the_interface_language() {
|
||||
let caps = LayerMenuCaps { vector: true, text: false };
|
||||
let caps = LayerMenuCaps { vector: true, text: false, source: true };
|
||||
let (mut auto, mut actions) = (crate::automation::Registry::default(), Vec::new());
|
||||
crate::i18n::set_current("zh-hans");
|
||||
let menu = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions));
|
||||
let effects = drawn_text(|ui| effects_submenu(ui, &[7], &mut |_: &mut egui::Ui, _: &str, _: &str, _: serde_json::Value| {}));
|
||||
crate::i18n::set_current("en");
|
||||
let english = drawn_text(|ui| layer_menu(ui, &[7], caps, (&mut auto, "t"), &mut actions));
|
||||
for label in ["新建", "图层设置...", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"] {
|
||||
for label in ["新建", "图层设置...", "在项目中显示图层源", "从矢量图层创建形状", "预合成...", "重复", "拆分图层", "删除"]
|
||||
{
|
||||
assert!(menu.iter().any(|t| t == label), "{label} missing from {menu:?}");
|
||||
}
|
||||
assert!(!menu.iter().any(|t| t.is_ascii()), "English left in {menu:?}");
|
||||
assert!(effects.iter().any(|t| t == "模糊和锐化"), "{effects:?}");
|
||||
assert!(!effects.iter().any(|t| t == "Blur & Sharpen"), "{effects:?}");
|
||||
for label in ["Layer Settings…", "Duplicate", "Delete"] {
|
||||
for label in ["Layer Settings…", "Reveal Layer Source in Project", "Duplicate", "Delete"] {
|
||||
assert!(english.iter().any(|t| t == label), "{label} missing from {english:?}");
|
||||
}
|
||||
assert!(actions.is_empty());
|
||||
@@ -4720,4 +4907,22 @@ mod tests {
|
||||
let sel = bar_color(red, true);
|
||||
assert!(sel.r() > bar.r());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_bar_range_covers_partial_frames() {
|
||||
let clip = Rect::from_min_max(pos2(0.0, 0.0), pos2(2.0, 1.0));
|
||||
let tm = TMap { x0: 0.0, start: 10.25, pps: 1.0 };
|
||||
assert_eq!(disk_bar_range(&tm, clip, 1.0, 100), 10..13);
|
||||
|
||||
let exact = TMap { start: 10.0, ..tm };
|
||||
assert_eq!(disk_bar_range(&exact, clip, 1.0, 100), 10..12);
|
||||
let off_comp = TMap { start: 200.0, ..tm };
|
||||
assert_eq!(disk_bar_range(&off_comp, clip, 1.0, 100), 100..100);
|
||||
assert_eq!(disk_bar_range(&tm, clip, f64::NAN, 100), 0..0);
|
||||
assert_eq!(disk_bar_range(&TMap { pps: 0.0, ..tm }, clip, 1.0, 100), 0..0);
|
||||
assert_eq!(disk_bar_range(&TMap { start: f64::NAN, ..tm }, clip, 1.0, 100), 0..0);
|
||||
assert_eq!(disk_bar_range(&tm, Rect::from_min_max(pos2(2.0, 0.0), pos2(2.0, 1.0)), 1.0, 100), 0..0);
|
||||
let above_limit = TMap { start: 100_001.0, ..tm };
|
||||
assert_eq!(disk_bar_range(&above_limit, clip, 1.0, i64::MAX), 100_000..100_000);
|
||||
}
|
||||
}
|
||||
@@ -1311,6 +1311,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let c = super::timeline::LayerMenuCaps::of(&app.session.project, layer);
|
||||
caps.vector |= c.vector;
|
||||
caps.text |= c.text;
|
||||
caps.source |= c.source;
|
||||
caps
|
||||
},
|
||||
);
|
||||
|
||||
@@ -297,5 +297,5 @@ pub fn on_close(app: &mut EffectcraftApp, p: PanelKind) {
|
||||
/// The tab label of viewer `id` ("Composition Intro").
|
||||
pub fn title(app: &EffectcraftApp, id: u32) -> Option<String> {
|
||||
let c = comp_of(app, id)?;
|
||||
Some(format!("Composition {}", app.session.project.item(c)?.name))
|
||||
Some(format!("{} {}", crate::i18n::command_label("window.panel", "Composition"), app.session.project.item(c)?.name))
|
||||
}
|
||||
+89
-11
@@ -370,6 +370,7 @@ const SCRIPT_PROBES: &[(char, &str)] = &[
|
||||
('ಕ', "kannada-system"),
|
||||
('ക', "malayalam-system"),
|
||||
('ก', "thai-system"),
|
||||
('ა', "georgian-system"),
|
||||
];
|
||||
|
||||
/// Register the system fallbacks for scripts the bundled fonts lack in every font family, and
|
||||
@@ -408,21 +409,46 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str)
|
||||
let cjk = if is_chinese(language) { [han, KANA] } else { [KANA, han] };
|
||||
let mut names = Vec::new();
|
||||
let mut seen = Vec::new();
|
||||
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)) {
|
||||
let id = fonts::fallback_for(probe, base);
|
||||
if seen.contains(&id) {
|
||||
// A font covering one Han probe may be a sparse subset. Register additional faces
|
||||
// needed by the actual interface catalog instead of assuming whole-script coverage.
|
||||
let extra = if is_chinese(language) { crate::i18n::catalog_characters(language) } else { Vec::new() };
|
||||
let extra: Vec<_> = extra.into_iter().filter(|ch| *ch >= '\u{2e80}').map(|ch| (ch, "catalog-system")).collect();
|
||||
// 256-character blocks where a catalog character found no face: with nothing installed
|
||||
// covering them, each lookup would read every installed font file again.
|
||||
let mut uncovered_blocks = std::collections::HashSet::new();
|
||||
for &(probe, name) in cjk.iter().chain(SCRIPT_PROBES).chain(std::iter::once(&HANGUL)).chain(&extra) {
|
||||
let catalog = name == "catalog-system";
|
||||
if catalog && (uncovered_blocks.contains(&(probe as u32 >> 8)) || seen.iter().any(|&id| fonts::face(id).has_char(probe))) {
|
||||
continue;
|
||||
}
|
||||
// The interface asks for the platform's UI face first; the order a composed frame
|
||||
// renders with is `fonts::fallback_for`'s and is not touched here. Another context may
|
||||
// install a different language concurrently, so the shared fallback is asked with this
|
||||
// context's own locale rather than the global request.
|
||||
let id = interface_fallback_candidates(name, language)
|
||||
.iter()
|
||||
.find_map(|family| {
|
||||
let r = fonts::resolve(family, "Regular");
|
||||
(!r.missing && fonts::face(r.face).has_char(probe)).then_some(r.face)
|
||||
})
|
||||
.unwrap_or_else(|| fonts::fallback_for_locale(probe, base, cjk_locale(language)));
|
||||
let face = fonts::face(id);
|
||||
if !face.has_char(probe) {
|
||||
if catalog {
|
||||
uncovered_blocks.insert(probe as u32 >> 8);
|
||||
}
|
||||
continue; // nothing installed covers this script
|
||||
}
|
||||
if seen.contains(&id) {
|
||||
continue;
|
||||
}
|
||||
let Some(font) = face.font() else { continue };
|
||||
seen.push(id);
|
||||
// Use the already-read, parsed bytes rather than reading a font file twice.
|
||||
let mut data = FontData::from_owned(font.data().as_bytes().to_vec());
|
||||
data.index = face.info.index;
|
||||
family_fonts.font_data.insert(name.to_string(), Arc::new(data));
|
||||
let name = if name == "catalog-system" { format!("{name}-{:x}", probe as u32) } else { name.to_string() };
|
||||
family_fonts.font_data.insert(name.clone(), Arc::new(data));
|
||||
for family in family_fonts.families.values_mut() {
|
||||
family.push(name.to_string());
|
||||
}
|
||||
@@ -432,6 +458,31 @@ fn install_script_fallbacks(family_fonts: &mut FontDefinitions, language: &str)
|
||||
}
|
||||
}
|
||||
|
||||
/// The faces the interface asks for before it settles on the one a composed frame renders with.
|
||||
///
|
||||
/// Windows ships a UI variant of its Japanese and Chinese families - `Meiryo`/`Meiryo UI`, `Yu
|
||||
/// Gothic`/`Yu Gothic UI`, `MS Gothic`/`MS UI Gothic`, `Microsoft YaHei`/`Microsoft YaHei UI`,
|
||||
/// `Microsoft JhengHei`/`Microsoft JhengHei UI` - and its own link table for `Segoe UI`
|
||||
/// (`FontLink\SystemLink`) picks the UI one for what it draws on screen. `PMingLiU` is the one face
|
||||
/// here that is not a sibling: it is `MingLiU`'s proportional relative, and proportional spacing is
|
||||
/// what makes the chrome read better in it.
|
||||
///
|
||||
/// Only the interface takes this order. `fonts::fallback_for` keeps the order a document renders
|
||||
/// with, so an existing project's output does not move when the chrome changes.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn interface_fallback_candidates(name: &str, language: &str) -> &'static [&'static str] {
|
||||
match (name, cjk_locale(language)) {
|
||||
// Kana, and the Han ideographs of a Japanese interface: the language asks for the Japanese
|
||||
// face, so its UI sibling is the one the platform would draw chrome with.
|
||||
("japanese-system", _) | ("chinese-system", "ja") => &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"],
|
||||
("chinese-system", "zh-hant") => &["Microsoft JhengHei UI", "PMingLiU"],
|
||||
("chinese-system", "zh-hans") => &["Microsoft YaHei UI"],
|
||||
// The other scripts have no UI variant, and their shared order already picks the face made
|
||||
// for the script - the one that draws it correctly at all.
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
/// The CJK locale the text engine should prefer for Han text: the interface language when it names
|
||||
/// one, otherwise the empty string, which leaves the operating system's locale in charge.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -443,9 +494,9 @@ fn cjk_locale(language: &str) -> &str {
|
||||
}
|
||||
|
||||
/// The Han character that picks the Chinese fallback face. A Simplified Chinese interface asks for
|
||||
/// a simplified-only hanzi: a Japanese face covers the shared ideographs (文) but not 页, 选 or 删,
|
||||
/// and the fallback takes a face already loaded first, so the Japanese face loaded for kana would
|
||||
/// draw the Chinese interface with boxes (#1141).
|
||||
/// a hanzi used by its catalog. Some Japanese faces also cover 页 while lacking 选 or 删, so
|
||||
/// coverage of this probe alone is not enough: the fallback must use the interface's own language
|
||||
/// policy, even while another context is installing Japanese fonts (#1141).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fn han_probe(language: &str) -> char {
|
||||
if language == "zh-hans" { '页' } else { '文' }
|
||||
@@ -530,6 +581,7 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) {
|
||||
|
||||
#[cfg(all(test, not(target_arch = "wasm32")))]
|
||||
mod script_font_tests {
|
||||
use super::{SCRIPT_PROBES, interface_fallback_candidates};
|
||||
use effectcraft_text::fonts;
|
||||
|
||||
/// Whether an installed face covers `probe` (else the glyph check is skipped).
|
||||
@@ -595,9 +647,35 @@ mod script_font_tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// #1141: switching the interface to Simplified Chinese drew most labels as boxes. The English
|
||||
/// interface had already loaded a Japanese face for kana, and that face covers the Han probe,
|
||||
/// so it became the Chinese fallback too, without the simplified-only hanzi.
|
||||
#[test]
|
||||
fn the_interface_asks_for_the_platforms_ui_faces() {
|
||||
// The face a window draws its own chrome with, which is not the one a composed frame
|
||||
// renders with: the two orders are deliberately separate.
|
||||
let ja = &["Meiryo UI", "Yu Gothic UI", "MS UI Gothic"][..];
|
||||
assert_eq!(interface_fallback_candidates("japanese-system", "ja"), ja);
|
||||
assert_eq!(interface_fallback_candidates("chinese-system", "ja"), ja);
|
||||
assert_eq!(interface_fallback_candidates("chinese-system", "zh-hans"), &["Microsoft YaHei UI"][..]);
|
||||
assert_eq!(interface_fallback_candidates("chinese-system", "zh-hant"), &["Microsoft JhengHei UI", "PMingLiU"][..]);
|
||||
// Every other script the install probes has no UI variant, so it keeps the shared order.
|
||||
for &(_, name) in SCRIPT_PROBES.iter() {
|
||||
assert!(interface_fallback_candidates(name, "en").is_empty(), "{name} asked for a UI face");
|
||||
}
|
||||
}
|
||||
|
||||
/// The Georgian interface draws its Mkhedruli text with an installed Georgian face (Sylfaen,
|
||||
/// SF Georgian, Noto Sans Georgian…): Inter has no Georgian, so the labels showed as boxes.
|
||||
#[test]
|
||||
fn installed_georgian_fallback_draws_the_georgian_interface() {
|
||||
if !installed('ა') {
|
||||
eprintln!("no Georgian system font installed; skipping glyph coverage");
|
||||
return;
|
||||
}
|
||||
assert_drawn("ka", "კომპოზიციაფენასაკვანძოკადრიეფექტიპარამეტრები");
|
||||
}
|
||||
|
||||
/// #1141: switching the interface to Simplified Chinese drew most labels as boxes. A Japanese face
|
||||
/// can cover the Han probe while lacking other catalog characters. The context must keep its
|
||||
/// own language policy even when another context installs Japanese fallbacks concurrently.
|
||||
#[test]
|
||||
fn simplified_chinese_interface_draws_simplified_hanzi_after_another_language() {
|
||||
let ctx = egui::Context::default();
|
||||
@@ -609,7 +687,7 @@ mod script_font_tests {
|
||||
let font = egui::FontId::new(13.0, egui::FontFamily::Proportional);
|
||||
let missing: String = ctx.fonts_mut(|fonts| {
|
||||
let replacement = fonts.layout_no_wrap("\u{10ffff}".into(), font.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect;
|
||||
// Simplified-only hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面).
|
||||
// Representative hanzi of the zh-hans catalog (选择, 删除, 图层, 设置, 导入, 页面).
|
||||
"选删层设导页"
|
||||
.chars()
|
||||
.filter(|ch| {
|
||||
|
||||
@@ -7,6 +7,7 @@ mod ui_agent_audit;
|
||||
mod ui_angles;
|
||||
mod ui_audio_scrub;
|
||||
mod ui_azerty_shortcuts;
|
||||
mod ui_batch_switches;
|
||||
mod ui_blank_context_menus;
|
||||
mod ui_camera_tracker;
|
||||
mod ui_character_eyedropper;
|
||||
@@ -63,6 +64,7 @@ mod ui_text_range_handles;
|
||||
mod ui_theme_favorites;
|
||||
mod ui_time_zoom_controls;
|
||||
mod ui_timeline_effect_label;
|
||||
mod ui_timeline_pan;
|
||||
mod ui_timeline_shapes;
|
||||
mod ui_timeline_switches;
|
||||
mod ui_timeline_trim;
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
//! A Timeline row control clicked on one of several selected layers applies to the whole
|
||||
//! selection (#1849), as in After Effects: the A/V Features and Switches columns, the label
|
||||
//! swatch and the Modes column. The engine commands already took several layers; the row
|
||||
//! controls used to pass the clicked layer alone.
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::color::{BlendMode, Label};
|
||||
use effectcraft_engine::project::{Layer, LayerId, Switches};
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Modifiers, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use serde_json::json;
|
||||
|
||||
/// Comp "Main" with solids A (top) to D (bottom); returns their ids top to bottom.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, Vec<u64>) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let mut ids: Vec<u64> = ["D", "C", "B", "A"]
|
||||
.iter()
|
||||
.map(|name| s.execute("layer.newSolid", json!({"name": name, "color": "#406080"})).unwrap()["layer"].as_u64().unwrap())
|
||||
.collect();
|
||||
ids.reverse();
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
(h, ids)
|
||||
}
|
||||
|
||||
fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect {
|
||||
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")).clone();
|
||||
Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]))
|
||||
}
|
||||
|
||||
/// Press on `from`, move to `to` in `steps`, release there.
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2, steps: u32) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=steps {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / steps as f32)));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// A click on the Timeline control `id`.
|
||||
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = rect(h, id).center();
|
||||
drag(h, p, p, 1);
|
||||
}
|
||||
|
||||
/// A click at `p` (a menu entry, found by its label).
|
||||
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
drag(h, p, p, 1);
|
||||
}
|
||||
|
||||
fn alt_click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
|
||||
let p = rect(h, id).center();
|
||||
let modifiers = Modifiers::ALT;
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.event(Event::ModifiersChanged(modifiers));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers });
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers });
|
||||
h.event(Event::ModifiersChanged(Modifiers::NONE));
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// Select `ids` in the Timeline, as a Shift range or a Cmd toggle would.
|
||||
fn select(h: &mut Harness<'_, EffectcraftApp>, ids: &[u64]) {
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": ids})).unwrap();
|
||||
h.run_steps(2);
|
||||
}
|
||||
|
||||
/// The state of switch `f` on each layer of `ids`, top to bottom.
|
||||
fn states(h: &Harness<'_, EffectcraftApp>, ids: &[u64], f: fn(&Layer) -> bool) -> Vec<bool> {
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
ids.iter().map(|id| f(comp.layer(LayerId(*id)).unwrap())).collect()
|
||||
}
|
||||
|
||||
fn switches(h: &Harness<'_, EffectcraftApp>, id: u64) -> Switches {
|
||||
h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().switches.clone()
|
||||
}
|
||||
|
||||
fn locked(l: &Layer) -> bool {
|
||||
l.switches.locked
|
||||
}
|
||||
|
||||
fn video(l: &Layer) -> bool {
|
||||
l.switches.video
|
||||
}
|
||||
|
||||
fn three_d(l: &Layer) -> bool {
|
||||
l.switches.three_d
|
||||
}
|
||||
|
||||
fn solo(l: &Layer) -> bool {
|
||||
l.switches.solo
|
||||
}
|
||||
|
||||
fn shy(l: &Layer) -> bool {
|
||||
l.switches.shy
|
||||
}
|
||||
|
||||
fn motion_blur(l: &Layer) -> bool {
|
||||
l.switches.motion_blur
|
||||
}
|
||||
|
||||
fn preserved(l: &Layer) -> bool {
|
||||
l.preserve_transparency
|
||||
}
|
||||
|
||||
/// #1849: a click on the Lock, 3D, Solo, Shy or Motion Blur switch of a selected layer gives the
|
||||
/// switch to every selected layer, in one undo step.
|
||||
#[test]
|
||||
fn switches_on_a_selected_row_set_the_whole_selection() {
|
||||
let (mut h, ids) = harness();
|
||||
select(&mut h, &[ids[0], ids[2]]);
|
||||
|
||||
let steps = h.state().session.history.undo.len();
|
||||
click(&mut h, &format!("timeline.layer.{}.lock", ids[0]));
|
||||
assert_eq!(states(&h, &ids, locked), [true, false, true, false]);
|
||||
assert_eq!(h.state().session.history.undo.len(), steps + 1, "one undo step");
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(states(&h, &ids, locked), [false; 4]);
|
||||
|
||||
click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[0]));
|
||||
assert_eq!(states(&h, &ids, three_d), [true, false, true, false]);
|
||||
// The switch of the other selected layer acts the same way.
|
||||
click(&mut h, &format!("timeline.layer.{}.solo", ids[2]));
|
||||
assert_eq!(states(&h, &ids, solo), [true, false, true, false]);
|
||||
click(&mut h, &format!("timeline.layer.{}.switch.shy", ids[2]));
|
||||
assert_eq!(states(&h, &ids, shy), [true, false, true, false]);
|
||||
click(&mut h, &format!("timeline.layer.{}.switch.motionBlur", ids[0]));
|
||||
assert_eq!(states(&h, &ids, motion_blur), [true, false, true, false]);
|
||||
// A second click turns both off again, whatever their own state.
|
||||
click(&mut h, &format!("timeline.layer.{}.switch.threeD", ids[2]));
|
||||
assert_eq!(states(&h, &ids, three_d), [false; 4]);
|
||||
}
|
||||
|
||||
/// A switch on a row that isn't selected still changes that layer alone, and leaves the
|
||||
/// selection.
|
||||
#[test]
|
||||
fn a_switch_on_an_unselected_row_sets_that_layer_only() {
|
||||
let (mut h, ids) = harness();
|
||||
select(&mut h, &[ids[0], ids[2]]);
|
||||
click(&mut h, &format!("timeline.layer.{}.lock", ids[1]));
|
||||
assert_eq!(states(&h, &ids, locked), [false, true, false, false]);
|
||||
assert_eq!(h.state().session.state.selected_layers, vec![LayerId(ids[0]), LayerId(ids[2])]);
|
||||
}
|
||||
|
||||
/// The targets skip the selected layers a switch doesn't apply to: 3D reaches a null but not a
|
||||
/// camera (3D by kind), the Effect switch not a layer without effects.
|
||||
#[test]
|
||||
fn switch_targets_skip_the_layers_the_switch_does_not_apply_to() {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 320, "height": 180, "frameRate": 30, "duration": 4})).unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let null = s.execute("layer.newNull", json!({"name": "Ctrl"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("effect.apply", json!({"effect": "ec.blur.gaussian", "layers": [solid]})).unwrap();
|
||||
s.execute("edit.deselectAll", json!({})).unwrap();
|
||||
let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(s));
|
||||
h.run_steps(3);
|
||||
select(&mut h, &[solid, null, cam]);
|
||||
|
||||
click(&mut h, &format!("timeline.layer.{solid}.switch.threeD"));
|
||||
assert!(switches(&h, solid).three_d && switches(&h, null).three_d, "3D reaches the null");
|
||||
assert!(!switches(&h, cam).three_d, "a camera is 3D by kind, its own switch stays off");
|
||||
|
||||
click(&mut h, &format!("timeline.layer.{solid}.switch.fx"));
|
||||
assert!(!switches(&h, solid).effects, "the Effect switch reaches the layer that has an effect");
|
||||
assert!(switches(&h, null).effects && switches(&h, cam).effects, "no effects, no Effect switch");
|
||||
}
|
||||
|
||||
/// Alt-click on an A/V feature sets the selected layers apart (the others off), and the next
|
||||
/// Alt-click brings the others back — with several layers, the whole selection stands apart.
|
||||
#[test]
|
||||
fn alt_click_sets_the_selected_layers_apart() {
|
||||
let (mut h, ids) = harness();
|
||||
select(&mut h, &[ids[0], ids[2]]);
|
||||
alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0]));
|
||||
assert_eq!(states(&h, &ids, video), [true, false, true, false]);
|
||||
alt_click(&mut h, &format!("timeline.layer.{}.video", ids[0]));
|
||||
assert_eq!(states(&h, &ids, video), [true; 4]);
|
||||
}
|
||||
|
||||
/// The label swatch menu of a selected layer sets the label of every selected layer.
|
||||
#[test]
|
||||
fn the_label_menu_sets_the_selections_label() {
|
||||
let (mut h, ids) = harness();
|
||||
select(&mut h, &[ids[0], ids[2]]);
|
||||
let green = h.state().session.prefs.label_name(Label::Green);
|
||||
click(&mut h, &format!("timeline.layer.{}.label", ids[0]));
|
||||
let entry = h.query_by_label(&green).expect("the label menu is open").rect();
|
||||
click_at(&mut h, entry.center());
|
||||
let label = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().label;
|
||||
assert_eq!([label(ids[0]), label(ids[1]), label(ids[2]), label(ids[3])], [Label::Green, Label::Red, Label::Green, Label::Red]);
|
||||
}
|
||||
|
||||
/// The Modes column: the Blending Mode menu and the Preserve Transparency checkbox of a selected
|
||||
/// layer set both selected layers, one undo step each.
|
||||
#[test]
|
||||
fn the_modes_column_sets_the_whole_selection() {
|
||||
let (mut h, ids) = harness();
|
||||
h.state_mut().ui.timeline.show_modes = true;
|
||||
h.run_steps(3);
|
||||
select(&mut h, &[ids[0], ids[2]]);
|
||||
|
||||
click(&mut h, &format!("timeline.layer.{}.mode", ids[0]));
|
||||
let entry = h.query_by_label("Multiply").expect("the blend mode menu is open").rect();
|
||||
click_at(&mut h, entry.center());
|
||||
let mode = |id: u64| h.state().session.active_comp().unwrap().layer(LayerId(id)).unwrap().blend_mode;
|
||||
assert_eq!([mode(ids[0]), mode(ids[1]), mode(ids[2]), mode(ids[3])], [BlendMode::Multiply, BlendMode::Normal, BlendMode::Multiply, BlendMode::Normal]);
|
||||
|
||||
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[0]));
|
||||
assert_eq!(states(&h, &ids, preserved), [true, false, true, false]);
|
||||
|
||||
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
|
||||
h.run_steps(2);
|
||||
assert_eq!(states(&h, &ids, preserved), [false; 4], "one undo step put both back");
|
||||
}
|
||||
|
||||
/// Preserve Transparency toggles from the row it is clicked on, not from the first selected layer:
|
||||
/// A on and C off, clicking C's box turns both on.
|
||||
#[test]
|
||||
fn preserve_transparency_toggles_from_the_clicked_row() {
|
||||
let (mut h, ids) = harness();
|
||||
h.state_mut().ui.timeline.show_modes = true;
|
||||
h.run_steps(3);
|
||||
h.state_mut().session.execute("layer.setSwitch", json!({"layers": [ids[0]], "switch": "preserveTransparency", "value": true})).unwrap();
|
||||
h.run_steps(2);
|
||||
select(&mut h, &[ids[0], ids[2]]);
|
||||
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2]));
|
||||
assert_eq!(states(&h, &ids, preserved), [true, false, true, false]);
|
||||
// The next click turns both off again.
|
||||
click(&mut h, &format!("timeline.layer.{}.preserveTransparency", ids[2]));
|
||||
assert_eq!(states(&h, &ids, preserved), [false; 4]);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Right-click actions in the blank Project and Timeline areas, and on Timeline property names.
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_engine::project::ItemKind;
|
||||
use effectcraft_ui_egui::{Dialog, EffectcraftApp, dock::PanelKind};
|
||||
use egui::{Event, Modifiers, PointerButton, Pos2, Rect, pos2, vec2};
|
||||
use egui_kittest::{Harness, kittest::Queryable as _};
|
||||
@@ -299,6 +300,43 @@ fn timeline_layer_context_menu_has_layer_styles_submenu() {
|
||||
assert!(l.layer_styles().is_some_and(|g| g.sub("dropShadow").is_some()));
|
||||
}
|
||||
|
||||
/// #1809: Reveal Layer Source in Project shows the source in a large project: it clears the
|
||||
/// Project panel's search, opens the folders holding the item and selects it.
|
||||
#[test]
|
||||
fn timeline_layer_context_menu_reveals_the_source_in_project() {
|
||||
let mut h = harness();
|
||||
let layer = h.state_mut().session.execute("layer.newSolid", json!({"name": "Plate", "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let solid = h.state().session.project.items.values().find(|i| matches!(i.kind, ItemKind::Solid(_))).unwrap().id;
|
||||
let outer = h.state_mut().session.execute("project.newFolder", json!({"name": "Assets"})).unwrap()["item"].as_u64().unwrap();
|
||||
let inner = h.state_mut().session.execute("project.newFolder", json!({"name": "Plates"})).unwrap()["item"].as_u64().unwrap();
|
||||
h.state_mut().session.execute("project.move", json!({"items": [inner], "folder": outer})).unwrap();
|
||||
h.state_mut().session.execute("project.move", json!({"items": [solid.0], "folder": inner})).unwrap();
|
||||
h.state_mut().session.execute("project.select", json!({"items": []})).unwrap();
|
||||
h.state_mut().ui.project_open_folders.clear();
|
||||
h.state_mut().ui.project_search = "no such item".into();
|
||||
h.run_steps(3);
|
||||
assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_none(), "the source starts hidden");
|
||||
let r = rect(&h, &format!("timeline.layer.{layer}.row"));
|
||||
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
|
||||
h.get_by_label("Reveal Layer Source in Project").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.state.project_selection, vec![solid]);
|
||||
assert_eq!(h.state().ui.focused, PanelKind::Project);
|
||||
assert!(h.state().ui.project_search.is_empty(), "the search is cleared");
|
||||
assert!([outer, inner].iter().all(|folder| h.state().ui.project_open_folders.contains(folder)), "the folders holding the source open");
|
||||
assert!(h.state().auto.find(&format!("project.item.{}", solid.0)).is_some(), "the source row is visible");
|
||||
// In a mixed selection the command still appears when the right-clicked layer has no source,
|
||||
// because the menu acts on the solid layer too.
|
||||
let shape = h.state_mut().session.execute("layer.newShape", json!({"kind": "none"})).unwrap()["layer"].as_u64().unwrap();
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [shape, layer]})).unwrap();
|
||||
h.run_steps(3);
|
||||
let r = rect(&h, &format!("timeline.layer.{shape}.row"));
|
||||
right_click(&mut h, pos2(r.min.x + 210.0, r.center().y));
|
||||
h.get_by_label("Reveal Layer Source in Project").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().session.state.project_selection, vec![solid], "the solid's source, not the shape");
|
||||
}
|
||||
|
||||
/// #976: a layer's context menu has After Effects' Markers ▸ Add Marker, which marks the layer that
|
||||
/// was right-clicked (not the one selected before, and not the composition) at the current time.
|
||||
#[test]
|
||||
|
||||
@@ -108,6 +108,47 @@ fn project_items_land_where_they_are_dropped_in_the_timeline() {
|
||||
assert!((in_point(&h, 3) - 1.0).abs() < 1e-9, "Shift starts it at the current time");
|
||||
}
|
||||
|
||||
/// Alt/Option-drag from the Project panel replaces the source of the layer under the pointer
|
||||
/// instead of adding a layer (#991).
|
||||
#[test]
|
||||
fn alt_dragging_a_project_item_replaces_the_layer_source() {
|
||||
let (mut h, clip) = harness();
|
||||
let top = layer_id(&h, "Top");
|
||||
let item = rect(&h, &format!("project.item.{clip}.name")).center();
|
||||
let row = rect(&h, &format!("timeline.layer.{top}.row"));
|
||||
let before = stack(&h);
|
||||
drag(&mut h, item, row.center(), Modifiers::ALT);
|
||||
assert_eq!(stack(&h), before, "no layer is added");
|
||||
let layer = h.state().session.active_comp().unwrap().layer(effectcraft_engine::project::LayerId(top)).unwrap();
|
||||
assert_eq!(layer.source, effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) });
|
||||
assert_eq!(layer.name, "Top", "the layer keeps its name");
|
||||
}
|
||||
|
||||
/// Ctrl+Alt+/ (Cmd+Opt+/ on macOS) replaces every selected layer's source with the Project panel
|
||||
/// selection (#991).
|
||||
#[test]
|
||||
fn ctrl_alt_slash_replaces_every_selected_layer_source() {
|
||||
let (mut h, clip) = harness();
|
||||
let top = layer_id(&h, "Top");
|
||||
let middle = layer_id(&h, "Middle");
|
||||
h.state_mut().session.execute("layer.select", json!({"layers": [top, middle]})).unwrap();
|
||||
h.state_mut().session.state.project_selection = vec![effectcraft_engine::project::ItemId(clip)];
|
||||
h.run_steps(3);
|
||||
let modifiers = Modifiers { command: true, alt: true, ..Default::default() };
|
||||
for pressed in [true, false] {
|
||||
h.event(Event::Key { key: egui::Key::Slash, physical_key: Some(egui::Key::Slash), pressed, repeat: false, modifiers });
|
||||
}
|
||||
h.run_steps(3);
|
||||
let comp = h.state().session.active_comp().unwrap();
|
||||
for lid in [top, middle] {
|
||||
assert_eq!(
|
||||
comp.layer(effectcraft_engine::project::LayerId(lid)).unwrap().source,
|
||||
effectcraft_engine::project::LayerSource::Comp { item: effectcraft_engine::project::ItemId(clip) }
|
||||
);
|
||||
}
|
||||
assert_eq!(stack(&h), ["Top", "Middle", "Bottom"], "no layer is added");
|
||||
}
|
||||
|
||||
/// #85: a Project item dropped on the Composition viewer becomes a layer centred where it was
|
||||
/// dropped (above the selected layer, like any new layer).
|
||||
#[test]
|
||||
|
||||
@@ -259,6 +259,45 @@ fn cache_frames_when_idle_fills_the_work_area() {
|
||||
assert_eq!(n, 240, "the work area is cached while idle");
|
||||
}
|
||||
|
||||
/// A preview that plays into frames it already cached (after a loop) keeps rendering the next
|
||||
/// uncached ones: the prefetch window used to end within the cached run, and the workers stood
|
||||
/// idle until the playhead came near its end.
|
||||
#[test]
|
||||
fn playback_prefetches_past_the_cached_frames_ahead() {
|
||||
let mut h = harness();
|
||||
let cid = h.state().session.active_comp_id().unwrap();
|
||||
let series = h.state().shown_series(cid);
|
||||
let scale = series.scale as f64 / 1000.0;
|
||||
// The first 120 frames (5 s) are cached: more than the widest old window (16 workers × 6).
|
||||
for f in 0..120 {
|
||||
h.state().request_frame(cid, f, scale);
|
||||
}
|
||||
for _ in 0..4000 {
|
||||
if h.state().frames.cached_frames(&series).len() >= 120 && h.state().frames.inflight() == 0 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert_eq!(h.state().frames.cached_frames(&series), (0..120).collect::<Vec<_>>());
|
||||
h.state_mut().session.set_time(Tick::ZERO);
|
||||
invoke(&mut h, "playback.toggle", json!({}));
|
||||
h.run_steps(2);
|
||||
assert!(h.state().playback.playing);
|
||||
let cur = h.state().session.time();
|
||||
assert!(cur < Tick::from_seconds_f64(1.0), "{cur:?}");
|
||||
for _ in 0..4000 {
|
||||
if h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120) {
|
||||
break;
|
||||
}
|
||||
h.step();
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
if h.state().session.time() >= Tick::from_seconds_f64(1.0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(h.state().frames.cached_frames(&series).iter().any(|&f| f >= 120), "frames past the cached run render while it plays the first second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_frames_when_idle_survives_an_inverted_work_area() {
|
||||
let mut h = harness();
|
||||
|
||||
@@ -194,3 +194,76 @@ fn a_bar_on_a_box_handle_leaves_it_the_press() {
|
||||
assert_eq!(layer.props.find(start).unwrap().value.as_f64(), 0.0, "Start stays");
|
||||
assert_ne!(scale(&h), before, "the handle scaled the layer");
|
||||
}
|
||||
|
||||
/// The text the Timeline paints in property `uid`'s value field.
|
||||
fn timeline_value(h: &Harness<'_, EffectcraftApp>, uid: u64) -> String {
|
||||
let e = h.state().auto.find(&format!("timeline.prop.{uid}.value")).unwrap_or_else(|| panic!("timeline.prop.{uid}.value isn't shown"));
|
||||
let field = egui::Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3]));
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) if field.contains(t.visual_bounding_rect().center()) => Some(t.galley.text().to_string()),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The centre of the first painted text that reads `label` exactly (a popup's option).
|
||||
fn text_at(h: &Harness<'_, EffectcraftApp>, label: &str) -> Option<Pos2> {
|
||||
h.output().shapes.iter().find_map(|c| match &c.shape {
|
||||
egui::Shape::Text(t) if t.galley.text() == label => Some(t.visual_bounding_rect().center()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
|
||||
h.event(Event::PointerMoved(p));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: p, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
/// #1814: with Units = Index the Timeline shows the Range Selector's Start, End and Offset as
|
||||
/// counts, without the % sign they carry with Units = Percentage. Units is changed the way a
|
||||
/// user changes it: its Timeline popup, then the Index option.
|
||||
#[test]
|
||||
fn index_units_show_start_end_and_offset_without_a_percent_sign() {
|
||||
let (s, ids) = setup();
|
||||
let l = LayerId(ids.layer);
|
||||
let layer = s.active_comp().unwrap().layer(l).unwrap().clone();
|
||||
let text = layer.props.sub("text").unwrap();
|
||||
let anim = animator(&s, ids.layer);
|
||||
let selectors = anim.sub("selectors").unwrap();
|
||||
let sel = selectors.groups().next().unwrap();
|
||||
let (offset, units) = (sel.get("offset").unwrap().uid, sel.prop("advanced/units").unwrap().uid);
|
||||
let advanced = sel.sub("advanced").unwrap().uid;
|
||||
let mut h = harness(s);
|
||||
let tl = &mut h.state_mut().ui.timeline;
|
||||
tl.open_layers.insert(ids.layer);
|
||||
let animators = text.sub("animators").unwrap().uid;
|
||||
tl.open_groups.extend([text.uid, animators, anim.uid, selectors.uid, sel.uid, advanced]);
|
||||
h.run_steps(3);
|
||||
for uid in [ids.start, ids.end, offset] {
|
||||
assert!(timeline_value(&h, uid).ends_with('%'), "Percentage units: {}", timeline_value(&h, uid));
|
||||
}
|
||||
// Units ▸ Index through the Timeline: open its popup, pick the option.
|
||||
let units_value = |h: &Harness<'_, EffectcraftApp>| h.state().session.active_comp().unwrap().layer(l).unwrap().props.find(units).unwrap().value.clone();
|
||||
assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(0), "Percentage to start with");
|
||||
let field = h
|
||||
.state()
|
||||
.auto
|
||||
.find(&format!("timeline.prop.{units}.value"))
|
||||
.map(|e| pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0))
|
||||
.expect("Units is shown");
|
||||
click_at(&mut h, field);
|
||||
let index = text_at(&h, "Index").expect("the Units popup lists Index");
|
||||
click_at(&mut h, index);
|
||||
assert_eq!(units_value(&h), effectcraft_engine::keyframe::Value::Enum(1), "the popup set Units to Index");
|
||||
for uid in [ids.start, ids.end, offset] {
|
||||
let v = timeline_value(&h, uid);
|
||||
assert!(!v.is_empty() && !v.contains('%'), "Index units: {v:?}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
//! Spacebar-drag panning in the Timeline: the Hand tool scrolls the layer rows vertically as well
|
||||
//! as the time graph horizontally (#2083).
|
||||
|
||||
use effectcraft_engine::Session;
|
||||
use effectcraft_ui_egui::EffectcraftApp;
|
||||
use egui::{Event, Modifiers, Pos2, pos2, vec2};
|
||||
use egui_kittest::Harness;
|
||||
use serde_json::json;
|
||||
|
||||
/// A 10 s comp with 40 solids, enough rows to scroll.
|
||||
fn harness() -> (Harness<'static, EffectcraftApp>, u64) {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Many", "width": 320, "height": 180, "frameRate": 30, "duration": 10})).unwrap();
|
||||
let mut top = 0;
|
||||
for i in 0..40 {
|
||||
top = s.execute("layer.newSolid", json!({"name": format!("Layer {i}"), "color": "#406080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
}
|
||||
s.execute("layer.select", json!({"layers": []})).unwrap();
|
||||
let app = EffectcraftApp::new(s);
|
||||
let mut h = Harness::builder().with_size(vec2(1200.0, 800.0)).build_eframe(|_| app);
|
||||
h.run_steps(4);
|
||||
(h, top)
|
||||
}
|
||||
|
||||
fn space(h: &mut Harness<'_, EffectcraftApp>, pressed: bool) {
|
||||
h.event(Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
}
|
||||
|
||||
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
|
||||
h.event(Event::PointerMoved(from));
|
||||
h.step();
|
||||
h.event(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE });
|
||||
h.step();
|
||||
for k in 1..=10 {
|
||||
h.event(Event::PointerMoved(from + (to - from) * (k as f32 / 10.0)));
|
||||
h.step();
|
||||
}
|
||||
h.event(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE });
|
||||
h.run_steps(3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spacebar_drag_pans_the_rows_vertically() {
|
||||
let (mut h, layer) = harness();
|
||||
assert_eq!(h.state().ui.timeline.scroll_y, 0.0);
|
||||
let bar = h.state().auto.find(&format!("timeline.layer.{layer}.bar")).expect("layer bar").rect;
|
||||
let from = pos2(bar[0] + 40.0, bar[1] + bar[3] / 2.0);
|
||||
let timing = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let l = &h.state().session.active_comp().unwrap().layers[0];
|
||||
(l.in_point.seconds(), l.out_point.seconds(), l.start_time.seconds())
|
||||
};
|
||||
let before = timing(&h);
|
||||
space(&mut h, true);
|
||||
drag(&mut h, from, from - vec2(0.0, 180.0));
|
||||
space(&mut h, false);
|
||||
let y = h.state().ui.timeline.scroll_y;
|
||||
assert!(y > 100.0, "the rows scrolled vertically: {y}");
|
||||
assert_eq!(h.state().ui.timeline.start, 0.0, "the time graph did not pan sideways");
|
||||
assert_eq!(timing(&h), before, "the layer under the hand did not move");
|
||||
}
|
||||
@@ -172,7 +172,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
|
||||
assert!(frames2.remote_disk());
|
||||
let until = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
let bar = loop {
|
||||
let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts);
|
||||
let bar = frames2.disk_frames(&source(&s, None), &key(fx), 0..30, &opts);
|
||||
if !bar.is_empty() {
|
||||
break bar;
|
||||
}
|
||||
|
||||
@@ -595,6 +595,12 @@ Extension points and the bundled extensions: [plugins.md](plugins.md).
|
||||
properties, FF missing effects, EE Expressions, LL Waveform, UU everything changed from its
|
||||
default. Shift+key adds or removes a set; the same key again hides it; Ctrl/Cmd+` twirls the
|
||||
selected layers open or closed. With no layer selected they apply to every layer.
|
||||
* A row control clicked on one of several selected layers applies to every selected layer, as in
|
||||
After Effects: the A/V switches (video, audio, solo, lock), the Switches column (shy, collapse,
|
||||
quality, fx, frame blending, motion blur, adjustment, 3D), the label swatch and the Modes column
|
||||
(blending mode, Preserve Transparency), one undo step, with the layers the switch doesn't apply
|
||||
to left out. A control on an unselected row changes that layer alone; Alt-clicking an A/V feature
|
||||
still sets the selected layers apart from the rest.
|
||||
* `keys.toggleTransform {"layers"?: [...], "prop": "position"}` (Alt+Shift+A/P/S/R/T) adds or
|
||||
removes a keyframe at the current time on that Transform property of each layer, in one undo
|
||||
step (separated X/Y/Z Position; Orientation and X/Y/Z Rotation on 3D layers).
|
||||
|
||||
@@ -70,6 +70,16 @@ next to it.
|
||||
Agents: `file.importProject {"path": "/projects/titles.ecproj"}` returns the comps, the root comps,
|
||||
`primaryComp` (the only root comp, if there is one) and any missing media.
|
||||
|
||||
## Finding a layer's source in the Project panel
|
||||
|
||||
Right-click a layer in the Timeline or Composition panel and choose **Reveal Layer Source in
|
||||
Project** (also Layer ▸ Reveal ▸ Reveal Layer Source in Project). The Project panel opens, its
|
||||
search is cleared, the folders holding the source open, and the source item is selected.
|
||||
|
||||
Layers whose image is generated inside the composition — text, shape, null, camera, light and
|
||||
3D primitive layers — have no Project item, so the command is not offered for them. Adjustment
|
||||
layers do have one: the solid that backs them.
|
||||
|
||||
## Interpret Footage
|
||||
|
||||
Select a footage item in the Project panel and choose **File ▸ Interpret Footage ▸ Main…**
|
||||
@@ -110,6 +120,15 @@ vectors, positions and IDs are never the picture. EXtractoR reads every other la
|
||||
Importing a layered file as a composition refreshes the decoded sources used by its layers,
|
||||
including files overwritten at the same path and byte size.
|
||||
|
||||
- **Replace a layer's source** without rebuilding the layer: Alt-drag (Option-drag on macOS) a
|
||||
footage item, composition or solid from the Project panel onto a layer in the Timeline. The
|
||||
layer keeps its name, timing, transforms, keyframes, expressions, effects, masks, switches,
|
||||
parenting and track matte; only its source changes. A source with audio adds Audio properties
|
||||
when the layer has none. To replace several selected layers with the selected Project item, press
|
||||
Ctrl+Alt+/ (Cmd+Opt+/). Compositions, footage and solids are interchangeable; folders, data files
|
||||
and 3D models are not sources, and a composition can't be nested in itself. Alt-dragging onto a
|
||||
layer is one layer at a time, as in After Effects.
|
||||
Agents: `layer.replaceSource {"item": id, "layers": [ids]}`.
|
||||
- **File ▸ Replace Footage ▸ File…** (Ctrl+H) points the item at another file. Pick a numbered
|
||||
file to replace it with that file's whole sequence (`file.replaceFootage {"path": …,
|
||||
"sequence": false}` for one frame).
|
||||
|
||||
+3
-3
@@ -72,7 +72,7 @@ are grouped by area; the issue tracker has the rest.
|
||||
| 13 | **3D model formats and materials** | After Effects 26 imports FBX, USD/USDZ, STL besides glTF and OBJ (`usd_plugins`), and Substance 3D materials with mesh displacement (26.0–26.2); we read glTF, OBJ, STL (binary and ASCII, with facet colours) and FBX 7.x (binary and ASCII: meshes, hierarchy, Lambert/Phong materials and textures; no FBX animation or skinning yet) | Product shots and 3D scenes come as FBX/USDZ | 25–45 h | — · [file-format-parity.md](file-format-parity.md) |
|
||||
| 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) |
|
||||
| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) |
|
||||
| 16 | **Localization depth** | Menus in 10 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs: 624 rows each); panels in 6 (ja, zh-hans, zh-hant, uk, pt-br, cs); effect names translated in Simplified Chinese (306, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
|
||||
| 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
|
||||
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
|
||||
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
|
||||
@@ -313,8 +313,8 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
|
||||
|
||||
- Localisation of the interface ([localization-parity.md](localization-parity.md)): since 6–12
|
||||
October the menus are translated into Japanese, Simplified and Traditional Chinese, Ukrainian,
|
||||
Spanish, Brazilian Portuguese, Russian, French, German and Czech, and the panel catalog covers
|
||||
Japanese, both Chinese, Ukrainian, Brazilian Portuguese and Czech. Next: effect and parameter
|
||||
Spanish, Brazilian Portuguese, Russian, French, German, Czech and Georgian, and the panel catalog covers
|
||||
Japanese, both Chinese, Ukrainian, Brazilian Portuguese, Czech and Georgian. Next: effect and parameter
|
||||
names, the remaining panel strings (Spanish, Russian, French, German), Korean and Italian, and
|
||||
native-speaker review of all of them.
|
||||
- Accessibility (keyboard navigation, screen readers, contrast), user documentation and
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Localization parity
|
||||
|
||||
> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-23 · **Change:** minor (missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026
|
||||
> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-23 · **Change:** minor (Georgian: menus, panels and effect names; missing strings show in English; `missing_translations_report` lists them) · **Target:** Adobe After Effects 2026
|
||||
|
||||
How much of EffectCraft's interface is translated, language by language, compared with After
|
||||
Effects. [gaps.md](gaps.md) gap 16 and workstream G9 hold the work;
|
||||
@@ -24,9 +24,10 @@ interface; Arabic and Hebrew text *in layers* is supported through its Middle Ea
|
||||
- **Panels, dialogs, buttons, tooltips:** `crates/ui-egui/src/i18n/ui.rs`, keyed by the English
|
||||
source string, with English fallback. Catalogs exist for Japanese (410 rows), Simplified and
|
||||
Traditional Chinese (506 each), Ukrainian (505), Brazilian Portuguese (400) and Czech (501);
|
||||
counted at 470ea7a3.
|
||||
counted at 470ea7a3. Georgian has every row the Chinese catalogs have at this revision (695, as
|
||||
both Chinese catalogs now have), plus 191 for panels converted to the catalog.
|
||||
- **Effect names:** `crates/ui-egui/src/i18n/effects.rs`, keyed by **effect id**, in Simplified
|
||||
Chinese (306 rows, one per effect in the registry, plus its 21 categories). The registry's
|
||||
Chinese and Georgian (307 rows, one per effect in the registry, plus its 21 categories). The registry's
|
||||
English name stays the effect's identity: `effect.list`, `effect.apply {"effect": …}`, scripts
|
||||
and expressions match on it, and the property tree stores it as the effect group's name. Only
|
||||
the drawing — the Effect menu, Effect Controls, the Timeline and Effects & Presets — goes
|
||||
@@ -82,13 +83,14 @@ the true percentages are slightly lower.
|
||||
| Korean | `ko` | 0 | — | Hangul fallback probe added (#599, closed); not checked on macOS here | no | none | 9–13 h + review |
|
||||
| Vietnamese | `vi` | 0 | — | Latin with stacked diacritics; untested | no | none (After Effects: none) | 8–12 h + review |
|
||||
|
||||
Other languages EffectCraft ships (3):
|
||||
Other languages EffectCraft ships (4):
|
||||
|
||||
| Language | Code | UI strings translated | Status | To `full` |
|
||||
|---|---|---|---|---|
|
||||
| Traditional Chinese | `zh-hant` | 1,130 (≈ 29%): menus 624 rows, panels 506 | partial | 6–9 h + review |
|
||||
| Ukrainian | `uk` | 1,129 (≈ 29%): menus 624 rows, panels 505 | partial | 6–9 h + review |
|
||||
| Czech | `cs` | 1,125 (≈ 29%): menus 624 rows, panels 501 | Existing catalog entries covered; unwrapped UI, effects and engine text still in English; AI-assisted, not yet native-speaker proofread | Remaining strings + independent native review |
|
||||
| Georgian | `ka` | 1,514 (≈ 39%): menus 628 rows, panels 886 (the Chinese catalogs' 695 plus 191 Georgian-only rows: form dialogs, context menus, column headers), effect names 307 + 21 categories; native-speaker reviewed | partial | 5–8 h |
|
||||
|
||||
Russian (`ru`) has a menu catalog (624 rows, ≈ 16%); After Effects also ships Italian, which EffectCraft does not (8–12 h).
|
||||
|
||||
@@ -103,13 +105,14 @@ needs ≈ 2,900 more strings (≈ 5–9 h per language at the pace of #398 and #
|
||||
Effects' 10 non-English languages: **≈ 60–110 h**; the four extra languages of the standard list
|
||||
(Hindi, Arabic, Indonesian, Vietnamese) add ≈ 50–80 h, most of it right-to-left layout and
|
||||
complex shaping in the interface. Every language needs native-speaker review, which only a human
|
||||
can give; none has had it.
|
||||
can give; only Georgian, contributed by a native speaker (#1843), has had it.
|
||||
|
||||
## Revision history
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-12 | minor | Recounted: Russian, French, German and Czech menus (#873, #987), Brazilian Portuguese and Czech panels (#830, #987), Hangul probe (#599); overall ≈ 12% to ≈ 18%; Korean and Italian still absent |
|
||||
| 2026-10-14 | minor | Georgian (`ka`): menus 628, panels 886 (191 Georgian-only, in `GEORGIAN_PANELS`), effect names and categories in `effects.rs` |
|
||||
| 2026-10-10 | major | First localization parity document: catalog rows counted for all six languages, the twelve-language table, After Effects' 11 shipped languages from its bundle |
|
||||
| 2026-10-11 | minor | Ukrainian panel catalog brought level with the Chinese ones (495 of 496 rows, counted at this commit); its share rises to ≈ 29%. The other languages' panel counts are the 10 October measurement |
|
||||
| 2026-10-13 | minor | Effect names and the generated Effect menu leave English: `i18n/effects.rs` adds 306 rows keyed by effect id (Simplified Chinese, from After Effects 2026's interface and Adobe's Chinese documentation for the `CC` series) and the 21 category names. Parameter names are still English; no other language has rows |
|
||||
+3
-2
@@ -30,7 +30,7 @@ Commands (CLI, MCP, control channel):
|
||||
|
||||
### Settings that change behaviour
|
||||
|
||||
- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`; the browser build stays in English with it. Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the `missing_translations_report` test lists what each language lacks, and the tests in `ui.rs` fail on an unused row.
|
||||
- `general.language`: interface language (`system` / `en` / `ja` / `zh-hans` / `zh-hant` / `uk` / `es` / `pt-br` / `ka`), Settings ▸ General ▸ Language; menu labels change immediately. `system` (Match System, the default) follows the operating system's interface language where EffectCraft has a translation and is English otherwise: a Simplified Chinese locale (`zh`, `zh-CN`, `zh_CN.UTF-8`, `zh-Hans-CN`, `zh-SG`) selects `zh-hans`, a Traditional one (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) selects `zh-hant`, a Ukrainian one (`uk`, `uk-UA`, `uk_UA.UTF-8`) selects `uk`, a Spanish one (`es`, `es-ES`, `es_MX.UTF-8`) selects `es`, a Portuguese one (`pt`, `pt-BR`, `pt_PT.UTF-8`) selects `pt-br`, a Georgian one (`ka`, `ka-GE`, `ka_GE.UTF-8`) selects `ka`; the browser build follows `?lang=` in the address, then the browser's languages (`navigator.languages`). Choose **Español** or **Português (Brasil)** for Spanish or Brazilian Portuguese menus (panels stay in English for now); choose **Українська** for Ukrainian menus and cataloged panel, dialog, button and tooltip text; Ukrainian uses the bundled UI fonts. Native Japanese, Simplified and Traditional Chinese UI uses installed system fonts; the web host must supply a Japanese font. The same setting picks the CJK fallback for the interface *and* the Han preference of the text engine, so the composition's own text is drawn with the same language's glyph forms (the text engine's preference is process-global); swapping the language reinstalls the fonts, so it applies without a restart. Two catalogs back the text: the menus are keyed by `(command, English label)` (`crates/ui-egui/src/i18n.rs`), and the rest of the interface - panels, dialogs, buttons, tooltips - is keyed by its English source string (`crates/ui-egui/src/i18n/ui.rs`, with rows for Japanese, Simplified and Traditional Chinese and Ukrainian), where a panel writes `tr("Rename")`, or `tr_args("Proxy Use: {}", &[&quality])` when the row carries a value. A row that is missing falls back to English, so a new string shows up untranslated rather than empty; the `missing_translations_report` test lists what each language lacks, and the tests in `ui.rs` fail on an unused row.
|
||||
- `general.undoLevels`: Levels of Undo
|
||||
- `general.pathPointSize`: Path Point and Handle Size
|
||||
- `general.recentItems`: Recent Projects Shown
|
||||
@@ -91,7 +91,8 @@ Commands (CLI, MCP, control channel):
|
||||
one and in the comps that nest it (through the precomp layers' timing)
|
||||
- `general.expressionPickWhipCompact`: off, the pick whip writes match names
|
||||
(`thisComp.layer("A")("transform")("opacity")`)
|
||||
- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer
|
||||
- `general.createSplitLayersAbove`: where Edit ▸ Split Layer puts the new layer (on by default,
|
||||
as in After Effects)
|
||||
- `startup.showHomeOnOpenProject`: the Home screen comes up after File ▸ Open / Open Recent
|
||||
- `composition.motionPath`, `composition.motionPathSeconds`, `composition.motionPathKeyframes`:
|
||||
how much of a position motion path the viewer draws (all, none, N seconds or N keyframes
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ feel and platform differences, and the 26.x interface changes are not in yet.
|
||||
|---|---|---:|---|
|
||||
| Menu bar | After Effects' menus, order, submenus and labels; native on macOS; keyboard-operable in-window bar | 95% | 587 entries in `crates/engine/src/menus.rs`; Adobe-service entries omitted by design; `ui_menus` tests (#279) |
|
||||
| Panels and workspaces | Every After Effects panel; all standard workspaces; drag-to-dock, tabs, floating panels, maximise | 75% | `ui_tabs`, `ui_panels`; panels move into their own native windows on any screen (#486 open: no dragging tabs between those windows); #302, #301 and #320 (docking, stacked-panel headers, workspace persistence) are closed |
|
||||
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there) |
|
||||
| Timeline | Switches, modes, parent & link, twirl-downs, reveal shortcuts (U, UU, P, S, R, T, A, E, M, F, Alt+Shift…), pick whips, graph editor, work area, markers | 75% | `ui_keyframes`, `ui_timeline_*`; expand precomps in place (#601); #456, #457, #569 and #355 (Graph Editor jitter and buttons, negative rotation, bulk keyframing) are closed; Proportional Scrubbing (26.2: Ctrl+Alt / Cmd+Opt scrub of hot text spreads the change over the selected layers by selection order, #770) matched for layers selected as a whole, not yet for individually selected properties; every layer has the Video switch, nulls, cameras and lights included (a null with its eye off has no box or handles in the viewer and isn't picked there); a row control (a switch, the label swatch, a Modes cell) clicked on one of several selected layers acts on the whole selection, with the layers the switch doesn't apply to left out (#1849) |
|
||||
| Composition viewer | Selection, handles with direction-aware cursors, rulers, guides, grids, snapping, channels, exposure, region of interest, snapshots, 3D views, Spacebar hand | 70% | `ui_viewer`, `ui_viewers`; Cmd/Ctrl +/− doesn't zoom (#570); anchor point grab on Linux (#492); #352 (snapping) is closed; no 3D transform gizmos (#392, #347); percentage guides (26.5) exist as View ▸ Add Percentage Guide and Convert Guides to Percentage / Pixels (a guide stores a percent of the comp, so it follows comp-size changes; behaviour inferred from the release note, not compared with After Effects); Copy Frame to Clipboard (26.3) is in the Composition menu and the viewer's context menu |
|
||||
| Tools panel | All After Effects tools; long-press flyouts; Tool Creates Shape / Mask; Fill / Stroke options | 80% | #213, #227 fixed; #553 (pen and vertex tools on motion paths) is closed |
|
||||
| Nudging and modifiers | Arrow nudge (1 px, Shift 10 px), Alt/Option duplicates and slips, Shift constrains | 75% | #290 nudging fixed; #491 (Linux multi-layer edits) is closed |
|
||||
|
||||
Reference in new issue
Block a user