- New TRADITIONAL_CHINESE catalog: the same (command, English label) keys as JAPANESE and
SIMPLIFIED_CHINESE, one entry per fixed menu row (618 rows). The wording follows the
Traditional Chinese catalogs PhotoCraft, PdfCraft, VectorCraft and LightCraft already ship
(`檔案`, `開啟`, `儲存`, `圖層`, `遮色片`, `影格`, `預設集`, `快速鍵`, …).
- Match System maps a Traditional Chinese locale (`zh-TW`, `zh-HK`, `zh-Hant`, `zh_MO`) to
`zh-hant` instead of English, now that there is a catalog for it.
- `simplified()` and `traditional()` replace the Simplified-only `chinese()`; the macOS Services
item is 服务 in Simplified and 服務 in Traditional.
- `general.language` gains 繁體中文; `docs/preferences.md` documents the locale mapping.
- Tests: all three catalogs must cover the same fixed TREE entries; the shape check covers both
Chinese catalogs; label/undo/panel-title/submenu/native-menu behaviour in zh-hant with file and
layer names kept verbatim; the Traditional locale mapping; the preference round-trips.
- The i18n module now selects a catalog by language code instead of testing for Japanese:
`language(app)` returns the code, `lookup` is shared by every catalog, and the undo/redo
and workspace / 3D-view titles are formatted per language.
- New SIMPLIFIED_CHINESE catalog: the same (command, English label) keys as JAPANESE, one
entry per fixed menu row of the engine tree (618 rows).
- `general.language` gains 简体中文 (`zh-hans`). Match System maps a Simplified Chinese locale
(zh, zh-CN, zh_CN.UTF-8, zh-Hans-CN, zh-SG) to it and a Traditional one (zh-TW, zh-HK,
zh-Hant) to English, which has no catalog of its own yet.
- The macOS Services menu item is 服务 in Chinese.
- Tests: both catalogs must cover the same fixed TREE entries; `...`, brackets and spacing
keep the English shape; label/undo/panel-title/submenu/native-menu behaviour in zh-hans
with file and layer names kept verbatim; the locale mapping; the preference round-trips
through prefs.json.
- release.yml: `freebsd` job (FreeBSD 14.3 VM, the freebsd.yml packages) running the new
packaging/freebsd/package.sh; `flatpak` matrix (x86_64 on ubuntu-24.04, aarch64 on
ubuntu-24.04-arm) repackaging the Linux tarball with packaging/linux/flatpak-bundle.sh and the
new ai.storyteller.effectcraft.bundle.yml; both added to the draft release's needs.
- AppImages embed gh-releases-zsync update information and ship a .zsync beside them; the Linux
job installs zsync and checks both.
- version, Linux and web jobs drop `environment: release` (they read no secrets), so a dispatch on
a branch is a full dry run of every unsigned job; the draft release still needs the signed jobs.
- Flatpak finish-args documented and shared by the bundle and Flathub manifests (lint checks they
match); audio, GPU, portals, xdg-pictures/videos/documents, network for the optional model
downloads. The Flathub manifest now also installs effectcraft-cli.
- packaging-lint runs the FreeBSD layout dry run.
- README: Downloads section (every file a release ships) and nav entry; docs/releasing.md updated.
Co-authored-by: Brandon Thomas <shared2@storyteller.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Imported GLB/glTF models were invisible in the Composition viewer while
text showed. Only the Advanced 3D renderer draws model and primitive
layers, and new compositions (and New Comp from Selection) are Classic
3D by default, but adding a model layer left the comp's renderer alone,
so the model drew nothing on every backend. It wasn't the reporter's
GPU: on this machine the viewer showed nothing for a GLB in a new comp
with the GPU compositor (Vulkan) and with Mercury Software Only, and
the model in both once the comp used Advanced 3D.
As After Effects documents ("Advanced 3D gets automatically selected
when you add a 3D model to the composition"), layer.newModel /
layer.addItem of a model and layer.newPrimitive now switch a Classic
3D comp to Advanced 3D inside the same edit (one undo step, Geometry
Options added to its text and shape layers like Composition Settings
does) and toast that the comp changed renderer. Switching a comp that
has model layers back to Classic 3D toasts why they disappear.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#276 documents ["mcp", "--gpu"] and get_project's renderer.active / why in
the Headless setup notes of the same file, so the pitfall only repeated them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1RGTt6AE3ysiaMQ7X6Nea
The Environment light existed (LightKind::Environment) but layer.newLight's help
and the kind errors of layer.newLight and layer.lightSettings listed only
Parallel|Spot|Point|Ambient, so agents could not find it. The help now lists it
and says how to give it an equirectangular image: prop.set
lightOptions/source to that layer's id, or leave it empty for the comp's
Environment Layer (layer.environment). layer.lightSettings documents its kind
values too.
docs/parity.md (3D) notes that Advanced 3D surfaces reflect only the
Environment light and that Appears in Reflections is stored but has no visible
effect yet. Object-to-object reflections stay tracked in #261.
Test: tests_3d::environment_light_is_listed_and_takes_a_source_layer (help and
both kind errors name every LightKind; an Environment light takes a source
layer by id, with undo).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1RGTt6AE3ysiaMQ7X6Nea
get_project / render.backend reported "gpuAcceleration": true with
"active": "cpu" and no reason, which read as a broken GPU. The
headless MCP server only attaches the GPU compositor when started with
--gpu, which docs/agents.md didn't mention, and a failed --gpu said
only "no usable GPU adapter" (the adapter's error went to a log no
one sees). The session now keeps the host's reason there is no GPU
compositor (accel_note): the CLI's "headless: renders on the CPU
unless started with --gpu", or the desktop app's compositor setup or
retirement error. The status reports it as `why` whenever it renders
on the CPU (or that the project's renderer is Software Only). --gpu
failures include the adapter's error (Gpu::try_headless).
docs/agents.md documents `mcp --gpu`.
Test: render_backend_says_why_it_renders_on_the_cpu (engine).
Checked with effectcraft-cli mcp and mcp --gpu.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds a short Pitfalls section to docs/agents.md: layer time vs comp time
for keyframe tools (#257), layer.timing to set a start time, app.run()
in scripts and undo groups, saving early in headless mode (#258),
`mcp --gpu` and renderer.active (#262), and the Environment light with
lightOptions/source (#261).
Docs only; every statement checked against main and a 0.4.0 build.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S1RGTt6AE3ysiaMQ7X6Nea
Dragging a layer already snapped its feature nearest the pointer to
other layers' edges, corners, centres, anchor points and path points
and to the comp's edges and centre, and Cmd/Ctrl held during the drag
already turned snapping on or off against the checkbox. What was
missing were After Effects' Snapping options beside the Tools bar's
checkbox. The new menu turns Snap Edges Extended off (a layer's edges
then snap only along the layer, not across the comp) and each feature
family (edges, corners, centres, anchor points, mask and shape path
points) on or off, for the dragged layer's snap features and for the
targets alike. Agents set them with view.snappingOptions.
Layers whose eye is off (or that a solo hides) are no longer snap
targets, mask and shape path points of the dragged layer snap too, and
while snapping is on the feature that snaps is boxed during the drag.
Test: layer_drag_snaps_to_other_layers_edges_ctrl_toggles_and_options_apply
(UI), snapping_options_pick_the_targets and
snapping_options_command_sets_and_toggles (engine).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A user suggested py-aep (MIT, Python), which reads and writes .aep
files. G4 now records it for the clean-room decision, with the two
ways it could be used: as an external converter, or ported, which
means reading another implementation of the format.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On some older Intel integrated GPUs (UHD 600, UHD with 2020 drivers)
the graphics driver crashes EffectCraft as its window's DirectX 12 or
Vulkan device is created (exit code 0xC0000005): the process
disappears with no message, and only WGPU_BACKEND=gl helped. A driver
crash can't be caught, so each launch now leaves a marker in the
settings folder until its window draws. A launch that finds the
previous one's marker switches the new Settings ▸ Startup & Repair ▸
Window Graphics (Automatic / OpenGL) to OpenGL and says so in the
status bar. A window that fails to open says the next launch will try
OpenGL. WGPU_BACKEND still wins; macOS has no OpenGL backend, so it
never switches there.
Tests: a_launch_that_never_drew_switches_the_next_to_opengl (app),
window_graphics_is_validated (engine). Checked end to end with a
leftover marker: the app opened on OpenGL with the notice and cleared
the marker.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On macOS 27.2 (rustc 1.98) one of the 404 pinned simulation hashes,
CC Mr. Mercury at non-default settings, 32 bpc, t = 2.2 s, came out
different. The reporter rendered every case at the commit that pinned
the hashes and at current main on that machine, and the frames were
byte-identical. So the code didn't change: the platform's libm did
(the hashes are exact f32 bits through sin, cos and powf). The test
now also accepts listed per-case libm variants, with that hash as the
first, and every other case stays exact. Its docs say how to tell a
libm difference from a code change.
Test: libm_variants_are_accepted_for_their_case_only (sim_golden; runs
on every platform).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reports of 8 October (#225-#227, #229-#231), their fixes (#235,
#237-#241), what remains of #227 (multi-monitor support, the Project
panel's empty-area menu, effect points on shape and text layers) and
the regression tests for each.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Tools bar had Fill and Stroke swatches and a Stroke Width, kept in
the interface only, so agents couldn't set them and new shapes could
only be a solid fill with a solid stroke. As in After Effects:
- With a shape layer selected and a shape tool or the Pen active, Tool
Creates Shape / Tool Creates Mask choose whether drawing adds shapes
to the layer or masks on it (Fill and Stroke hide while drawing
masks).
- Clicking the word "Fill" or "Stroke" opens Fill Options / Stroke
Options: None, Solid Color, Linear Gradient or Radial Gradient, the
blend mode and the opacity. The swatches show the kind; Alt-click
steps through the kinds.
- New shapes and Pen paths are painted with them: a Gradient Fill or
Gradient Stroke across the shape for the gradient kinds.
The options are engine state (EditorState.shape_tool) set with the new
shape.toolOptions command; shape.newShape and shape.newPath use them
unless given paint parameters (which now include fillType, fillBlend,
fillOpacity and their stroke twins; layer.newShape takes them too). A
Pen path without paint parameters now takes the Tools bar's stroke
(none by default) instead of a fixed 2 px white one. layer.addMask also
draws rounded rectangle, polygon and star masks, so every shape tool
makes a mask on a selected footage or solid layer (rounded rectangles,
polygons and stars made a new shape layer).
Test: shape_tool_options_paint_new_shapes,
masks_of_every_shape_tool_kind,
tool_creates_mask_draws_a_mask_on_the_selected_shape_layer,
fill_options_paint_the_next_shape.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With a shape layer selected, the Rectangle, Rounded Rectangle, Ellipse,
Polygon and Star tools made a new shape layer for every shape. After
Effects draws into the selected shape layer instead: the shape becomes a
new group on top of its Contents ("Rectangle 2", ...), placed by the
group's Transform. The new shape.newShape command does that (in the
layer's space, so a moved or scaled layer still gets the shape where it
was drawn), and makes a new shape layer only when no unlocked shape
layer is selected. The viewer's shape tools use it; masks on other
selected layers are unchanged. layer.newShape and the Pen's
shape.newPath share its layer and Contents helpers.
Test: shape_tools_draw_into_the_selected_shape_layer,
shape_tool_draws_into_the_selected_shape_layer.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The interface language was English unless Japanese was chosen in
Settings ▸ General ▸ Language. Language now has a Match System choice
(`general.language` = `system`), the new default: the operating
system's interface language where EffectCraft has a translation
(Japanese), else English, as After Effects installs in the system's
language. English and Japanese can still be chosen; a language chosen
before stays. The browser build stays in English with Match System,
since it has no Japanese font of its own.
The system language comes from sys-locale (MIT OR Apache-2.0, no
dependencies on the desktop platforms), on desktop builds only.
Tests: match_system_uses_the_system_language_where_there_is_a_catalog
(i18n), interface_language_is_validated_persisted_and_backward_compatible
(engine).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the page's WebGPU device was lost, the canvas kept showing its
last frame: edits still went through and rendered frames were right,
but the editor never showed them and nothing said why (the status
message about it is drawn in the canvas that can't present any more).
The page now covers the canvas with a notice that offers Save Project
(a download) and Reload. Reload first writes pending changes to
browser storage, so the session snapshot brings the project and its
unsaved changes back; if that write fails it says so and offers
Reload Anyway. Without a kept session (?empty, memory storage) the
notice says to save first.
Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Files... and Upload Folder... read every picked file in one go and
rejected on the first file the browser couldn't read; the Media
Browser dropped that rejection, so nothing was added and nothing said
why. Each file is now read on its own: the readable ones are added and
the status bar names the unreadable ones with the browser's reason and
suggests picking them again. Cancelling the picker is not reported.
Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle)
About EffectCraft now has tabs About · Contributors · Models, following the craftrules standard
(standards/contributors.md): contributors/contributors.json is generated by
craftrules/scripts/contributors.py and baked into the binary by crates/ui-egui/build.rs as static
tables. Each contributor line: @username, opt-in display/real name, PRs, commits, lines +/−/Δ,
binary assets +/−, first/last commit. Sortable by every column; alphabetical ignores case and
the leading @. Every control registers an automation id (about.tab.*, about.credits.*).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section
build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* Contributors: regenerate (verified people.toml entries)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
A write to browser storage that failed (quota exceeded) was dropped:
effectcraft.flush() resolved anyway, nothing retried it, and a reload
restored the project from before the change. The failed key now stays
pending and is written again after 2 s (doubling to a minute while it
keeps failing; a newer change of the key goes with it), and flush()
rejects with the error.
Test: failed_writes_retry (store). The flush itself runs only in the
browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
worker.js left a rejected start (its async init) unhandled, which a
page's Worker error event doesn't see: the page waited for "ready"
forever and the render stayed pending until cancelled. The failed start
is now re-raised where the page's onerror sees it, like the other
worker failures; the job fails with the error and the worker is
terminated.
Test: apps/effectcraft-web/tests/workers.mjs (worker.js with a stub
module whose init rejects; host.js with a worker whose start fails).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
The desktop app opened the demo project whenever it wasn't given a
project file, so File ▸ New Composition on the Home screen added "Comp
4" to the three demo comps. As in After Effects, a launch now starts
with an empty Untitled Project. --demo opens the demo (Help ▸ Open
Demo Project and the Home screen tile still do); --empty is kept.
The arguments are parsed by a function now, with a test
(no_arguments_start_an_empty_project).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018gnTnH3Jmk5zr4tMq1Mc9f
A text layer was drawn at its font size and the bitmap resampled by the
layer's Scale unless its Continuously Rasterize switch was on, so text
grown with Scale (the usual way to animate it) went soft. Text layers now
always rasterise at their on-screen scale, as they would with the switch;
shape layers and vector footage still follow the switch.
Text at 10 px scaled 800% now renders identically to text set at 80 px
(795 partially covered pixels each, maximum difference 0; it was 5854
against 795 before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
add_effect ran effect.apply and then one prop.set per `values` entry as
separate commands: each became its own undo step, and a bad value (an
unknown parameter id) returned an error but left the effect, and any
values set before it, on the layer, so a corrected retry landed at
effects/#2.
It now runs them as one engine.batch, named like effect.apply's own undo
step ("Apply Gaussian Blur"): one undo removes the effect with its values,
and a failing step rolls the whole call back. The reply is unchanged.
Test: add_effect_is_one_undo_step_and_all_or_nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
prop.setExpression keeps an expression with a syntax error but disables
it, and says why only in its own reply. MCP set_property answers with
prop.get instead, and prop.get reported `evaluated` / `expressionError`
only for enabled expressions, so set_property, get_property and prop.get
all showed the broken expression as if it were installed.
prop.get now reports a disabled expression too: `evaluated` is the
static value that renders, and `expressionError` is its syntax error.
Test: expression_syntax_errors_reach_the_reply (set_property and
get_property carry the SyntaxError; runtime errors and valid expressions
report as before).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Args::parse kept any unrecognised `--option` as a valueless flag nobody
read, so `exec comp.new --bogus` ran with defaults and exit 0, and a
misspelt valued option left its value behind as a positional:
`--saveas z.ecproj` opened z.ecproj as the project and failed with an
unrelated "cannot read" error.
Options are now checked against the declared lists (VALUED and the new
FLAGS) before anything runs; unknown ones are a usage error naming them
(exit 2), and `--flag=value` on a flag says it takes no value.
render-frame's `--transparent` (the MCP render_frame option) was one of
the silently ignored options; it now keeps the frame's alpha.
Tests: unknown_options_are_refused, every_option_read_is_declared (every
option the code reads is declared, so none is refused),
unknown_options_are_usage_errors (exit 2, nothing runs or renders, and
--transparent renders alpha).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #174: the file names are fixed (effectcraft_web.js, effectcraft_web_bg.wasm, worker.js,
sw.js…), not content-hashed, so the guide's year-long immutable caching kept returning
visitors on the old release after an in-place upgrade. The guide, nginx example, _headers
and .htaccess now revalidate (no-cache) the .wasm, .js and index.html, and suggest a
versioned path for longer caching. The file table lists the real files.
- #180: the .wasm is about 58 MB (17 MB gzip, 10 MB Brotli) for v0.3.1, not 13 MB / 5 MB;
docs/web.md's 38 MB figure is updated too.
- #165: preferences live in the origin's browser storage (OPFS, IndexedDB or memory), not
localStorage.
- #164: relative URLs come from the page itself (and package.sh's check), not a Trunk.toml
that doesn't exist.
The iframe example's title no longer says image editor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Tools table gains history and script_ui, get_layer's depth,
add_keyframe's comp and the bridge screenshot's max_side and path.
agents_doc_tools_table_is_current (effectcraft-automation) fails when a
tool or an argument of its input schema is missing from the table, as
effects_doc_is_current guards docs/effects.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
How a release is cut (version bump, the gate, pushing main to release,
the draft, publishing), test runs, what gets built where, the Linux
glibc baseline behind the ubuntu-22.04 runners, and the optional signing
secrets. release.yml's two pointers now resolve.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A line that is not a JSON object with a string `method` (an HTTP request
line, other non-JSON text, a JSON array, invalid UTF-8, a line over 4 MiB)
now gets one error reply and the connection is closed, so a browser's
cross-origin fetch can't smuggle a command in its body. Connections are
capped at 16. Same transport as LightCraft (storytold/lightcraft#129) and
FilmCraft (storytold/filmcraft#123).
The ui_angles evidence (#141) moves next to G2's other regression
evidence; the Stability row notes the parent-chain hang, the label colour
panic and the contained GPU failures.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gaps.md records the seven reports of 6 October (#85, #88, #89, #93, #103,
#106, #117), what they found in features marked done, their fixes of 7
October and the regression tests; parity.md's Interface, Animation and
Preview rows, the ROADMAP's reliability note and the README's panels
paragraph (drag and drop between panels) follow.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The desktop app shares egui-wgpu's device, which had no uncaptured-error
handler, so wgpu's default one panicked on an out-of-memory error on
every frame thread; the panicking frame stayed "in progress" forever and
never rendered again. The GPU frame budget was a fixed 1 GiB.
- GpuContext::new logs uncaptured errors once its pipelines are built
(errors while building them still panic there, which the app catches
to fall back to CPU compositing).
- Gpu::within_memory runs GPU work in an out-of-memory error scope; on an
error the result is discarded and uploaded layers and pooled textures
are freed.
- Frame workers render each frame within it; out of memory, the frame
renders on the CPU and the RAM preview keeps half as many GPU frames
(down to 64 MiB), since wgpu can't tell how much video memory there is.
- A frame whose render panics renders again on the CPU, or is released,
so the viewer asks for it again.
Tests: a_frame_whose_render_panics_is_released (failed before),
running_out_of_video_memory_halves_the_gpu_frame_budget and
within_memory_returns_the_work_when_there_is_room.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Toggling a layer's Audio, Lock or Shy switch (or Hide Shy Layers) threw
away every cached frame: the RAM preview identity hashes each comp's
address, which any edit replaces, and the layer and disk cache keys
hashed every switch. None of these change pixels.
- Switches::pixels, Layer::same_pixels and Comp::same_pixels (exhaustive
field lists, so a new field must be considered) and Comp::pixel_form.
- Layer cache keys hash Switches::pixels; the disk cache's comp content
key hashes Comp::pixel_form.
- Frames::identity: when a new revision's comps draw the same pixels as
those of the comp's last new identity, that identity (and the project
holding its comps' addresses) is kept, so the frames stay, also when the
switch is toggled back.
With audio on, playback skipped frames that weren't rendered yet. Now the
sound waits: until the frames ahead (to the end of the preview, or what
fits in the RAM preview) are cached, every frame shows as it renders,
silently, like a preview without audio; then the sound starts in sync.
Reaching an uncached frame stops the sound instead of skipping frames.
Tests: audio_lock_and_shy_switches_keep_the_frames (frames.rs, nested
comps and toggling back), audio_lock_and_shy_keep_cached_layers (engine),
the disk key in tests_disk_cache, and
preview_with_audio_shows_every_frame_and_sounds_once_cached.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An angle's revolutions were painted text: only the degrees could be
dragged or typed, so full turns needed the dial or typing past 360.
fx_widgets::angle_field draws `0x+45.0°` as two hot numbers: the
revolutions (widgets::hot_int_at, ten pixels a turn, fractions adding up
between frames so slow drags still step; a typed value is truncated) and
the degrees, each keeping the other. Effect Controls, the Timeline and
the Properties panel use it, so they read and edit angles alike (the
Timeline showed `-1x-40.0°` and Properties had its own split).
Automation ids: timeline.prop.<uid>.revolutions,
effectControls.prop.<uid>.revolutions, properties.prop.<uid>.revolutions.
Regression test: angle_revolutions_scrub_and_take_typing drags and types
the revolutions in all three panels.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>