From 4d5a56ed1ea12a43b33a1141f85d50a1afbb2f3b Mon Sep 17 00:00:00 2001 From: Jeremy Date: Mon, 26 Oct 2026 22:18:43 +0000 Subject: [PATCH 01/38] feat(installer): default to a per-user ArtCraft folder --- .github/workflows/packaging-lint.yml | 19 ++ packaging/windows/INSTALLATION.md | 35 ++++ packaging/windows/check-install-policy.ps1 | 195 +++++++++++++++++++++ packaging/windows/effectcraft.wxs | 63 +++---- packaging/windows/install-policy.json | 44 +++++ packaging/windows/install-policy.wxi | 54 ++++++ packaging/windows/installer-ui.wxs | 12 -- packaging/windows/package.ps1 | 2 + packaging/windows/test-install-policy.ps1 | 54 ++++++ packaging/windows/test-msi.ps1 | 11 +- 10 files changed, 431 insertions(+), 58 deletions(-) create mode 100644 packaging/windows/INSTALLATION.md create mode 100644 packaging/windows/check-install-policy.ps1 create mode 100644 packaging/windows/install-policy.json create mode 100644 packaging/windows/install-policy.wxi create mode 100644 packaging/windows/test-install-policy.ps1 diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 1353830f8..1f9e844db 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -22,3 +22,22 @@ jobs: lint: # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + + windows-install-policy: + timeout-minutes: 10 + name: Windows installation policy (no app build) + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Install the pinned packaging toolchain + shell: pwsh + run: | + $tools = Join-Path $env:RUNNER_TEMP 'craft-install-policy-wix' + dotnet tool install --tool-path $tools wix --version 5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } + $tools >> $env:GITHUB_PATH + & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 + if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + - name: Build and check non-executable installer fixtures + shell: pwsh + run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/INSTALLATION.md b/packaging/windows/INSTALLATION.md new file mode 100644 index 000000000..982a5b420 --- /dev/null +++ b/packaging/windows/INSTALLATION.md @@ -0,0 +1,35 @@ +# Windows installation policy checks + +New installations default to the current account under `Programs\ArtCraft\`. +The first setup page offers installation for everyone. Scope is selected before duplicate +checks and directory costing. An update uses the existing context's remembered exact folder; +`INSTALLFOLDER` supplied explicitly is never replaced. It is a secure MSI property so the +chosen folder reaches the elevated installer. Silent machine updates pass `ALLUSERS=1`. +A cross-context copy is refused; changing context means uninstalling the old copy first. + +App registration follows the selected context. Existing optional shortcuts and file-handler +choices stay app-owned. No new cleanup operation or model/settings migration is introduced. +Packaged resources remain package-owned; separately downloaded shared models are not caches +and are outside this installer's ownership. + +## Repeatable metadata tests + +On Windows with WiX 5.0.2 and its matching UI/Util extensions: + +```powershell +./packaging/windows/test-install-policy.ps1 -OutputDirectory ./target/install-policy-tests +``` + +The script builds x64, x86 and arm64 packages with deliberately non-executable payloads. It +uses the actual package source and compiled database. A safe Windows Installer session checks +24 scope/registration/maintenance cases, remembered and explicit custom folders, sequencing, +upgrade identity and the original registration inventory recorded in `install-policy.json`. +The release packaging script runs the compiled checker before signing. Packaging CI runs the +small fixture builds without compiling the Rust application. + +Never install or distribute the `NOT-INSTALLABLE.msi` fixtures as an app. Passing these checks +does not prove real installation, elevation, legacy-version upgrades, registry writes, optional +shortcut/menu behaviour, cancellation, rollback or uninstall. Those still require the real +payload in an authorised clean Windows test environment. Cross-architecture metadata is not +execution evidence on those architectures. Existing open installer/association PRs must be +reconciled before publication. Nothing here changes macOS/Linux native packaging conventions. diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 new file mode 100644 index 000000000..dd2895643 --- /dev/null +++ b/packaging/windows/check-install-policy.ps1 @@ -0,0 +1,195 @@ +<# +.SYNOPSIS + Check the compiled installation policy without installing any product. +.DESCRIPTION + Uses Windows Installer's safe session (OpenPackage option 1). Only property-setting custom + actions are exercised. No AppSearch, install, repair, removal or file-writing action runs. +#> +param([Parameter(Mandatory = $true)] [string] $MsiPath) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +function Read-Rows($Database, [string] $Sql, [int] $Columns) { + $view = $Database.OpenView($Sql) + try { + [void]$view.Execute() + while ($null -ne ($record = $view.Fetch())) { + try { + $values = @(for ($i = 1; $i -le $Columns; $i++) { + $record.GetType().InvokeMember('StringData', 'GetProperty', $null, $record, @($i)) + }) + ,$values + } finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($record) } + } + } finally { + [void]$view.Close() + [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($view) + } +} +function Set-SessionProperty($Session, [string] $Name, [string] $Value) { + [void]$Session.GetType().InvokeMember('Property', 'SetProperty', $null, $Session, @($Name, $Value)) +} +function Get-SessionProperty($Session, [string] $Name) { + $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) +} +$installer = $null; $database = $null; $session = $null +try { + $installer = New-Object -ComObject WindowsInstaller.Installer + $file = (Resolve-Path -LiteralPath $MsiPath).Path + $database = $installer.OpenDatabase($file, 0) + $session = $installer.OpenPackage($file, 1) + $properties = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Value` FROM `Property`' 2)) { $properties[$row[0]] = $row[1] } + $secureProperties = @($properties['SecureCustomProperties'] -split ';') + if ('INSTALLFOLDER' -notin $secureProperties) { + throw 'The selected installation folder must survive elevation (secure INSTALLFOLDER).' + } + if ($policy.mode -eq 'perUser') { + if ($properties.ContainsKey('ALLUSERS')) { throw 'Launcher must remain per-user; machine self-update is not part of this change.' } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'Launcher upgrade identity changed.' } + $dirs = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { $dirs[$row[0]] = @($row[1],$row[2]) } + if ($dirs['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or $dirs['CraftSuiteFolder'][1] -ne 'ArtCraft' -or + $dirs['CraftSuiteFolder'][0] -ne 'LocalProgramsFolder' -or $dirs['LocalProgramsFolder'][0] -ne 'LocalAppDataFolder') { + throw 'Launcher new-install folder must be under the per-user ArtCraft suite root.' + } + foreach ($explicit in @('', 'F:\Chosen exact launcher')) { + Set-SessionProperty $session 'PREVIOUSINSTALLFOLDER' 'E:\Existing exact launcher' + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + $row = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftRestoreLauncherFolder''' 1)[0] + if ($session.EvaluateCondition($row[0]) -eq 1) { + if ($session.DoAction('CraftRestoreLauncherFolder') -ne 1) { throw 'Launcher restore action failed.' } + } + $expected = if ($explicit) { $explicit } else { 'E:\Existing exact launcher' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Launcher custom or existing path was lost.' } + } + $rows = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($rows | Where-Object { $_[0] -ne '1' }).Count) { throw 'Launcher registration must stay under the current account.' } + foreach ($entry in $policy.registry) { + if (@($rows | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }).Count -ne 1) { + throw 'Launcher registration or shortcut preference changed unexpectedly.' + } + } + Write-Output 'PASS ArtCraft Launcher: per-user scope, suite folder, remembered/custom paths and existing registration. No installation performed.' + return + } + if ($properties['ALLUSERS'] -ne '2' -or $properties['MSIINSTALLPERUSER'] -ne '1') { + throw 'Policy regression: new packages must default to the per-user dual-scope context.' + } + if ($properties['UpgradeCode'].Trim('{}') -ne $policy.upgradeCode) { throw 'The existing upgrade identity must not change.' } + $directories = @{} + foreach ($row in (Read-Rows $database 'SELECT `Directory`, `Directory_Parent`, `DefaultDir` FROM `Directory`' 3)) { + $directories[$row[0]] = @($row[1], $row[2]) + } + if ($directories['INSTALLFOLDER'][0] -ne 'CraftSuiteFolder' -or + $directories['CraftSuiteFolder'][1] -ne 'ArtCraft') { throw 'Expected the app under the ArtCraft suite folder.' } + $programs = $directories['CraftSuiteFolder'][0] + if ($programs -eq 'ProgramFiles6432Folder') { $programs = $directories[$programs][0] } + if ($programs -notin 'ProgramFilesFolder', 'ProgramFiles64Folder') { + throw 'Use Windows Installer native folder redirection, not a hard-coded profile path.' + } + $guards = @(Read-Rows $database 'SELECT `Condition`, `Description` FROM `LaunchCondition`' 2 | + Where-Object { $_[0] -match 'CRAFT_(USER|MACHINE)_DIR' }) + if ($guards.Count -ne 2) { throw 'Expected both duplicate-installation guards.' } + $cases = 0 + foreach ($scope in @('', '2', '1')) { + foreach ($user in @('', 'D:\Existing user app')) { + foreach ($machine in @('', 'E:\Existing machine app')) { + foreach ($installed in @('', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'Installed' $installed + Set-SessionProperty $session 'CRAFT_USER_DIR' $user + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $machine + $allowed = $true + foreach ($guard in $guards) { + $value = $session.EvaluateCondition($guard[0]) + if ($value -notin 0, 1) { throw 'Invalid duplicate guard.' } + if ($value -eq 0) { $allowed = $false } + } + $opposite = if ($scope -eq '1') { $user } else { $machine } + $expected = [bool]$installed -or -not [bool]$opposite + if ($allowed -ne $expected) { throw "Scope regression: ALLUSERS='$scope', user=$([bool]$user), machine=$([bool]$machine), maintenance=$([bool]$installed)." } + $cases++ + } + } + } + } + # Execute the actual authored type-51 actions in a safe session, not copied resolver logic. + foreach ($scope in @('', '2', '1')) { + foreach ($explicit in @('', 'F:\Chosen exact app')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' $explicit + Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } + if (-not $explicit) { + if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } + } + $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } + if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } + } + } + $ui = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } + if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + } + $execute = @{} + foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } + foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { + if (-not ($execute['AppSearch'] -lt $execute[$action] -and $execute[$action] -lt $execute['CostInitialize'])) { + throw 'Silent updates must restore the folder before directory costing.' + } + } + # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. + $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) + foreach ($choice in @('user','machine','user')) { + Set-SessionProperty $session 'CRAFT_SCOPE' $choice + foreach ($event in $events) { + if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } + $property = $Matches[1] + if ($session.EvaluateCondition($event[2]) -eq 1) { + Set-SessionProperty $session $property $(if ($event[1] -eq '{}') { '' } else { $event[1] }) + } + } + $allUsers = Get-SessionProperty $session 'ALLUSERS' + $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' + if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or + ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + throw 'Scope selector does not reset the requested installation context.' + } + } + $actions = @(Read-Rows $database 'SELECT `Action`, `Source`, `Target`, `Type` FROM `CustomAction`' 4) + $savedOptions = @($actions | Where-Object { $_[0] -match '^CraftLoadOption[0-9]+M$' }) + foreach ($option in $savedOptions) { + if ([int]$option[3] -ne 51) { throw 'Saved-option test may run only property-setting actions.' } + $number = [regex]::Match($option[0], '[0-9]+').Value + foreach ($scope in @('2','1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session "CRAFT_OPTION_${number}_M" 'saved-machine-choice' + Set-SessionProperty $session "CRAFT_OPTION_${number}_U" 'saved-user-choice' + Set-SessionProperty $session $option[1] '' + $action = "CraftLoadOption$number" + $(if ($scope -eq '1') { 'M' } else { 'U' }) + if ($session.DoAction($action) -ne 1) { throw 'Saved-option property action failed.' } + $expected = if ($scope -eq '1') { 'saved-machine-choice' } else { 'saved-user-choice' } + if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } + } + } + $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) + if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } + if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { + throw 'HKCR keys must be relative to Classes.' + } + foreach ($entry in $policy.registry) { + $found = @($registry | Where-Object { $_[0] -eq $entry[0] -and $_[1] -eq $entry[1] -and $_[2] -eq $entry[2] -and $_[3] -eq $entry[3] }) + if ($found.Count -ne 1) { throw "Lost or changed registration: $($entry[1]) / $($entry[2])" } + } + $installDir = @($registry | Where-Object { $_[0] -eq '-1' -and $_[1] -eq "Software\$($policy.name)" -and $_[2] -eq 'InstallDir' -and $_[3] -eq '[INSTALLFOLDER]' }) + if ($installDir.Count -ne 1) { throw 'A context-sensitive installation-folder record is required.' } + Write-Output "PASS $($policy.name): $cases scope cases, folder preservation, action sequencing and registry contract. No installation performed." +} finally { + foreach ($com in @($session, $database, $installer)) { + if ($null -ne $com) { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($com) } + } +} diff --git a/packaging/windows/effectcraft.wxs b/packaging/windows/effectcraft.wxs index cc1df639b..f330ca061 100644 --- a/packaging/windows/effectcraft.wxs +++ b/packaging/windows/effectcraft.wxs @@ -1,26 +1,11 @@ - - + + - + @@ -36,32 +21,24 @@ could be put elsewhere with INSTALLFOLDER=...) the folder of the effectcraft.exe that App Paths names. A "file" search with no FileSearch inside yields that file's folder. A folder passed on the command line (INSTALLFOLDER=...) wins. --> - - + - + - - - - - - + + - + + + @@ -69,7 +46,9 @@ - + + + @@ -78,15 +57,14 @@ - + - - + + - + + @@ -95,9 +73,8 @@ - - + + diff --git a/packaging/windows/install-policy.json b/packaging/windows/install-policy.json new file mode 100644 index 000000000..6553d217b --- /dev/null +++ b/packaging/windows/install-policy.json @@ -0,0 +1,44 @@ +{ + "id": "effectcraft", + "name": "EffectCraft", + "upgradeCode": "AACA8593-C6ED-40A8-BA6C-CB325D8529F9", + "sources": [ + "effectcraft.wxs", + "installer-ui.wxs" + ], + "icon": "assets/app-icon/effectcraft.ico", + "localization": [], + "extensions": [], + "payloads": [ + "NOTICE", + "effectcraft-cli.exe", + "effectcraft.exe" + ], + "registry": [ + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\effectcraft.exe", + "", + "[#EffectcraftExe]" + ], + [ + "-1", + "Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\effectcraft.exe", + "Path", + "[INSTALLFOLDER]" + ], + [ + "-1", + "Software\\EffectCraft", + "DesktopShortcut", + "[DESKTOPSHORTCUT]" + ], + [ + "-1", + "Software\\EffectCraft", + "DesktopShortcut", + "1" + ] + ], + "mode": "dualScope" +} diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi new file mode 100644 index 000000000..9320cd084 --- /dev/null +++ b/packaging/windows/install-policy.wxi @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 16ab58d7f..876aaa519 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -1,16 +1,4 @@ - diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index edbb163c0..e49fde52c 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -100,6 +100,8 @@ Invoke-Native 'wix build' { -d "Version=$MsiVersion" -d "BinDir=$Stage" -d "IconPath=$(Join-Path $Root 'assets\app-icon\effectcraft.ico')" ` -o $Msi } +# Check the compiled scope, path and registration contract before signing. +& (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $Msi # wix writes its debug symbols (.wixpdb) next to the MSI; keep them out of the release assets. Remove-Item -Force -ErrorAction SilentlyContinue ([IO.Path]::ChangeExtension($Msi, '.wixpdb')) & (Join-Path $PSScriptRoot 'sign.ps1') $Msi diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 new file mode 100644 index 000000000..21199ca7f --- /dev/null +++ b/packaging/windows/test-install-policy.ps1 @@ -0,0 +1,54 @@ +<# +.SYNOPSIS + Build non-executable installer metadata fixtures; never install them. +#> +param( + [Parameter(Mandatory = $true)] [string] $OutputDirectory, + [ValidateSet('x64','x86','arm64')] [string[]] $Architectures = @('x64','x86','arm64') +) +$ErrorActionPreference = 'Stop' +Set-StrictMode -Version Latest +$root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path +$policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json +[void](New-Item -ItemType Directory -Force -Path $OutputDirectory) +$output = (Resolve-Path -LiteralPath $OutputDirectory).Path +Add-Type -AssemblyName System.Drawing +foreach ($arch in $Architectures) { + $stage = Join-Path $output $arch + [void](New-Item -ItemType Directory -Force -Path $stage) + foreach ($name in $policy.payloads) { + $destination = Join-Path $stage $name + [void](New-Item -ItemType Directory -Force -Path ([IO.Path]::GetDirectoryName($destination))) + Set-Content -LiteralPath $destination -Encoding Ascii -Value 'NON-EXECUTABLE MSI METADATA TEST FIXTURE' + } + foreach ($folder in @('docs','models','notices')) { + $path = Join-Path $stage $folder + [void](New-Item -ItemType Directory -Force -Path $path) + Set-Content -LiteralPath (Join-Path $path "$folder-fixture.txt") -Value 'Metadata test fixture, not a user file or model.' + } + foreach ($model in @('metadata-one.rten','metadata-two.rten')) { + Set-Content -LiteralPath (Join-Path (Join-Path $stage 'models') $model) -Encoding Ascii -Value 'NON-EXECUTABLE MODEL METADATA FIXTURE' + } + foreach ($spec in @(@('banner.bmp',493,58),@('dialog.bmp',493,312))) { + $bitmap = [Drawing.Bitmap]::new($spec[1],$spec[2],[Drawing.Imaging.PixelFormat]::Format24bppRgb) + try { $bitmap.Save((Join-Path $stage $spec[0]),[Drawing.Imaging.ImageFormat]::Bmp) } + finally { $bitmap.Dispose() } + } + Set-Content -LiteralPath (Join-Path $stage 'licence.rtf') -Encoding Ascii -Value '{\rtf1 Metadata test fixture.}' + $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) + $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" + $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) + & wix build @sources -arch $arch -wx @extensions ` + -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` + -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` + -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` + -d "AppNoticesDir=$(Join-Path $stage 'notices')" -d 'HasCraftFontLicences=1' -d 'HasCli=1' ` + -d "BannerBmp=$(Join-Path $stage 'banner.bmp')" -d "DialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "UiBannerBmp=$(Join-Path $stage 'banner.bmp')" -d "UiDialogBmp=$(Join-Path $stage 'dialog.bmp')" ` + -d "LicenseRtf=$(Join-Path $stage 'licence.rtf')" -o $msi @($policy.localization | ForEach-Object { '-loc'; Join-Path $PSScriptRoot $_ }) + if ($LASTEXITCODE -ne 0) { throw "WiX validation failed for $arch (exit $LASTEXITCODE)." } + $shell = (Get-Process -Id $PID).Path + & $shell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot 'check-install-policy.ps1') -MsiPath $msi + if ($LASTEXITCODE -ne 0) { throw "Compiled installer policy failed for $arch (exit $LASTEXITCODE)." } + Write-Output "PASS $arch metadata-only package. This is not an executable installer." +} diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 5868c77b3..f4a88eec0 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -36,7 +36,9 @@ function Invoke-Native([string] $What, [scriptblock] $Block) { } function Invoke-Msiexec([string] $What, [string[]] $Arguments) { $log = Join-Path $Work "$($What -replace '\W', '-').log" - $p = Start-Process msiexec.exe -ArgumentList ($Arguments + '/qn', '/l*v', "`"$log`"") -Wait -PassThru + # This existing VM regression deliberately covers the all-users upgrade route. + $p = Start-Process msiexec.exe -ArgumentList ($Arguments + 'ALLUSERS=1', '/qn', '/norestart', '/l*v', "`"$log`"") -PassThru + if (-not $p.WaitForExit(300000)) { $p.Kill(); throw "msiexec ($What) exceeded five minutes; see $log" } if ($p.ExitCode -ne 0) { Get-Content $log -Tail 60; throw "msiexec ($What) exited $($p.ExitCode)" } Write-Output "ok $What" } @@ -65,8 +67,9 @@ New-Item -ItemType Directory -Force -Path $Bin | Out-Null # The MSI only copies these. The app is versioned (it embeds VERSIONINFO); the real CLI has no # version resource, so its stand-in is an unversioned file too: Windows Installer keeps a changed # unversioned file on repair unless the repair mode forces it. -Copy-Item (Join-Path $env:SystemRoot 'System32\notepad.exe') (Join-Path $Bin 'effectcraft.exe') +Copy-Item (Join-Path $env:SystemRoot 'System32\cmd.exe') (Join-Path $Bin 'effectcraft.exe') [IO.File]::WriteAllText((Join-Path $Bin 'effectcraft-cli.exe'), 'effectcraft-cli stand-in') +[IO.File]::WriteAllText((Join-Path $Bin 'NOTICE'), 'Test fixture notice') # 0.0.1 stands in for releases before this installer change: the same package without the App # Paths "Path" value. @@ -76,6 +79,8 @@ $pathValue = '(?m)^.*]*Name="Path"[^>]*/>\r?\n' $text = Get-Content -Raw $Wxs if ($text -notmatch $pathValue) { throw "no App Paths Path value in $Wxs to leave out" } [IO.File]::WriteAllText($LegacyWxs, ($text -replace $pathValue, '')) +# The legacy source lives in the scratch folder, so its local include must travel with it. +Copy-Item (Join-Path $PSScriptRoot 'install-policy.wxi') (Join-Path $Work 'install-policy.wxi') $Msi = @{} foreach ($v in '0.0.1', '0.0.2', '0.0.3') { @@ -90,7 +95,7 @@ foreach ($v in '0.0.1', '0.0.2', '0.0.3') { # Spaces and a non-ASCII letter (written as a char so the script's own encoding doesn't matter). $Custom = Join-Path $env:SystemDrive "EffectCraft Test $([char]0x00DC)nicode\My Apps" -$Default = Join-Path $env:ProgramFiles 'EffectCraft' +$Default = Join-Path $env:ProgramFiles 'ArtCraft\EffectCraft' if (Test-Path -LiteralPath $AppPaths) { throw 'EffectCraft is already installed here; run this on a clean machine' } Invoke-Msiexec 'install the legacy package to a custom folder' @('/i', "`"$($Msi['0.0.1'])`"", "INSTALLFOLDER=`"$Custom`"") From b110afb1ad380575ddbe56d1a43b9ba29bfebd87 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 10:47:32 +0000 Subject: [PATCH 02/38] fix(installer): address scope and ARM64 review feedback --- .github/workflows/windows-arm64.yml | 6 +- packaging/windows/check-install-policy.ps1 | 66 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 6 +- packaging/windows/installer-ui.wxs | 21 ++++++- packaging/windows/test-install-policy.ps1 | 1 + packaging/windows/test-legacy-fixture.ps1 | 21 +++++++ packaging/windows/test-msi.ps1 | 4 +- 7 files changed, 114 insertions(+), 11 deletions(-) create mode 100644 packaging/windows/test-legacy-fixture.ps1 diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index a074e6dbe..eee6564ea 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -28,8 +28,10 @@ permissions: jobs: windows-arm64: - # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. - uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + # Immutable supporting workflow revision until the new install contract lands upstream. + uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 with: app-name: effectcraft app-dir: EffectCraft + install-relative-path: Programs/ArtCraft/EffectCraft + install-scope: perUser diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index dd2895643..00b683d70 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -131,9 +131,12 @@ try { } $ui = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallUISequence`' 2)) { $ui[$row[0]] = [int]$row[1] } - if (-not ($ui['AppSearch'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['LaunchConditions'] -and + if (-not ($ui['AppSearch'] -lt $ui['CraftPreferMachineScope'] -and + $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and + $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and + $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { - throw 'Scope must be chosen after discovery and before duplicate checks, folder restoration and costing.' + throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } @@ -144,7 +147,18 @@ try { } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) - foreach ($choice in @('user','machine','user')) { + # Model the full-UI upgrade entry with an existing machine-wide copy. Run + # the actual preference action, then the compiled Next events before discovery. + Set-SessionProperty $session 'ALLUSERS' '2' + Set-SessionProperty $session 'CRAFT_SCOPE' 'user' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' + $preference = @(Read-Rows $database 'SELECT `Condition` FROM `InstallUISequence` WHERE `Action` = ''CraftPreferMachineScope''' 1)[0] + if ($session.EvaluateCondition($preference[0]) -ne 1 -or $session.DoAction('CraftPreferMachineScope') -ne 1 -or + (Get-SessionProperty $session 'CRAFT_SCOPE') -ne 'machine') { + throw 'Full-UI machine upgrades must initially select the existing machine context.' + } + foreach ($choice in @('machine','user','machine','user')) { Set-SessionProperty $session 'CRAFT_SCOPE' $choice foreach ($event in $events) { if ($event[0] -notmatch '^\[([^\]]+)\]$') { continue } @@ -176,6 +190,52 @@ try { if ((Get-SessionProperty $session $option[1]) -ne $expected) { throw 'Saved installer choices leaked across contexts.' } } } + # Follow the compiled control conditions, not the source's intended appearance. + $controls = @(Read-Rows $database 'SELECT `Dialog_`, `Control`, `Type`, `Attributes`, `Text` FROM `Control`' 5) + $conditions = @() + if (@(Read-Rows $database 'SELECT `Name` FROM `_Tables` WHERE `Name` = ''ControlCondition''' 1).Count) { + $conditions = @(Read-Rows $database 'SELECT `Dialog_`, `Control_`, `Action`, `Condition` FROM `ControlCondition`' 4) + } + function Test-ControlVisible($Control) { + $visible = ([int]$Control[3] -band 1) -ne 0 + foreach ($condition in $conditions) { + if ($condition[0] -eq $Control[0] -and $condition[1] -eq $Control[1] -and $session.EvaluateCondition($condition[3]) -eq 1) { + if ($condition[2] -eq 'Show') { $visible = $true } + if ($condition[2] -eq 'Hide') { $visible = $false } + } + } + return $visible + } + foreach ($button in @($controls | Where-Object { $_[0] -in 'InstallWelcome','InstallMaintenance','DesktopShortcutDlg' -and ([int]$_[3] -band 8388608) -ne 0 })) { + $plain = @($controls | Where-Object { $_[0] -eq $button[0] -and $_[1] -eq ($button[1] + 'User') }) + if ($plain.Count -ne 1 -or ([int]$plain[0][3] -band 8388608)) { throw 'Scope-aware buttons need an unshielded per-user counterpart.' } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + if ((Test-ControlVisible $button) -ne ($scope -eq '1') -or (Test-ControlVisible $plain[0]) -ne ($scope -ne '1')) { + throw 'The elevation shield must follow the selected scope.' + } + } + $dialog = $button[0]; $shieldId = $button[1]; $plainId = $plain[0][1] + $shieldEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$shieldId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + $plainEvents = @(Read-Rows $database "SELECT ``Event``, ``Argument``, ``Condition``, ``Ordering`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$plainId' ORDER BY ``Ordering``" 4 | ForEach-Object { $_ -join '|' }) + if (($shieldEvents -join "`n") -ne ($plainEvents -join "`n")) { throw 'Per-user and machine buttons must perform the same operation.' } + } + foreach ($scope in @('', '2', '1')) { + Set-SessionProperty $session 'ALLUSERS' $scope + foreach ($text in @($controls | Where-Object { $_[4] -like '*for all users*' })) { + if ((Test-ControlVisible $text) -ne ($scope -eq '1')) { throw 'The welcome-page scope text must follow the selected scope.' } + } + } + if ($policy.id -in 'deckcraft','filmcraft','soundcraft') { + foreach ($route in @(@('WelcomeDlg','Next','InstallDirDlg'),@('InstallDirDlg','Back','WelcomeDlg'))) { + $dialog = $route[0]; $control = $route[1] + $routes = @(Read-Rows $database "SELECT ``Argument``, ``Condition`` FROM ``ControlEvent`` WHERE ``Dialog_`` = '$dialog' AND ``Control_`` = '$control' AND ``Event`` = 'NewDialog' ORDER BY ``Ordering``" 2) + Set-SessionProperty $session 'Installed' '' + $destination = '' + foreach ($event in $routes) { if ($session.EvaluateCondition($event[1]) -eq 1) { $destination = $event[0] } } + if ($destination -ne $route[2]) { throw 'Skipping the licence page must work in both navigation directions.' } + } + } $registry = @(Read-Rows $database 'SELECT `Root`, `Key`, `Name`, `Value` FROM `Registry`' 4) if (@($registry | Where-Object { $_[0] -in '1', '2' }).Count) { throw 'Registration must follow the selected context, not force HKCU or HKLM.' } if (@($registry | Where-Object { $_[0] -eq '0' -and $_[1] -match '^Software\\Classes(\\|$)' }).Count) { diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index 9320cd084..7ed031649 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -50,5 +50,9 @@ - + + + + + diff --git a/packaging/windows/installer-ui.wxs b/packaging/windows/installer-ui.wxs index 876aaa519..17c7efd0b 100644 --- a/packaging/windows/installer-ui.wxs +++ b/packaging/windows/installer-ui.wxs @@ -66,7 +66,10 @@ - + + + + @@ -78,7 +81,7 @@ - + @@ -86,7 +89,19 @@ - + + + + + + + + + + + diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index 21199ca7f..d0b9a73bf 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -12,6 +12,7 @@ $root = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path $policy = Get-Content -Raw -LiteralPath (Join-Path $PSScriptRoot 'install-policy.json') | ConvertFrom-Json [void](New-Item -ItemType Directory -Force -Path $OutputDirectory) $output = (Resolve-Path -LiteralPath $OutputDirectory).Path +& (Join-Path $PSScriptRoot 'test-legacy-fixture.ps1') -Work (Join-Path $output 'legacy-source') Add-Type -AssemblyName System.Drawing foreach ($arch in $Architectures) { $stage = Join-Path $output $arch diff --git a/packaging/windows/test-legacy-fixture.ps1 b/packaging/windows/test-legacy-fixture.ps1 new file mode 100644 index 000000000..44336dca9 --- /dev/null +++ b/packaging/windows/test-legacy-fixture.ps1 @@ -0,0 +1,21 @@ +<# Exercise the legacy source transformation without building or installing. #> +param([Parameter(Mandatory = $true)] [string] $Work) +$ErrorActionPreference = 'Stop' +[void](New-Item -ItemType Directory -Force -Path $Work) +$source = Get-Content -Raw (Join-Path $PSScriptRoot 'test-msi.ps1') +$start = $source.IndexOf('$Wxs =') +$end = $source.IndexOf('$Msi = @{}', $start) +if ($start -lt 0 -or $end -le $start) { throw 'Could not locate the legacy source transformation.' } +# Run the test harness's exact source-producing block, with only scratch outputs. +$block = $source.Substring($start, $end - $start).Replace('$PSScriptRoot', ("'" + $PSScriptRoot.Replace("'", "''") + "'")) +Invoke-Expression $block +[xml]$legacy = Get-Content -Raw $LegacyWxs +$ns = New-Object Xml.XmlNamespaceManager($legacy.NameTable) +$ns.AddNamespace('w', 'http://wixtoolset.org/schemas/v4/wxs') +if ($legacy.SelectNodes('//w:RegistryValue[@Name="Path" or @Name="InstallDir" or @Name="InstallFolder"]', $ns).Count) { + throw 'Legacy fixture must omit every modern folder record so discovery uses the executable fallback.' +} +if (-not $legacy.SelectNodes('//w:RegistryValue[contains(@Value,"EffectcraftExe")]', $ns).Count) { + throw 'Legacy fixture must retain its executable registration.' +} +Write-Output 'PASS legacy fixture exposes only the executable-path fallback.' diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index f4a88eec0..8f8396395 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -72,10 +72,10 @@ Copy-Item (Join-Path $env:SystemRoot 'System32\cmd.exe') (Join-Path $Bin 'effect [IO.File]::WriteAllText((Join-Path $Bin 'NOTICE'), 'Test fixture notice') # 0.0.1 stands in for releases before this installer change: the same package without the App -# Paths "Path" value. +# Paths "Path" value or modern InstallDir/InstallFolder records. $Wxs = Join-Path $PSScriptRoot 'effectcraft.wxs' $LegacyWxs = Join-Path $Work 'effectcraft-legacy.wxs' -$pathValue = '(?m)^.*]*Name="Path"[^>]*/>\r?\n' +$pathValue = '(?m)^.*]*Name="(?:Path|InstallDir|InstallFolder)"[^>]*/>\r?\n' $text = Get-Content -Raw $Wxs if ($text -notmatch $pathValue) { throw "no App Paths Path value in $Wxs to leave out" } [IO.File]::WriteAllText($LegacyWxs, ($text -replace $pathValue, '')) From 28a8f3b08cbc085d9f19b0ce7f6c76d4de1b06da Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 11:45:51 +0000 Subject: [PATCH 03/38] fix(installer): harden install-policy checks and packaging-lint workflow --- .github/workflows/packaging-lint.yml | 9 +++++++-- packaging/windows/check-install-policy.ps1 | 13 ++++++++++--- packaging/windows/test-install-policy.ps1 | 2 +- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 1f9e844db..b903f82ea 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -29,6 +29,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Install the pinned packaging toolchain shell: pwsh run: | @@ -36,8 +38,11 @@ jobs: dotnet tool install --tool-path $tools wix --version 5.0.2 if ($LASTEXITCODE -ne 0) { throw 'WiX installation failed.' } $tools >> $env:GITHUB_PATH - & (Join-Path $tools 'wix.exe') extension add -g WixToolset.UI.wixext/5.0.2 WixToolset.Util.wixext/5.0.2 - if ($LASTEXITCODE -ne 0) { throw 'WiX extension installation failed.' } + $policy = Get-Content -Raw -LiteralPath packaging/windows/install-policy.json | ConvertFrom-Json + foreach ($extension in @($policy.extensions)) { + & (Join-Path $tools 'wix.exe') extension add -g "WixToolset.$extension.wixext/5.0.2" + if ($LASTEXITCODE -ne 0) { throw "WiX extension $extension installation failed." } + } - name: Build and check non-executable installer fixtures shell: pwsh run: ./packaging/windows/test-install-policy.ps1 -OutputDirectory (Join-Path $env:RUNNER_TEMP 'craft-install-policy-tests') diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 00b683d70..451295a2e 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -122,9 +122,11 @@ try { Set-SessionProperty $session 'CRAFT_USER_DIR' 'D:\Existing user app' Set-SessionProperty $session 'CRAFT_MACHINE_DIR' 'E:\Existing machine app' $action = if ($scope -eq '1') { 'CraftRestoreMachineFolder' } else { 'CraftRestoreUserFolder' } - if (-not $explicit) { - if ($session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } - } + # Evaluate the compiled condition, so the test exercises it instead of calling the action directly. + $restore = @(Read-Rows $database ('SELECT `Condition` FROM `InstallExecuteSequence` WHERE `Action` = ''{0}''' -f $action) 1)[0] + $selected = $session.EvaluateCondition($restore[0]) -eq 1 + if ($selected -eq [bool]$explicit) { throw "$action must run only when no folder was supplied (condition: $($restore[0]))." } + if ($selected -and $session.DoAction($action) -ne 1) { throw 'Remembered-folder property action failed.' } $expected = if ($explicit) { $explicit } elseif ($scope -eq '1') { 'E:\Existing machine app' } else { 'D:\Existing user app' } if ((Get-SessionProperty $session 'INSTALLFOLDER') -ne $expected) { throw 'Custom or remembered folder was not preserved.' } } @@ -145,6 +147,11 @@ try { throw 'Silent updates must restore the folder before directory costing.' } } + foreach ($resolver in @('CraftUFromLegacyExe','CraftMFromLegacyExe')) { + if (-not ($execute['AppSearch'] -lt $execute[$resolver] -and $execute[$resolver] -lt $execute['LaunchConditions'])) { + throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run diff --git a/packaging/windows/test-install-policy.ps1 b/packaging/windows/test-install-policy.ps1 index d0b9a73bf..c28fe3950 100644 --- a/packaging/windows/test-install-policy.ps1 +++ b/packaging/windows/test-install-policy.ps1 @@ -39,7 +39,7 @@ foreach ($arch in $Architectures) { $sources = @($policy.sources | ForEach-Object { Join-Path $PSScriptRoot $_ }) $msi = Join-Path $stage "$($policy.id)-NOT-INSTALLABLE.msi" $extensions = @($policy.extensions | ForEach-Object { '-ext'; "WixToolset.$_.wixext" }) - & wix build @sources -arch $arch -wx @extensions ` + & wix build @sources -arch $arch @extensions ` -culture en-US -d 'Version=0.0.1' -d 'DisplayVersion=0.0.1-metadata-test' ` -d "BinDir=$stage" -d "IconPath=$(Join-Path $root $policy.icon)" ` -d "DocsDir=$(Join-Path $stage 'docs')" -d "ModelsDir=$(Join-Path $stage 'models')" ` From a41f75b4a7aaec6a0168f5d255c60816bde32907 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:09:02 +0000 Subject: [PATCH 04/38] docs(installer): describe the per-user default install location and scope --- README.md | 6 ++++-- docs/agents.md | 6 +++--- docs/releasing.md | 2 +- packaging/windows/test-msi.ps1 | 2 +- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 5fa28f0bb..4a3947a42 100644 --- a/README.md +++ b/README.md @@ -275,8 +275,10 @@ send us right now. | arm64 (Snapdragon and other ARM PCs) | `effectcraft--windows-arm64.msi` | `effectcraft--windows-arm64-portable.zip` | | x86 (32-bit) | `effectcraft--windows-x86.msi` | `effectcraft--windows-x86-portable.zip` | -Installers and executables are code-signed. The installer asks where to install EffectCraft -(Program Files by default), and later versions install into the same folder. To install silently +Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your +account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt; +"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft`. Later versions +install into the same folder. To install silently into a folder of your choice: `msiexec /i effectcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`. ### macOS diff --git a/docs/agents.md b/docs/agents.md index aae8be12c..3788caebe 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -54,13 +54,13 @@ The release packages ship `effectcraft-cli` alongside the desktop app, so no bui | Install | CLI | |---|---| -| Windows (MSI) | `C:\Program Files\EffectCraft\effectcraft-cli.exe` by default (wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft\effectcraft-cli.exe` by default (`C:\Program Files\ArtCraft\EffectCraft\effectcraft-cli.exe` for an all-users install, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/effectcraft-cli` | | macOS | the separate `effectcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | ```sh -# Windows, default install folder -claude mcp add effectcraft -- "C:\Program Files\EffectCraft\effectcraft-cli.exe" mcp +# Windows (PowerShell), default install folder +claude mcp add effectcraft -- "$env:LOCALAPPDATA\Programs\ArtCraft\EffectCraft\effectcraft-cli.exe" mcp # Linux, or macOS with the CLI unzipped onto PATH claude mcp add effectcraft -- effectcraft-cli mcp ``` diff --git a/docs/releasing.md b/docs/releasing.md index bd28d8ec3..07d988b5c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -71,7 +71,7 @@ The MSI's setup wizard ([`installer-ui.wxs`](../packaging/windows/installer-ui.w choose the install folder; upgrades and repairs reuse it, and `INSTALLFOLDER="..."` picks it for a silent (`/qn`) install. [`packaging-lint.yml`](../.github/workflows/packaging-lint.yml) install-tests that with stub binaries ([`test-msi.ps1`](../packaging/windows/test-msi.ps1)): a custom folder, -an upgrade that keeps it, a repair, uninstall, and the Program Files default. The wizard pages +an upgrade that keeps it, a repair, uninstall, and the all-users default folder (`Program Files\ArtCraft\EffectCraft`). The wizard pages themselves are checked by hand. The portable zip ships [`portable.txt`](../packaging/windows/portable.txt) beside `effectcraft.exe`. diff --git a/packaging/windows/test-msi.ps1 b/packaging/windows/test-msi.ps1 index 8f8396395..fbc463e4b 100644 --- a/packaging/windows/test-msi.ps1 +++ b/packaging/windows/test-msi.ps1 @@ -11,7 +11,7 @@ 3. upgrades to 0.0.3 the same way (found from the Path value 0.0.2 wrote), 4. repairs a damaged (unversioned) CLI there with the wizard's Repair mode, 5. uninstalls: files, the App Paths registration and the desktop shortcut go, - 6. installs 0.0.3 with no folder and DESKTOPSHORTCUT=0: it must land in Program Files with no + 6. installs 0.0.3 with no folder and DESKTOPSHORTCUT=0: it must land in Program Files\ArtCraft\EffectCraft (ALLUSERS=1) with no desktop shortcut, then uninstalls. The installs before step 6 take the default desktop shortcut, which upgrades keep. The wizard itself needs a person (or the snapshot steps in the PR); this covers what msiexec /qn From ed3e3d4bfe9442e2fd681f09646ce266a95b3e03 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:25:18 +0000 Subject: [PATCH 05/38] docs(installer): note that the 32-bit all-users folder is Program Files (x86); tidy wording --- README.md | 2 +- docs/agents.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4a3947a42..02b3af97e 100644 --- a/README.md +++ b/README.md @@ -277,7 +277,7 @@ send us right now. Installers and executables are code-signed. The installer asks who to install EffectCraft for and where. By default it installs for your account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an administrator prompt; -"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft`. Later versions +"Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions install into the same folder. To install silently into a folder of your choice: `msiexec /i effectcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`. diff --git a/docs/agents.md b/docs/agents.md index 3788caebe..d2b691b18 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -54,7 +54,7 @@ The release packages ship `effectcraft-cli` alongside the desktop app, so no bui | Install | CLI | |---|---| -| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft\effectcraft-cli.exe` by default (`C:\Program Files\ArtCraft\EffectCraft\effectcraft-cli.exe` for an all-users install, or wherever you installed it otherwise), not on `PATH` | +| Windows (MSI) | `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft\effectcraft-cli.exe` by default (`C:\Program Files\ArtCraft\EffectCraft\effectcraft-cli.exe` for an all-users install, under `Program Files (x86)` instead for the 32-bit build, or wherever you installed it otherwise), not on `PATH` | | Linux (deb, rpm) | `/usr/bin/effectcraft-cli` | | macOS | the separate `effectcraft-cli--macos-.zip` release asset (the `.app` holds only the desktop app) | From f7f47233e39a6757ea0806b65b64bd58059113fd Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 12:26:55 +0000 Subject: [PATCH 06/38] docs(installer): say that silent installs are per-user and when the scope page appears --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 02b3af97e..81d42360c 100644 --- a/README.md +++ b/README.md @@ -280,6 +280,8 @@ account only, into `%LOCALAPPDATA%\Programs\ArtCraft\EffectCraft`, without an ad "Everyone on this computer" installs into `C:\Program Files\ArtCraft\EffectCraft` (`Program Files (x86)` for the 32-bit build). Later versions install into the same folder. To install silently into a folder of your choice: `msiexec /i effectcraft--windows-x64.msi /qn INSTALLFOLDER="D:\Apps\EffectCraft"`. +A silent install is per-user by default. `INSTALLFOLDER` chooses the folder, not who it is installed for; +add `ALLUSERS=1`, from an elevated terminal or deployment tool, for an all-users install. ### macOS From ca36861bde4d4efcd68bd12b04add2570b332beb Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 15:32:20 +0000 Subject: [PATCH 07/38] fix(installer): resolve the wizard scope and folder correctly Real msiexec runs (per-user and all-users, silent and wizard) found two defects that the metadata fixtures cannot see: - "Only for me" published ALLUSERS=2, so FindRelatedProducts treated the run as per-machine and skipped the existing per-user copy. An interactive upgrade left two registered products and uninstall left files behind. The page now leaves ALLUSERS unset for a per-user choice. - Windows Installer resolves Program Files once, for the context it started in, so choosing "Everyone" in the wizard installed into the user profile with a machine-wide registration. The wizard now sets the new-install folder itself after the choice (Program Files, Program Files (x86) for 32-bit, or the per-user Programs folder). Silent installs are unchanged. check-install-policy.ps1 now fails if either regresses. --- packaging/windows/check-install-policy.ps1 | 24 +++++++++++++++++++++- packaging/windows/install-policy.wxi | 15 +++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index 451295a2e..a4a14bc64 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -140,6 +140,28 @@ try { $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } + # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. + # The wizard therefore sets the new-install folder itself, and only in the wizard. + $defaults = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | Where-Object { $_[0] -like 'CraftDefault*' }) + if ($defaults.Count -lt 2) { throw 'The wizard must set the new-install folder after the scope page; Program Files is resolved before the choice.' } + foreach ($row in $defaults) { + if (-not ($ui['CraftScopeDlg'] -lt [int]$row[1] -and [int]$row[1] -lt $ui['CostInitialize'])) { throw "$($row[0]) must run after the scope page and before costing." } + } + if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { + throw 'Silent installs must not use the wizard-only default folder actions.' + } + foreach ($scope in @('1', '')) { + Set-SessionProperty $session 'ALLUSERS' $scope + Set-SessionProperty $session 'INSTALLFOLDER' '' + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' '' + Set-SessionProperty $session 'CRAFT_USER_DIR' '' + foreach ($row in @($defaults | Sort-Object { [int]$_[1] })) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } + if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -177,7 +199,7 @@ try { $allUsers = Get-SessionProperty $session 'ALLUSERS' $perUser = Get-SessionProperty $session 'MSIINSTALLPERUSER' if (($choice -eq 'machine' -and ($allUsers -ne '1' -or $perUser)) -or - ($choice -eq 'user' -and ($allUsers -ne '2' -or $perUser -ne '1'))) { + ($choice -eq 'user' -and ($allUsers -or $perUser -ne '1'))) { throw 'Scope selector does not reset the requested installation context.' } } diff --git a/packaging/windows/install-policy.wxi b/packaging/windows/install-policy.wxi index 7ed031649..669c08126 100644 --- a/packaging/windows/install-policy.wxi +++ b/packaging/windows/install-policy.wxi @@ -1,6 +1,9 @@ + @@ -25,6 +28,16 @@ + + + + + + + + + @@ -42,7 +55,7 @@ - + From 44061a3b339f07d5ea74e2be6770fc0a48305d92 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 19:13:29 +0000 Subject: [PATCH 08/38] fix(installer): check the exact architecture-specific machine folder The pre-signing check matched any folder named Program Files, so it rejected a valid MSI when Windows resolves Program Files elsewhere, accepted the 64-bit root for an x86 package and accepted a different app name. Build the expected folder from the package architecture's resolved root and the policy name. --- packaging/windows/check-install-policy.ps1 | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index a4a14bc64..cc9416e9c 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -32,6 +32,12 @@ function Set-SessionProperty($Session, [string] $Name, [string] $Value) { function Get-SessionProperty($Session, [string] $Name) { $Session.GetType().InvokeMember('Property', 'GetProperty', $null, $Session, @($Name)) } +function Get-PackageTemplate($Database) { + # Summary information property 7 is the platform: "Intel;1033", "x64;1033" or "Arm64;1033". + $summary = $Database.GetType().InvokeMember('SummaryInformation', 'GetProperty', $null, $Database, @(0)) + try { [string]$summary.GetType().InvokeMember('Property', 'GetProperty', $null, $summary, @(7)) } + finally { [void][Runtime.InteropServices.Marshal]::FinalReleaseComObject($summary) } +} $installer = $null; $database = $null; $session = $null try { $installer = New-Object -ComObject WindowsInstaller.Installer @@ -150,6 +156,12 @@ try { if (@(Read-Rows $database 'SELECT `Action` FROM `InstallExecuteSequence`' 1 | Where-Object { $_[0] -like 'CraftDefault*' }).Count) { throw 'Silent installs must not use the wizard-only default folder actions.' } + # The machine default comes from the environment of the package architecture, not from a folder name. + # x86 packages use Program Files (x86) on 64-bit Windows; x64 and Arm64 packages use the 64-bit root. + $machineRoot = if ((Get-PackageTemplate $database) -match '^Intel') { + if (Get-SessionProperty $session 'VersionNT64') { ${env:ProgramFiles(x86)} } else { $env:ProgramFiles } + } elseif ($env:ProgramW6432) { $env:ProgramW6432 } else { $env:ProgramFiles } + $machineFolder = (Join-Path $machineRoot "ArtCraft\$($policy.name)") + '\' foreach ($scope in @('1', '')) { Set-SessionProperty $session 'ALLUSERS' $scope Set-SessionProperty $session 'INSTALLFOLDER' '' @@ -159,8 +171,8 @@ try { if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } } $folder = Get-SessionProperty $session 'INSTALLFOLDER' - $wanted = if ($scope -eq '1') { '^[A-Za-z]:\\Program Files( \(x86\))?\\ArtCraft\\[^\\]+\\$' } else { '^' + [regex]::Escape($env:LOCALAPPDATA) + '\\Programs\\ArtCraft\\[^\\]+\\$' } - if ($folder -notmatch $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder'." } + $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } + if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } From a63b3bc7b81ee40e3620319e63c90823f7501033 Mon Sep 17 00:00:00 2001 From: aiongg Date: Tue, 27 Oct 2026 20:51:53 +0000 Subject: [PATCH 09/38] ci: use storytold/craft-common-actions at the merged installer-policy commit --- .github/workflows/windows-arm64.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index eee6564ea..4499e3fdc 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -29,7 +29,7 @@ permissions: jobs: windows-arm64: # Immutable supporting workflow revision until the new install contract lands upstream. - uses: Jeremy8776/craft-common-actions/.github/workflows/windows-arm64.yml@f4666d5cbf35f9bdeda24f1f88c7fb5c6adf5276 + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@8bb4e5ab95443171104ad88113fe1ef9b0e5ce3f with: app-name: effectcraft app-dir: EffectCraft From d0a67ac0777ff722d0214ad69a61ed6974d9d7d5 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 27 Oct 2026 21:13:27 +0000 Subject: [PATCH 10/38] test(installer): cover the compiled folder searches, resolver chains and wizard folder replay The policy check now reads the compiled AppSearch and RegLocator rows for the remembered-folder properties (registry root, key, value name and type for both scopes) and runs the DIR, FOLDER, PATH and legacy exe actions in sequence order against seeded values, so their precedence and the preset-folder guard cannot regress. It also replays the wizard folder actions in sequence order (a remembered folder beats the new-install default, a typed folder beats both) and requires the machine restore action between the scope page and costing. --- packaging/windows/check-install-policy.ps1 | 76 +++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packaging/windows/check-install-policy.ps1 b/packaging/windows/check-install-policy.ps1 index cc9416e9c..5463c0083 100644 --- a/packaging/windows/check-install-policy.ps1 +++ b/packaging/windows/check-install-policy.ps1 @@ -143,7 +143,8 @@ try { $ui['CraftPreferMachineScope'] -lt $ui['CraftScopeDlg'] -and $ui['CraftScopeDlg'] -lt $ui['FindRelatedProducts'] -and $ui['FindRelatedProducts'] -lt $ui['LaunchConditions'] -and - $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'])) { + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreUserFolder'] -and $ui['CraftRestoreUserFolder'] -lt $ui['CostInitialize'] -and + $ui['CraftScopeDlg'] -lt $ui['CraftRestoreMachineFolder'] -and $ui['CraftRestoreMachineFolder'] -lt $ui['CostInitialize'])) { throw 'Scope must be chosen after AppSearch and before FindRelatedProducts, launch checks and costing.' } # Windows Installer resolves Program Files for the context it started in, before the wizard's scope choice. @@ -174,6 +175,28 @@ try { $wanted = if ($scope -eq '1') { $machineFolder } else { (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' } if ($folder -ine $wanted) { throw "Wizard default folder for ALLUSERS='$scope' is '$folder', expected '$wanted'." } } + # Replay the wizard's compiled folder actions in sequence order, as the full UI runs them. A remembered + # folder must win over the new-install default, and a folder the user typed must survive both. + $uiFolderActions = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallUISequence`' 3 | + Where-Object { $_[0] -match '^Craft(Restore|Default)' } | Sort-Object { [int]$_[1] }) + $userDefault = (Join-Path $env:LOCALAPPDATA "Programs\ArtCraft\$($policy.name)") + '\' + foreach ($case in @( + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'E:\Remembered machine app'), + @('', 'D:\Remembered user app', 'E:\Remembered machine app', '', 'D:\Remembered user app'), + @('1', '', '', '', $machineFolder), + @('', '', '', '', $userDefault), + @('1', 'D:\Remembered user app', 'E:\Remembered machine app', 'F:\Chosen exact app', 'F:\Chosen exact app'))) { + Set-SessionProperty $session 'Installed' '' + Set-SessionProperty $session 'ALLUSERS' $case[0] + Set-SessionProperty $session 'CRAFT_USER_DIR' $case[1] + Set-SessionProperty $session 'CRAFT_MACHINE_DIR' $case[2] + Set-SessionProperty $session 'INSTALLFOLDER' $case[3] + foreach ($row in $uiFolderActions) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $folder = Get-SessionProperty $session 'INSTALLFOLDER' + if ($folder -ine $case[4]) { throw "Full-UI folder for ALLUSERS='$($case[0])' is '$folder', expected '$($case[4])'." } + } $execute = @{} foreach ($row in (Read-Rows $database 'SELECT `Action`, `Sequence` FROM `InstallExecuteSequence`' 2)) { $execute[$row[0]] = [int]$row[1] } foreach ($action in @('CraftRestoreUserFolder','CraftRestoreMachineFolder')) { @@ -186,6 +209,57 @@ try { throw 'Both legacy folder resolvers must run after AppSearch and before the launch checks.' } } + # AppSearch itself never runs in this safe session, so check the compiled searches and then run the + # property actions that turn the search results into the remembered folder, once per scope. + $appSearch = @{} + foreach ($row in (Read-Rows $database 'SELECT `Property`, `Signature_` FROM `AppSearch`' 2)) { $appSearch[$row[0]] = $row[1] } + $locators = @{} + foreach ($row in (Read-Rows $database 'SELECT `Signature_`, `Root`, `Key`, `Name`, `Type` FROM `RegLocator`' 5)) { $locators[$row[0]] = $row } + $appPaths = '^Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\[^\\]+\.exe$' + # Suffix, how to match the key, key, value name, locator type (2 = raw value, 1 = file; the 64-bit flag is masked off). + $searchContract = @( + @('DIR', 'exact', "Software\$($policy.name)", 'InstallDir', '2'), + @('FOLDER', 'exact', "Software\$($policy.name)", 'InstallFolder', '2'), + @('PATH', 'regex', $appPaths, 'Path', '2'), + @('EXEFOLDER', 'regex', $appPaths, '', '1'), + @('EXE', 'regex', $appPaths, '', '1')) + $resolverCases = @( + @{ name = 'every record, DIR wins'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Dir\' }, + @{ name = 'FOLDER wins without DIR'; dir = ''; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Folder\' }, + @{ name = 'PATH wins without DIR, FOLDER'; dir = ''; folder = ''; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Path\' }, + @{ name = 'legacy exe folder is last'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = ''; expected = 'D:\Exe\' }, + @{ name = 'legacy folder needs the exe'; dir = ''; folder = ''; path = ''; exeFolder = 'D:\Exe\'; exe = ''; existing = ''; expected = '' }, + @{ name = 'a preset folder is kept'; dir = 'D:\Dir\'; folder = 'D:\Folder\'; path = 'D:\Path\'; exeFolder = 'D:\Exe\'; exe = 'D:\Exe\app.exe'; existing = 'P:\Preset\'; expected = 'P:\Preset\' }) + foreach ($scopeSpec in @(@('U', '1', 'CRAFT_USER_DIR'), @('M', '2', 'CRAFT_MACHINE_DIR'))) { + $letter = $scopeSpec[0]; $target = $scopeSpec[2] + foreach ($item in $searchContract) { + $property = "CRAFT_${letter}_$($item[0])" + $locator = if ($appSearch.ContainsKey($property)) { $locators[$appSearch[$property]] } else { $null } + $keyOk = $null -ne $locator -and $(if ($item[1] -eq 'exact') { $locator[2] -eq $item[2] } else { $locator[2] -match $item[2] }) + if (-not $keyOk -or $locator[1] -ne $scopeSpec[1] -or $locator[3] -ne $item[3] -or ([int]$locator[4] -band 3) -ne [int]$item[4]) { + throw "Compiled search for $property is missing or points at the wrong registry location." + } + } + $chain = @(Read-Rows $database 'SELECT `Action`, `Sequence`, `Condition` FROM `InstallExecuteSequence`' 3 | + Where-Object { $_[0] -match "^Craft${letter}From" } | Sort-Object { [int]$_[1] }) + $order = @($chain | ForEach-Object { $_[0] }) -join ',' + if ($order -ne "Craft${letter}FromDIR,Craft${letter}FromFOLDER,Craft${letter}FromPATH,Craft${letter}FromLegacyExe") { + throw "Remembered-folder resolvers for scope $letter run in the wrong order: $order" + } + foreach ($case in $resolverCases) { + Set-SessionProperty $session $target $case.existing + Set-SessionProperty $session "CRAFT_${letter}_DIR" $case.dir + Set-SessionProperty $session "CRAFT_${letter}_FOLDER" $case.folder + Set-SessionProperty $session "CRAFT_${letter}_PATH" $case.path + Set-SessionProperty $session "CRAFT_${letter}_EXEFOLDER" $case.exeFolder + Set-SessionProperty $session "CRAFT_${letter}_EXE" $case.exe + foreach ($row in $chain) { + if ($session.EvaluateCondition($row[2]) -eq 1 -and $session.DoAction($row[0]) -ne 1) { throw "$($row[0]) failed." } + } + $got = Get-SessionProperty $session $target + if ($got -ne $case.expected) { throw "Resolver chain for scope $letter, case '$($case.name)': got '$got', expected '$($case.expected)'." } + } + } # Switching the first-page choice must reset BOTH properties, including a back-and-forth change. $events = @(Read-Rows $database 'SELECT `Event`, `Argument`, `Condition`, `Ordering` FROM `ControlEvent` WHERE `Dialog_` = ''CraftScopeDlg'' AND `Control_` = ''Next'' ORDER BY `Ordering`' 4) # Model the full-UI upgrade entry with an existing machine-wide copy. Run From d562e10acf6a3da67670490549545b106ada6db2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:20 +0000 Subject: [PATCH 11/38] Refactor path for strict Clippy checks --- crates/path/src/lib.rs | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/crates/path/src/lib.rs b/crates/path/src/lib.rs index 5069cb83e..c52b3d262 100644 --- a/crates/path/src/lib.rs +++ b/crates/path/src/lib.rs @@ -144,7 +144,20 @@ pub fn ellipse(size: [f64; 2], pos: [f64; 2]) -> BezPath { } /// Polystar: a star (`inner` radius used) or polygon, rotation in degrees, roundness in percent. -pub fn polystar(star: bool, points: f64, pos: [f64; 2], rotation: f64, inner: f64, outer: f64, inner_round: f64, outer_round: f64) -> BezPath { +pub struct Polystar { + pub star: bool, + pub points: f64, + pub pos: [f64; 2], + pub rotation: f64, + pub inner: f64, + pub outer: f64, + pub inner_round: f64, + pub outer_round: f64, +} + +/// Build the path described by the polystar's geometry. +pub fn polystar(spec: Polystar) -> BezPath { + let Polystar { star, points, pos, rotation, inner, outer, inner_round, outer_round } = spec; let n = points.max(3.0).round() as usize; let verts = if star { n * 2 } else { n }; let mut path = BezPath::new(); @@ -398,7 +411,7 @@ mod tests { #[test] fn star_has_right_vertex_count() { - let p = polystar(true, 5.0, [0.0, 0.0], 0.0, 20.0, 50.0, 0.0, 0.0); + let p = polystar(Polystar { star: true, points: 5.0, pos: [0.0, 0.0], rotation: 0.0, inner: 20.0, outer: 50.0, inner_round: 0.0, outer_round: 0.0 }); let v = from_kurbo(&p); assert_eq!(v[0].vertices.len(), 10); } From e55e2883971fb5951f8932de7de5057e5aab882a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:20 +0000 Subject: [PATCH 12/38] Refactor raster for strict Clippy checks --- crates/raster/src/inpaint.rs | 53 ++++++++++++++++++++---------------- crates/raster/src/warp.rs | 43 +++++++++++++++-------------- 2 files changed, 52 insertions(+), 44 deletions(-) diff --git a/crates/raster/src/inpaint.rs b/crates/raster/src/inpaint.rs index 15bdaa0dd..c64de3375 100644 --- a/crates/raster/src/inpaint.rs +++ b/crates/raster/src/inpaint.rs @@ -607,38 +607,44 @@ pub struct FillInput { pub references: Vec<(usize, Image)>, } -/// Trace pixel (x, y) of frame `t` through `flows` (`step` = +1 forward with `fwd`, −1 backward -/// with `bwd`) to the nearest frame where it is visible: (sampled colour, frames travelled). -fn trace( - t: usize, +/// Shared frame and flow data for tracing pixels through a sequence. +struct TraceContext<'a> { + fwd: &'a [Flow], + bwd: &'a [Flow], + known: &'a (dyn Fn(usize, i64, i64) -> bool + Sync), + frames: &'a [&'a Image], + max: usize, +} + +/// Starting frame, point, and direction for a flow trace. +struct TraceStart { + frame: usize, x: f64, y: f64, step: i64, - fwd: &[Flow], - bwd: &[Flow], - known: &dyn Fn(usize, i64, i64) -> bool, - frames: &[&Image], - max: usize, -) -> Option<([f32; 4], usize)> { - let n = frames.len(); - let (w, h) = (frames[0].width as f64, frames[0].height as f64); - let (mut px, mut py) = (x, y); - let mut k = t as i64; - for d in 1..=max { - let v = if step > 0 { +} + +/// Trace a point to the nearest visible frame: (sampled colour, frames travelled). +fn trace(context: &TraceContext<'_>, start: TraceStart) -> Option<([f32; 4], usize)> { + let n = context.frames.len(); + let (w, h) = (context.frames[0].width as f64, context.frames[0].height as f64); + let (mut px, mut py) = (start.x, start.y); + let mut k = start.frame as i64; + for d in 1..=context.max { + let v = if start.step > 0 { if k as usize + 1 >= n { return None; } - fwd[k as usize].at(px, py) + context.fwd[k as usize].at(px, py) } else { if k == 0 { return None; } - bwd[k as usize - 1].at(px, py) + context.bwd[k as usize - 1].at(px, py) }; px += v[0] as f64; py += v[1] as f64; - k += step; + k += start.step; if px < 0.0 || py < 0.0 || px >= w || py >= h { return None; } @@ -646,9 +652,9 @@ fn trace( let (x0, y0) = ((px - 0.5).floor() as i64, (py - 0.5).floor() as i64); let ok = [(x0, y0), (x0 + 1, y0), (x0, y0 + 1), (x0 + 1, y0 + 1)] .iter() - .all(|&(xx, yy)| known(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1))); + .all(|&(xx, yy)| (context.known)(k as usize, xx.clamp(0, w as i64 - 1), yy.clamp(0, h as i64 - 1))); if ok { - return Some((frames[k as usize].sample_bilinear_clamped(px, py), d)); + return Some((context.frames[k as usize].sample_bilinear_clamped(px, py), d)); } } None @@ -697,6 +703,7 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u // 2. Propagation (and the lighting-correction border ring). let refs: Vec<&Image> = frames.iter().collect(); let known = |k: usize, x: i64, y: i64| !holes[k][y as usize * w + x as usize]; + let trace_context = TraceContext { fwd: &fwd, bwd: &bwd, known: &known, frames: &refs, max: n }; let ring_r = 2usize; let propagated: Vec<(Image, Vec)> = (0..n) .into_par_iter() @@ -708,8 +715,8 @@ pub fn fill_sequence(input: &FillInput, o: &FillOpts, progress: &mut dyn FnMut(u } let sample = |i: usize| -> Option<[f32; 4]> { let (x, y) = ((i % w) as f64 + 0.5, (i / w) as f64 + 0.5); - let f = trace(t, x, y, 1, &fwd, &bwd, &known, &refs, n); - let b = trace(t, x, y, -1, &fwd, &bwd, &known, &refs, n); + let f = trace(&trace_context, TraceStart { frame: t, x, y, step: 1 }); + let b = trace(&trace_context, TraceStart { frame: t, x, y, step: -1 }); match (f, b) { (Some((a, da)), Some((c, dc))) => { let (wa, wc) = (1.0 / da as f32, 1.0 / dc as f32); diff --git a/crates/raster/src/warp.rs b/crates/raster/src/warp.rs index c16480865..9f6de31ce 100644 --- a/crates/raster/src/warp.rs +++ b/crates/raster/src/warp.rs @@ -203,22 +203,17 @@ fn union(r: [usize; 4], q: [usize; 4]) -> [usize; 4] { } } -/// Fast affine rows: `blend(dst, src, x, y)` is only called for visible source pixels. Returns -/// the rectangle of the pixels it blended. -#[allow(clippy::too_many_arguments)] -fn affine_rows( - dst: &mut Image, - img: &Image, - inv: &Mat3, +/// Sampling, blending and destination bounds for an affine row operation. +struct AffineRows { sampling: Sampling, - op: f32, + opacity: f32, quantize: Option, - x0: usize, - x1: usize, - y0: usize, - y1: usize, - blend: impl Fn(Px, Px, u32, u32) -> Px + Sync, -) -> [usize; 4] { + bounds: [usize; 4], +} + +/// Blend visible source samples and return the rectangle of changed pixels. +fn affine_rows(dst: &mut Image, img: &Image, inv: &Mat3, rows: AffineRows, blend: impl Fn(Px, Px, u32, u32) -> Px + Sync) -> [usize; 4] { + let AffineRows { sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] } = rows; let levels = quantize.map(|l| (l, 1.0 / l)); let w = dst.width as usize; let m = inv.0; @@ -363,22 +358,28 @@ fn composite_warp_impl( if let Some(k) = accumulate && affine { - let r = affine_rows(dst, img, &inv, opts.sampling, 1.0, None, x0, x1, y0, y1, |d, s, _, _| { + let r = affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: 1.0, quantize: None, bounds: [x0, x1, y0, y1] }, |d, s, _, _| { [d[0] + s[0] * k, d[1] + s[1] * k, d[2] + s[2] * k, d[3] + s[3] * k] }); return changed(r); } if allow_fast && affine && !mode.is_stencil() { let r = match mode { - BlendMode::Normal => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| { - let k = 1.0 - s[3]; - [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] - }), + BlendMode::Normal => { + affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, _, _| { + let k = 1.0 - s[3]; + [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k] + }) + } BlendMode::Dissolve | BlendMode::DancingDissolve => { let seed = opts.seed; - affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, x, y| blend_pixel(mode, d, s, hash_noise(x, y, seed))) + affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, x, y| { + blend_pixel(mode, d, s, hash_noise(x, y, seed)) + }) } - _ => affine_rows(dst, img, &inv, opts.sampling, op, quantize, x0, x1, y0, y1, |d, s, _, _| blend_pixel(mode, d, s, 0.5)), + _ => affine_rows(dst, img, &inv, AffineRows { sampling: opts.sampling, opacity: op, quantize, bounds: [x0, x1, y0, y1] }, |d, s, _, _| { + blend_pixel(mode, d, s, 0.5) + }), }; return changed(r); } From 2fd6c448c51e15085a52b242f91439675768fbde Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:20 +0000 Subject: [PATCH 13/38] Refactor expr for strict Clippy checks --- crates/expr/src/host.rs | 51 ++++++++++++++++++++++------------------- crates/expr/src/lib.rs | 18 +++++++++------ 2 files changed, 38 insertions(+), 31 deletions(-) diff --git a/crates/expr/src/host.rs b/crates/expr/src/host.rs index f98b895a5..a25fbc4f0 100644 --- a/crates/expr/src/host.rs +++ b/crates/expr/src/host.rs @@ -313,6 +313,32 @@ impl<'a> Resolver<'a> { self.try_resolve(req).unwrap_or(Resp::Null) } + /// Resolve an ancestor from its UID path while preserving child indexing. + fn ancestor(&self, comp: u64, layer: u64, path: &str, up: i64) -> Option { + let l = self.layer(comp, layer)?; + // The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes. + let uid = match self.prop(comp, layer, path) { + Some((_, p)) => p.uid, + None => l.props.node(path)?.uid(), + }; + let full = l.props.path_of(uid)?; + let segs: Vec<&str> = full.split('/').collect(); + let up = usize::try_from(up).ok().filter(|u| *u >= 1 && *u <= segs.len())?; + if up == segs.len() { + return Some(Resp::Str("layer".into())); + } + let keep = segs.len() - up; + let group_path = segs.get(..keep)?.join("/"); + let parent = match keep { + 1 => &l.props, + _ => l.props.group(&segs.get(..keep - 1)?.join("/"))?, + }; + let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::().ok()?; + let index = parent.children.iter().position(|c| c.uid() == uid)?; + let n = parent.children.get(index)?; + Some(node_resp(group_path, n, index + 1)) + } + fn try_resolve(&self, req: &Req) -> Option { let project = self.ctx.project; Some(match req { @@ -373,30 +399,7 @@ impl<'a> Resolver<'a> { let path = if group.is_empty() { format!("@{}", n.uid()) } else { format!("{group}/@{}", n.uid()) }; node_resp(path, n, index) } - Req::Ancestor { comp, layer, path, up } => { - let l = self.layer(*comp, *layer)?; - // The node's full `@uid/@uid/…` path from the layer: its ancestors are its prefixes. - let uid = match self.prop(*comp, *layer, path) { - Some((_, p)) => p.uid, - None => l.props.node(path)?.uid(), - }; - let full = l.props.path_of(uid)?; - let segs: Vec<&str> = full.split('/').collect(); - let up = usize::try_from(*up).ok().filter(|u| *u >= 1 && *u <= segs.len())?; - if up == segs.len() { - return Some(Resp::Str("layer".into())); - } - let keep = segs.len() - up; - let group_path = segs.get(..keep)?.join("/"); - let parent = match keep { - 1 => &l.props, - _ => l.props.group(&segs.get(..keep - 1)?.join("/"))?, - }; - let uid = segs.get(keep - 1)?.strip_prefix('@')?.parse::().ok()?; - let index = parent.children.iter().position(|c| c.uid() == uid)?; - let n = parent.children.get(index)?; - node_resp(group_path, n, index + 1) - } + Req::Ancestor { comp, layer, path, up } => return self.ancestor(*comp, *layer, path, *up), Req::PropInfo { comp, layer, path } => { let (l, p) = self.prop(*comp, *layer, path)?; let dims = prop_dims(l, p); diff --git a/crates/expr/src/lib.rs b/crates/expr/src/lib.rs index 4963810c4..c77af5b37 100644 --- a/crates/expr/src/lib.rs +++ b/crates/expr/src/lib.rs @@ -93,7 +93,7 @@ impl ExprHost for Expressions { return Ok([v.first().copied().unwrap_or(100.0), v.get(1).copied().unwrap_or(100.0), v.get(2).copied().unwrap_or(100.0)]); }; let value = prop.value_at(layer.layer_time(ctx.time)); - let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, [index as f64 + 1.0, total as f64], selector)?; + let out = evaluate_out(self, ctx, layer, prop, &value, &e.text, TextSelector { index_total: [index as f64 + 1.0, total as f64], value: selector })?; let num = |o: &runtime::Out| match o { runtime::Out::Num(x) => Some(*x), runtime::Out::Bool(b) => Some(if *b { 100.0 } else { 0.0 }), @@ -124,13 +124,17 @@ impl Drop for FrameGuard { /// Evaluate `text` as the expression of `prop` on `layer` at `ctx.time`, given its keyframed /// `value`. Other properties' expressions are evaluated through `host`. pub fn evaluate(host: &dyn ExprHost, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value, text: &str) -> Result { - let out = evaluate_out(host, ctx, layer, prop, value, text, [1.0, 1.0], [100.0; 3])?; + let out = evaluate_out(host, ctx, layer, prop, value, text, TextSelector { index_total: [1.0, 1.0], value: [100.0; 3] })?; runtime::to_value(&out, value) } -/// Run an expression and return the raw script result. `text_sel` / `selector` are the text -/// Expression Selector's `[textIndex, textTotal]` and `selectorValue`. -#[allow(clippy::too_many_arguments)] +/// Expression Selector state: `[textIndex, textTotal]` and `selectorValue`. +struct TextSelector { + index_total: [f64; 2], + value: [f64; 3], +} + +/// Run an expression and return the raw script result. fn evaluate_out( host: &dyn ExprHost, ctx: &EvalCtx, @@ -138,9 +142,9 @@ fn evaluate_out( prop: &Property, value: &Value, text: &str, - text_sel: [f64; 2], - selector: [f64; 3], + text_selector: TextSelector, ) -> Result { + let TextSelector { index_total: text_sel, value: selector } = text_selector; let owner = Owner { comp: ctx.comp_id.0, layer: layer.id.0, uid: prop.uid, time: ctx.time, text: text.to_string() }; let (depth, cycle) = FRAMES.with(|f| { let f = f.borrow(); From 8d34f1d44979cbf30ed8b5c5673df2463d09ecf0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:20 +0000 Subject: [PATCH 14/38] Refactor media for strict Clippy checks --- crates/media/src/convert.rs | 39 +++++++++++++++++++++++++++++++++---- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index d483e4b47..9e88ab4dd 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -103,12 +103,34 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - PixelData::Yuv8 { planes, chroma, alpha } => { let yuv = Yuv::new(f.color.matrix, f.color.range, 8); let amax = 255.0; - convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op); + convert_yuv( + &mut img, + YuvSource { + size: (w, h), + chroma: *chroma, + planes: [&planes[0][..], &planes[1][..], &planes[2][..]], + alpha: alpha.as_deref().map(|a| &a[..]), + amax, + }, + &yuv, + op, + ); } PixelData::Yuv16 { planes, chroma, bits, alpha } => { let yuv = Yuv::new(f.color.matrix, f.color.range, *bits); let amax = ((1u32 << *bits) - 1) as f32; - convert_yuv(&mut img, w, h, *chroma, [&planes[0][..], &planes[1][..], &planes[2][..]], alpha.as_deref().map(|a| &a[..]), amax, &yuv, op); + convert_yuv( + &mut img, + YuvSource { + size: (w, h), + chroma: *chroma, + planes: [&planes[0][..], &planes[1][..], &planes[2][..]], + alpha: alpha.as_deref().map(|a| &a[..]), + amax, + }, + &yuv, + op, + ); } } img @@ -280,8 +302,17 @@ fn centred(i: usize, n: usize) -> (usize, usize) { (c, o) } -#[allow(clippy::too_many_arguments)] -fn convert_yuv(img: &mut Image, w: usize, h: usize, chroma: Chroma, planes: [&[T]; 3], alpha: Option<&[T]>, amax: f32, k: &Yuv, op: AlphaOp) { +/// Borrowed YUV planes and the geometry/range needed to interpret their samples. +struct YuvSource<'a, T> { + size: (usize, usize), + chroma: Chroma, + planes: [&'a [T]; 3], + alpha: Option<&'a [T]>, + amax: f32, +} + +fn convert_yuv(img: &mut Image, source: YuvSource<'_, T>, k: &Yuv, op: AlphaOp) { + let YuvSource { size: (w, h), chroma, planes, alpha, amax } = source; let (sx, sy) = chroma.shifts(); let cw = w.div_ceil(1 << sx); let ch = h.div_ceil(1 << sy); From 7c74960c6fb162af662e29e993117682d64abc71 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:20 +0000 Subject: [PATCH 15/38] Refactor text for strict Clippy checks --- crates/text/src/fonts.rs | 31 +++++++++++++++++++++++++------ crates/text/src/fonts/tests.rs | 9 +++++++++ 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/crates/text/src/fonts.rs b/crates/text/src/fonts.rs index 8475525cd..a973335f4 100644 --- a/crates/text/src/fonts.rs +++ b/crates/text/src/fonts.rs @@ -292,15 +292,37 @@ impl Face { } struct Db { + fallback: Arc, faces: Vec>, scanned: bool, } +/// Keep the compiled-in regular face available independently of discovery results. +fn bundled_default_face() -> Arc { + let info = + read_faces_bytes(INTER_REGULAR).into_iter().next().map(|n| info_from(n, FaceData::Static(INTER_REGULAR), "bundled")).unwrap_or_else(|| FaceInfo { + family: DEFAULT_FAMILY.into(), + style: "Regular".into(), + weight: 400, + italic: false, + index: 0, + data: FaceData::Static(INTER_REGULAR), + origin: "bundled", + native_family: None, + full_name: "Inter Regular".into(), + postscript: Some("Inter-Regular".into()), + variable: false, + instances: Vec::new(), + }); + Arc::new(Face { id: 0, info, bytes: OnceLock::new(), shaper: OnceLock::new() }) +} + fn db() -> &'static RwLock { static DB: OnceLock> = OnceLock::new(); DB.get_or_init(|| { - let mut db = Db { faces: Vec::new(), scanned: false }; - for b in [INTER_REGULAR, INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] { + let fallback = bundled_default_face(); + let mut db = Db { faces: vec![fallback.clone()], fallback, scanned: false }; + for b in [INTER_MEDIUM, INTER_SEMIBOLD, INTER_BOLD, INTER_ITALIC, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR] { for n in read_faces_bytes(b) { push(&mut db, info_from(n, FaceData::Static(b), "bundled")); } @@ -394,10 +416,7 @@ pub fn system_scanned() -> bool { /// A face by id (panics never: unknown ids give the default face). pub fn face(id: FaceId) -> Arc { let d = db().read().unwrap_or_else(std::sync::PoisonError::into_inner); - // The bundled fonts are compiled in and always parse (`tests::bundled_families_and_resolution` checks them), so - // the database is never empty. - #[allow(clippy::expect_used)] - d.faces.get(id).or_else(|| d.faces.first()).cloned().expect("bundled fonts present") + d.faces.get(id).cloned().unwrap_or_else(|| d.fallback.clone()) } /// All faces (bundled first). diff --git a/crates/text/src/fonts/tests.rs b/crates/text/src/fonts/tests.rs index c703bdd9a..079a001e0 100644 --- a/crates/text/src/fonts/tests.rs +++ b/crates/text/src/fonts/tests.rs @@ -469,3 +469,12 @@ fn metrics_and_coverage() { assert!(!f.has_char('\u{5d0}'), "Inter has no Hebrew"); assert!(f.has_feature(b"liga") || f.has_feature(b"calt")); } + +#[test] +fn unknown_face_id_returns_bundled_default() { + let fallback = face(usize::MAX); + assert_eq!(fallback.id, 0); + assert_eq!(fallback.info.family, DEFAULT_FAMILY); + assert!(fallback.font().is_some()); + assert!(Arc::ptr_eq(&fallback, &face(0))); +} From 87e42c150ce80948b5861634964e001281dd4e24 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 16/38] Refactor segment for strict Clippy checks --- crates/segment/src/lib.rs | 2 +- crates/segment/src/mobilesam.rs | 4 +-- crates/segment/src/nn.rs | 47 ++++++++++++++++++++++++++++++--- 3 files changed, 47 insertions(+), 6 deletions(-) diff --git a/crates/segment/src/lib.rs b/crates/segment/src/lib.rs index e04b7c336..adeb44881 100644 --- a/crates/segment/src/lib.rs +++ b/crates/segment/src/lib.rs @@ -14,8 +14,8 @@ //! so official weights load as published. //! - [`mobilesam`]: MobileSAM (Apache-2.0) and [`mediapipe`]: MediaPipe Face Landmarker //! (Apache-2.0), in plain Rust ([`nn`] kernels on rayon). -#![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::todo, clippy::unreachable)] +#![forbid(unsafe_code)] pub mod face; pub mod mediapipe; diff --git a/crates/segment/src/mobilesam.rs b/crates/segment/src/mobilesam.rs index 22d98ba7c..6599b018a 100644 --- a/crates/segment/src/mobilesam.rs +++ b/crates/segment/src/mobilesam.rs @@ -191,7 +191,7 @@ impl Block { v[t * dim + hh * hd..][..hd].copy_from_slice(&s[2 * hd..]); } } - nn::attention(&q, &k, &v, n, n, heads, hd, Some(&self.bias)) + nn::attention(&q, &k, &v, nn::AttentionShape { query_tokens: n, key_tokens: n, heads, head_dim: hd }, Some(&self.bias)) }) .collect(); // Un-window into the real grid, project, residual. @@ -325,7 +325,7 @@ impl Attn { fn forward(&self, q: &[f32], nq: usize, k: &[f32], v: &[f32], nk: usize) -> Vec { let (q, k, v) = (self.q.forward(q, nq), self.k.forward(k, nk), self.v.forward(v, nk)); let heads = 8; - let o = nn::attention(&q, &k, &v, nq, nk, heads, self.q.out / heads, None); + let o = nn::attention(&q, &k, &v, nn::AttentionShape { query_tokens: nq, key_tokens: nk, heads, head_dim: self.q.out / heads }, None); self.out.forward(&o, nq) } } diff --git a/crates/segment/src/nn.rs b/crates/segment/src/nn.rs index 4cf7a5344..713b392b8 100644 --- a/crates/segment/src/nn.rs +++ b/crates/segment/src/nn.rs @@ -334,9 +334,19 @@ pub fn softmax_rows(x: &mut [f32], n: usize) { } } -/// Multi-head scaled dot-product attention of `nq` queries over `nk` keys/values, each `heads × -/// hd` wide (token-major, heads side by side), plus an optional per-head bias `[heads × nq × nk]`. -pub fn attention(q: &[f32], k: &[f32], v: &[f32], nq: usize, nk: usize, heads: usize, hd: usize, bias: Option<&[f32]>) -> Vec { +/// Token counts and channel layout shared by the query, key and value tensors. +#[derive(Clone, Copy, Debug)] +pub struct AttentionShape { + pub query_tokens: usize, + pub key_tokens: usize, + pub heads: usize, + pub head_dim: usize, +} + +/// Multi-head scaled dot-product attention with token-major tensors, heads side by side, +/// and an optional per-head bias `[heads × query_tokens × key_tokens]`. +pub fn attention(q: &[f32], k: &[f32], v: &[f32], shape: AttentionShape, bias: Option<&[f32]>) -> Vec { + let AttentionShape { query_tokens: nq, key_tokens: nk, heads, head_dim: hd } = shape; let dim = heads * hd; let mut out = vec![0.0f32; nq * dim]; if q.len() < nq * dim || k.len() < nk * dim || v.len() < nk * dim { @@ -372,6 +382,37 @@ pub fn attention(q: &[f32], k: &[f32], v: &[f32], nq: usize, nk: usize, heads: u mod tests { use super::*; + #[test] + fn multi_head_cross_attention_matches_direct_sums() { + let shape = AttentionShape { query_tokens: 2, key_tokens: 3, heads: 2, head_dim: 2 }; + let q = [1.0, -0.5, 0.25, 2.0, -1.0, 0.75, 1.5, -0.25]; + let k = [0.5, 1.0, -0.5, 0.25, 1.5, -0.25, 0.75, 1.0, -1.0, 0.5, 2.0, -1.5]; + let v = [1.0, 2.0, 3.0, 4.0, -1.0, 0.5, 1.5, -2.0, 0.25, 0.75, -0.5, 2.5]; + let bias = [0.0, 0.5, -0.5, 1.0, 0.0, -1.0, -0.25, 0.25, 0.5, 0.0, -0.5, 1.0]; + for bias in [None, Some(bias.as_slice())] { + let actual = attention(&q, &k, &v, shape, bias); + for query in 0..shape.query_tokens { + for head in 0..shape.heads { + let mut scores: Vec = (0..shape.key_tokens) + .map(|key| { + let dot: f32 = (0..shape.head_dim).map(|channel| q[query * 4 + head * 2 + channel] * k[key * 4 + head * 2 + channel]).sum(); + dot / (shape.head_dim as f32).sqrt() + bias.map_or(0.0, |b| b[(head * 2 + query) * 3 + key]) + }) + .collect(); + let max = scores.iter().copied().fold(f32::NEG_INFINITY, f32::max); + for score in &mut scores { + *score = (*score - max).exp(); + } + let total: f32 = scores.iter().sum(); + for channel in 0..shape.head_dim { + let expected: f32 = scores.iter().enumerate().map(|(key, score)| score / total * v[key * 4 + head * 2 + channel]).sum(); + assert!((actual[query * 4 + head * 2 + channel] - expected).abs() < 1e-6); + } + } + } + } + } + #[test] fn gemm_matches_naive() { let (m, k, n) = (130, 17, 9); From 388ebc10ac5d96dfac75e95ebb226acfaf0e26b9 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 17/38] Refactor psd for strict Clippy checks --- crates/psd/src/lib.rs | 1 - crates/psd/src/limits.rs | 2 +- crates/psd/src/model.rs | 2 +- crates/psd/src/parse.rs | 3 ++- crates/psd/src/pixels.rs | 35 ++++++++++++++++++++++------------- crates/psd/src/tests/opt.rs | 2 ++ 6 files changed, 28 insertions(+), 17 deletions(-) diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index b341f84af..91792a837 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.rs @@ -15,7 +15,6 @@ pub mod placed; mod read; pub mod write; -use std::borrow::Cow; use std::sync::Arc; pub use descriptor::{DValue, Descriptor}; diff --git a/crates/psd/src/limits.rs b/crates/psd/src/limits.rs index 5d6101989..c1c23b390 100644 --- a/crates/psd/src/limits.rs +++ b/crates/psd/src/limits.rs @@ -1,4 +1,4 @@ -use super::*; +use std::borrow::Cow; #[derive(Debug, Clone, PartialEq)] pub enum Error { diff --git a/crates/psd/src/model.rs b/crates/psd/src/model.rs index ffe44d4b0..729b9a3c4 100644 --- a/crates/psd/src/model.rs +++ b/crates/psd/src/model.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Descriptor, engine_data, placed}; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ColorMode { diff --git a/crates/psd/src/parse.rs b/crates/psd/src/parse.rs index 136cc5745..d221b9044 100644 --- a/crates/psd/src/parse.rs +++ b/crates/psd/src/parse.rs @@ -2,7 +2,8 @@ use super::limits::{Error, Result}; use super::model::{ Adjustment, ChannelInfo, ColorMode, Layer, LayerMask, LinkedFile, MAX_GROUP_DEPTH, Node, Rect, Section, SmartObject, SubPath, TextInfo, VectorMask, }; -use super::*; +use super::{Psd, Reader, descriptor, engine_data, placed}; +use std::sync::Arc; /// Whether `bytes` start like a Photoshop document. pub fn is_psd(bytes: &[u8]) -> bool { diff --git a/crates/psd/src/pixels.rs b/crates/psd/src/pixels.rs index d5c0c5993..21a5c8845 100644 --- a/crates/psd/src/pixels.rs +++ b/crates/psd/src/pixels.rs @@ -1,6 +1,9 @@ +#[cfg(test)] +use super::counters; use super::limits::{Budget, DECODE_BUDGET, Error, Result, decode_pixels, finite_sample, inflate, release_bytes, too_large}; -use super::model::{ColorMode, Pixels, Rect}; -use super::*; +use super::model::{ColorMode, Layer, Pixels, Rect}; +use super::{Psd, Reader}; +use std::borrow::Cow; impl Psd { fn bytes_per_sample(&self) -> usize { @@ -29,7 +32,7 @@ impl Psd { pub(crate) fn plane_bytes<'a>(&self, comp: u16, r: &mut Reader<'a>, w: usize, h: usize, budget: &mut Budget) -> Result> { let rowb = self.row_bytes(w); let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; - Ok(match self.open_planes(comp, r, 1, 1, w, h, budget)? { + Ok(match self.open_planes(comp, r, 1, 1, (w, h), budget)? { PlaneStream::Raw => Cow::Borrowed(r.bytes(size)?), PlaneStream::Rle(counts) => Cow::Owned(Self::unpack_plane(&counts, 0, r, rowb, h, budget)?), PlaneStream::Zip(mut v) => { @@ -44,8 +47,8 @@ impl Psd { /// `w`×`h` samples, of which the first `want` are decoded: the RLE row sizes of every plane, /// or the inflated ZIP data, which holds all planes one after another (charged to `budget` /// before it is inflated). - #[allow(clippy::too_many_arguments)] - fn open_planes(&self, comp: u16, r: &mut Reader, total: usize, want: usize, w: usize, h: usize, budget: &mut Budget) -> Result { + fn open_planes(&self, comp: u16, r: &mut Reader, total: usize, want: usize, size: (usize, usize), budget: &mut Budget) -> Result { + let (w, h) = size; let size = self.row_bytes(w).checked_mul(h).ok_or_else(|| too_large(w, h))?; Ok(match comp { 0 => PlaneStream::Raw, @@ -72,17 +75,16 @@ impl Psd { /// planes are read from `r` in order, raw ones borrowed from the file data, ZIP ones from the /// inflated buffer (un-predicted in place; already charged by `open_planes`), RLE ones charged /// to `budget`. - #[allow(clippy::too_many_arguments)] fn decode_plane<'x, 'a: 'x>( &self, comp: u16, planes: &'x mut PlaneStream, r: &mut Reader<'a>, c: usize, - w: usize, - h: usize, + size: (usize, usize), budget: &mut Budget, ) -> Result> { + let (w, h) = size; let rowb = self.row_bytes(w); let size = rowb.checked_mul(h).ok_or_else(|| too_large(w, h))?; Ok(match planes { @@ -265,9 +267,9 @@ impl Psd { // Opaque black until a channel says otherwise. let mut data = budget.filled(n, [0.0f32, 0.0, 0.0, 1.0])?; let mut black = if self.mode == ColorMode::Cmyk && want > 3 { Some(budget.filled(n, 0.0f32)?) } else { None }; - let mut stream = self.open_planes(comp, &mut r, nch, want, w, h, &mut budget)?; + let mut stream = self.open_planes(comp, &mut r, nch, want, (w, h), &mut budget)?; for c in 0..want { - let raw = self.decode_plane(comp, &mut stream, &mut r, c, w, h, &mut budget)?; + let raw = self.decode_plane(comp, &mut stream, &mut r, c, (w, h), &mut budget)?; self.store_merged_channel(c, &raw, &mut data, &mut black); release_bytes(&mut budget, raw); } @@ -407,12 +409,19 @@ impl Psd { } } } + self.apply_layer_mask(l, out_rect, &mut out, &mut budget)?; + Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) + } + + /// Apply the raster mask to alpha, including its default outside the mask bounds. + fn apply_layer_mask(&self, l: &Layer, out_rect: Rect, out: &mut [[f32; 4]], budget: &mut Budget) -> Result<()> { + let (ow, oh) = (out_rect.width() as usize, out_rect.height() as usize); // Layer mask: multiplies alpha (also outside the mask rect, by its default colour). if let Some(m) = l.mask.filter(|m| !m.disabled) { let mc = l.channels.iter().find(|c| c.id == -2); let (mw, mh) = (m.rect.width() as usize, m.rect.height() as usize); let mask = match mc { - Some(c) if mw > 0 && mh > 0 => Some(self.channel_floats(c.offset, c.len, mw, mh, &mut budget)?), + Some(c) if mw > 0 && mh > 0 => Some(self.channel_floats(c.offset, c.len, mw, mh, budget)?), _ => None, }; let def = m.default_color as f32 / 255.0; @@ -426,7 +435,7 @@ impl Psd { } }; match &mask { - None => scale(&mut out, outside), + None => scale(out, outside), Some(mk) => { // The columns of the output that fall inside the mask rect (the same for every row). let lo = (i64::from(m.rect.left) - i64::from(out_rect.left)).clamp(0, ow as i64); @@ -454,7 +463,7 @@ impl Psd { } } } - Ok(Pixels { width: ow as u32, height: oh as u32, data: out }) + Ok(()) } } diff --git a/crates/psd/src/tests/opt.rs b/crates/psd/src/tests/opt.rs index 5387e42b9..908026d07 100644 --- a/crates/psd/src/tests/opt.rs +++ b/crates/psd/src/tests/opt.rs @@ -1,6 +1,8 @@ //! The optimised decode / mask / placed-bake paths against the implementations they replaced //! (kept below verbatim as references), and counters for the work they no longer do. +use std::borrow::Cow; + use crate::counters::{self, INSIDE, QUAD, SCRATCH}; use crate::placed::{Warp, square_to_quad}; use crate::write::*; From 51b910657d0acf12e0ccb825d6d5989ddefa21f0 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 18/38] Refactor pdf for strict Clippy checks --- crates/pdf/src/font.rs | 234 +++++++++++---------- crates/pdf/src/ps/builtin.rs | 396 ++++++++++++++++++++++------------- crates/pdf/src/ps/eps.rs | 2 +- crates/pdf/src/ps/exec.rs | 2 +- crates/pdf/src/ps/paths.rs | 2 +- crates/pdf/src/ps/scanner.rs | 2 +- crates/pdf/src/ps/text.rs | 2 +- crates/pdf/src/ps/value.rs | 2 +- 8 files changed, 386 insertions(+), 256 deletions(-) diff --git a/crates/pdf/src/font.rs b/crates/pdf/src/font.rs index 2c27838a2..64b0a1c3a 100644 --- a/crates/pdf/src/font.rs +++ b/crates/pdf/src/font.rs @@ -89,7 +89,7 @@ impl OutlinePen for Pen { /// A bundled stand-in for a font that is not embedded, by its PostScript name. fn fallback(base: &str) -> &'static [u8] { - use effectcraft_text::fonts::*; + use effectcraft_text::fonts::{INTER_BOLD, INTER_ITALIC, INTER_REGULAR, INTER_SEMIBOLD, JETBRAINS_MONO_REGULAR, NOTO_SERIF_REGULAR}; let b = base.to_ascii_lowercase(); if b.contains("courier") || b.contains("mono") || b.contains("consol") { return JETBRAINS_MONO_REGULAR; @@ -393,89 +393,10 @@ impl Font { } // Widths. if f.composite { - f.default_width = file.get_num(&desc_dict, "DW").unwrap_or(1000.0) / 1000.0; - if let Some(w) = file.get(&desc_dict, "W").and_then(Obj::array) { - let mut i = 0; - while i < w.len() { - let first = file.resolve(&w[i]).num().unwrap_or(0.0) as u32; - match w.get(i + 1).map(|x| file.resolve(x)) { - Some(Obj::Array(list)) => { - for (k, v) in list.iter().enumerate() { - if let Some(cid) = first.checked_add(k as u32) { - f.cid_widths.insert(cid, file.resolve(v).num().unwrap_or(0.0) / 1000.0); - } - } - i += 2; - } - Some(Obj::Num(last)) => { - let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0; - for c in first..=(*last as u32).min(first.saturating_add(65535)) { - f.cid_widths.insert(c, wv); - } - i += 3; - } - _ => break, - } - } - } - // Vertical metrics (§9.7.4.3): /DW2 [vy w1y] and /W2 entries - // `c [w1y vx vy …]` or `cfirst clast w1y vx vy`. - let dw2 = file.get(&desc_dict, "DW2").map(|x| file.nums(x)).unwrap_or_default(); - if dw2.len() == 2 { - f.dw2 = (dw2[0] / 1000.0, dw2[1] / 1000.0); - } - if let Some(w) = file.get(&desc_dict, "W2").and_then(Obj::array) { - let num = |i: usize| w.get(i).and_then(|x| file.resolve(x).num()); - let mut i = 0; - while i < w.len() { - let Some(first) = num(i) else { break }; - let first = first.max(0.0) as u32; - match w.get(i + 1).map(|x| file.resolve(x)) { - Some(Obj::Array(list)) => { - let v: Vec = list.iter().filter_map(|x| file.resolve(x).num()).collect(); - for (k, t) in v.as_chunks::<3>().0.iter().enumerate() { - if let Some(cid) = first.checked_add(k as u32) { - f.cid_vmetrics.insert(cid, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); - } - } - i += 2; - } - Some(Obj::Num(last)) => { - let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break }; - for c in first..=(last.max(0.0) as u32).min(first.saturating_add(65535)) { - f.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0)); - } - i += 5; - } - _ => break, - } - } - } + f.load_cid_widths(file, &desc_dict); + f.load_vertical_metrics(file, &desc_dict); f.has_widths = true; - // Encoding CMap. - match file.get(d, "Encoding") { - Some(Obj::Name(n)) => { - f.vertical = n.ends_with("-V"); - if !n.starts_with("Identity") { - skipped.push(format!("CMap {n}")); - } - } - Some(Obj::Stream(sd, raw)) => { - if let Some(data) = decode_stream(file, sd, raw) { - let cm = parse_cmap(&data); - f.vertical = cm.wmode || file.get_num(sd, "WMode") == Some(1.0); - f.codespace = cm.codespace; - f.cid_ranges = cm.cids; - } - } - _ => {} - } - match file.get(&desc_dict, "CIDToGIDMap") { - Some(Obj::Stream(sd, raw)) => { - f.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); - } - _ => f.cid_to_gid = None, - } + f.load_cid_encoding(file, d, &desc_dict, skipped); } else { f.first_char = file.get_num(d, "FirstChar").unwrap_or(0.0) as u32; if let Some(w) = file.get(d, "Widths") { @@ -486,16 +407,113 @@ impl Font { f.default_width = file.get_num(fd, "MissingWidth").unwrap_or(0.0); } } + f.load_program(file, d, fd.as_ref(), &subtype, skipped); + f.load_simple_encoding(file, d); + f + } + + fn load_cid_widths(&mut self, file: &File, desc_dict: &Dict) { + self.default_width = file.get_num(desc_dict, "DW").unwrap_or(1000.0) / 1000.0; + if let Some(w) = file.get(desc_dict, "W").and_then(Obj::array) { + let mut i = 0; + while i < w.len() { + let first = file.resolve(&w[i]).num().unwrap_or(0.0) as u32; + match w.get(i + 1).map(|x| file.resolve(x)) { + Some(Obj::Array(list)) => { + for (k, v) in list.iter().enumerate() { + if let Some(cid) = first.checked_add(k as u32) { + self.cid_widths.insert(cid, file.resolve(v).num().unwrap_or(0.0) / 1000.0); + } + } + i += 2; + } + Some(Obj::Num(last)) => { + let wv = w.get(i + 2).and_then(|x| file.resolve(x).num()).unwrap_or(0.0) / 1000.0; + for c in first..=(*last as u32).min(first.saturating_add(65535)) { + self.cid_widths.insert(c, wv); + } + i += 3; + } + _ => break, + } + } + } + } + + fn load_vertical_metrics(&mut self, file: &File, desc_dict: &Dict) { + // Vertical metrics (§9.7.4.3): /DW2 [vy w1y] and /W2 entries + // `c [w1y vx vy …]` or `cfirst clast w1y vx vy`. + let dw2 = file.get(desc_dict, "DW2").map(|x| file.nums(x)).unwrap_or_default(); + if dw2.len() == 2 { + self.dw2 = (dw2[0] / 1000.0, dw2[1] / 1000.0); + } + if let Some(w) = file.get(desc_dict, "W2").and_then(Obj::array) { + let num = |i: usize| w.get(i).and_then(|x| file.resolve(x).num()); + let mut i = 0; + while i < w.len() { + let Some(first) = num(i) else { break }; + let first = first.max(0.0) as u32; + match w.get(i + 1).map(|x| file.resolve(x)) { + Some(Obj::Array(list)) => { + let v: Vec = list.iter().filter_map(|x| file.resolve(x).num()).collect(); + for (k, t) in v.as_chunks::<3>().0.iter().enumerate() { + if let Some(cid) = first.checked_add(k as u32) { + self.cid_vmetrics.insert(cid, (t[0] / 1000.0, t[1] / 1000.0, t[2] / 1000.0)); + } + } + i += 2; + } + Some(Obj::Num(last)) => { + let (Some(w1), Some(vx), Some(vy)) = (num(i + 2), num(i + 3), num(i + 4)) else { break }; + for c in first..=(last.max(0.0) as u32).min(first.saturating_add(65535)) { + self.cid_vmetrics.insert(c, (w1 / 1000.0, vx / 1000.0, vy / 1000.0)); + } + i += 5; + } + _ => break, + } + } + } + } + + fn load_cid_encoding(&mut self, file: &File, d: &Dict, desc_dict: &Dict, skipped: &mut Vec) { + // Encoding CMap. + match file.get(d, "Encoding") { + Some(Obj::Name(n)) => { + self.vertical = n.ends_with("-V"); + if !n.starts_with("Identity") { + skipped.push(format!("CMap {n}")); + } + } + Some(Obj::Stream(sd, raw)) => { + if let Some(data) = decode_stream(file, sd, raw) { + let cm = parse_cmap(&data); + self.vertical = cm.wmode || file.get_num(sd, "WMode") == Some(1.0); + self.codespace = cm.codespace; + self.cid_ranges = cm.cids; + } + } + _ => {} + } + match file.get(desc_dict, "CIDToGIDMap") { + Some(Obj::Stream(sd, raw)) => { + self.cid_to_gid = decode_stream(file, sd, raw).map(|b| b.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect()); + } + _ => self.cid_to_gid = None, + } + } + + fn load_program(&mut self, file: &File, d: &Dict, fd: Option<&Dict>, subtype: &str, skipped: &mut Vec) { // The program. let font_file = |key: &str| -> Option<(Dict, Vec)> { - let fd = fd.as_ref()?; + let fd = fd?; match file.get(fd, key) { Some(Obj::Stream(sd, raw)) => Some((sd.clone(), decode_stream(file, sd, raw)?)), _ => None, } }; if subtype == "Type3" { - f.matrix = file + self.matrix = file .get(d, "FontMatrix") .map(|m| file.nums(m)) .filter(|m| m.len() == 6) @@ -511,76 +529,78 @@ impl Font { } } } - f.program = Program::Type3 { procs, resources: file.get_dict(d, "Resources").cloned() }; + self.program = Program::Type3 { procs, resources: file.get_dict(d, "Resources").cloned() }; // Type 3 widths are in glyph space. - let m = f.matrix.as_coeffs()[0]; - for w in &mut f.widths { + let m = self.matrix.as_coeffs()[0]; + for w in &mut self.widths { *w *= m * 1000.0; } } else if let Some((_, data)) = font_file("FontFile2") { - f.program = Program::TrueType(Arc::new(data)); + self.program = Program::TrueType(Arc::new(data)); } else if let Some((sd, data)) = font_file("FontFile3") { match file.get(&sd, "Subtype").and_then(Obj::name) { Some("OpenType") => { // An OpenType wrapper: TrueType outlines through skrifa, CFF through ours. let cff = FontRef::new(&data).ok().and_then(|fr| fr.table_data(skrifa::Tag::new(b"CFF ")).map(|t| t.as_bytes().to_vec())); match cff.and_then(|c| Cff::parse(&c)) { - Some(c) => f.program = Program::Cff(Box::new(c)), - None => f.program = Program::TrueType(Arc::new(data)), + Some(c) => self.program = Program::Cff(Box::new(c)), + None => self.program = Program::TrueType(Arc::new(data)), } } _ => match Cff::parse(&data) { - Some(c) => f.program = Program::Cff(Box::new(c)), + Some(c) => self.program = Program::Cff(Box::new(c)), None => skipped.push("font program (CFF)".into()), }, } } else if let Some((_, data)) = font_file("FontFile") { match Type1::parse(&data) { - Some(t) => f.program = Program::Type1(Box::new(t)), + Some(t) => self.program = Program::Type1(Box::new(t)), None => skipped.push("font program (Type 1)".into()), } } else { - f.program = Program::Fallback(fallback(&base_name)); + self.program = Program::Fallback(fallback(&self.base_name)); } - if let Program::Cff(c) = &f.program { + if let Program::Cff(c) = &self.program { let m = c.font_matrix; - f.matrix = Affine::new(m); + self.matrix = Affine::new(m); } - if let Program::Type1(t) = &f.program + if let Program::Type1(t) = &self.program && let Some(m) = t.font_matrix { - f.matrix = Affine::new(m); + self.matrix = Affine::new(m); } + } + + fn load_simple_encoding(&mut self, file: &File, d: &Dict) { // Simple-font encoding: base encoding, built-in encoding, Differences. - if !f.composite { + if !self.composite { let enc = file.get(d, "Encoding").map(|e| file.resolve(e)); let base = match enc { Some(Obj::Name(n)) => Base::from_name(n), Some(Obj::Dict(e)) => file.get(e, "BaseEncoding").and_then(Obj::name).and_then(Base::from_name), _ => None, }; - let builtin: Option>> = match &f.program { + let builtin: Option>> = match &self.program { Program::Type1(t) if !t.encoding.is_empty() => Some((0..256).map(|c| t.encoding.get(&(c as u8)).cloned()).collect()), _ => None, }; - let fallback_base = if f.symbolic { None } else { Some(Base::Standard) }; + let fallback_base = if self.symbolic { None } else { Some(Base::Standard) }; for c in 0..256usize { - f.names[c] = match (base, &builtin) { + self.names[c] = match (base, &builtin) { (Some(b), _) => b.name(c as u8), (None, Some(bi)) => bi[c].clone(), - (None, None) => match &f.program { + (None, None) => match &self.program { // TrueType symbolic fonts map codes directly; others use Standard. - Program::TrueType(_) if f.symbolic => None, - Program::Fallback(_) if base_name.contains("Symbol") || base_name.contains("Dingbats") => None, + Program::TrueType(_) if self.symbolic => None, + Program::Fallback(_) if self.base_name.contains("Symbol") || self.base_name.contains("Dingbats") => None, _ => fallback_base.or(Some(Base::Standard)).and_then(|b| b.name(c as u8)), }, }; } if let Some(Obj::Dict(e)) = enc { - differences(file, e, &mut f.names); + differences(file, e, &mut self.names); } } - f } /// Split a string into (code, byte length) pairs. diff --git a/crates/pdf/src/ps/builtin.rs b/crates/pdf/src/ps/builtin.rs index 3d998d620..dcafa9417 100644 --- a/crates/pdf/src/ps/builtin.rs +++ b/crates/pdf/src/ps/builtin.rs @@ -2,10 +2,166 @@ use super::eps::find_from; use super::scanner::Src; use super::text::encoding_array; use super::value::{Flow, V, array, matrix_of, matrix_val, new_dict}; -use super::*; +use super::{Affine, BezPath, Cap, FillRule, Interp, Join, PathEl, Point, Rc, RefCell, parse_num}; impl Interp { pub(super) fn builtin(&mut self, n: &str, src: &mut Src) -> Result<(), Flow> { + // Every handler leaves the stacks untouched when the operator is outside its family. + if self.try_stack_op(n) + || self.try_arithmetic_op(n) + || self.try_logic_op(n) + || self.try_control_op(n, src)? + || self.try_dictionary_op(n) + || self.try_collection_op(n) + || self.try_environment_op(n, src)? + || self.try_graphics_op(n)? + || self.try_coordinate_op(n) + || self.try_path_op(n)? + || self.try_paint_op(n)? + { + return Ok(()); + } + match n { + // Text (see `text_op`). + "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "currentfont" | "setcachedevice" + | "setcharwidth" | "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" | "stringwidth" | "definefont" + | "StandardEncoding" | "ISOLatin1Encoding" | "eexec" | "StartData" | "undefinefont" => self.text_op(n, src)?, + // Images: operands popped, inline data skipped. + "image" | "imagemask" | "colorimage" => { + let mut reads_file = false; + let check = |v: &V, rf: &mut bool| { + if matches!(v, V::File) || matches!(v, V::Proc(p) if p.iter().any(|x| matches!(x, V::Name(n) if &**n == "currentfile"))) { + *rf = true; + } + }; + if n == "colorimage" { + let ncomp = self.popn().max(1.0) as usize; + let multi = matches!(self.pop(), V::Bool(true)); + for _ in 0..if multi { ncomp.min(4) } else { 1 } { + let v = self.pop(); + check(&v, &mut reads_file); + } + for _ in 0..4 { + self.pop(); + } + } else { + match self.pop() { + V::Dict(d) => { + if let Some(ds) = d.borrow().get("DataSource") { + check(ds, &mut reads_file); + } + } + top => { + check(&top, &mut reads_file); + for _ in 0..4 { + self.pop(); + } + } + } + } + if reads_file { + src.skip_image_data(); + } + self.b.skip("image"); + } + _ => { + if self.unknown.len() < 32 && !self.unknown.iter().any(|u| u == n) { + self.unknown.push(n.to_string()); + } + } + } + Ok(()) + } + + fn repeat_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + let k = self.popn().clamp(0.0, 1e6) as usize; + for _ in 0..k { + self.spend(1)?; + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + + Ok(()) + } + + fn for_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + let lim = self.popn(); + let inc = self.popn(); + let mut i = self.popn(); + let mut guard = 0; + while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { + self.spend(1)?; + self.push(V::Num(i)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + i += inc; + guard += 1; + } + + Ok(()) + } + + fn loop_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + for _ in 0..1_000_000 { + self.spend(1)?; + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + + Ok(()) + } + + fn forall_proc(&mut self, src: &mut Src) -> Result<(), Flow> { + let p = self.pop(); + match self.pop() { + V::Dict(d) => { + let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + for (k, v) in items { + self.spend(1)?; + self.push(V::Lit(k.into())); + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + V::Str(s) => { + let bytes = s.borrow().clone(); + for c in bytes { + self.spend(1)?; + self.push(V::Num(c as f64)); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + a => { + for v in a.items().unwrap_or_default() { + self.spend(1)?; + self.push(v); + match self.exec_obj(p.clone(), src) { + Err(Flow::Exit) => break, + r => r?, + } + } + } + } + + Ok(()) + } + + fn try_stack_op(&mut self, n: &str) -> bool { match n { // Stack. "pop" => { @@ -21,25 +177,27 @@ impl Interp { let a = self.st.last().cloned().unwrap_or(V::Null); self.push(a); } - "copy" => match self.pop() { - V::Num(k) => { - let k = (k.max(0.0) as usize).min(self.st.len()); - let tail: Vec = self.st[self.st.len() - k..].to_vec(); - self.st.extend(tail); - } - V::Array(dst) => { - let s = self.pop().items().unwrap_or_default(); - let mut d = dst.borrow_mut(); - for (i, v) in s.into_iter().enumerate() { - if i < d.len() { - d[i] = v; - } + "copy" => { + match self.pop() { + V::Num(k) => { + let k = (k.max(0.0) as usize).min(self.st.len()); + let tail: Vec = self.st[self.st.len() - k..].to_vec(); + self.st.extend(tail); } - drop(d); - self.push(V::Array(dst)); - } - other => self.push(other), - }, + V::Array(dst) => { + let s = self.pop().items().unwrap_or_default(); + let mut d = dst.borrow_mut(); + for (i, v) in s.into_iter().enumerate() { + if i < d.len() { + d[i] = v; + } + } + drop(d); + self.push(V::Array(dst)); + } + other => self.push(other), + }; + } "index" => { let k = self.popn().max(0.0) as usize; let v = if k < self.st.len() { self.st[self.st.len() - 1 - k].clone() } else { V::Null }; @@ -84,6 +242,13 @@ impl Interp { } self.push(V::Dict(d)); } + _ => return false, + } + true + } + + fn try_arithmetic_op(&mut self, n: &str) -> bool { + match n { // Arithmetic. "add" | "sub" | "mul" | "div" | "idiv" | "mod" | "atan" | "exp" | "max" | "min" => { let b = self.popn(); @@ -131,6 +296,13 @@ impl Interp { }; self.push(V::Num(if v.is_finite() { v } else { 0.0 })); } + _ => return false, + } + true + } + + fn try_logic_op(&mut self, n: &str) -> bool { + match n { // Comparison and logic. "eq" | "ne" => { let b = self.pop(); @@ -181,6 +353,13 @@ impl Interp { "true" => self.push(V::Bool(true)), "false" => self.push(V::Bool(false)), "null" => self.push(V::Null), + _ => return false, + } + true + } + + fn try_control_op(&mut self, n: &str, src: &mut Src) -> Result { + match n { // Control. "exec" => { let v = self.pop(); @@ -199,82 +378,10 @@ impl Interp { self.exec_obj(if c { t } else { f }, src)?; } // Each pass of a loop counts against the budget. - "repeat" => { - let p = self.pop(); - let k = self.popn().clamp(0.0, 1e6) as usize; - for _ in 0..k { - self.spend(1)?; - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - "for" => { - let p = self.pop(); - let lim = self.popn(); - let inc = self.popn(); - let mut i = self.popn(); - let mut guard = 0; - while inc != 0.0 && ((inc > 0.0 && i <= lim) || (inc < 0.0 && i >= lim)) && guard < 1_000_000 { - self.spend(1)?; - self.push(V::Num(i)); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - i += inc; - guard += 1; - } - } - "loop" => { - let p = self.pop(); - for _ in 0..1_000_000 { - self.spend(1)?; - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - "forall" => { - let p = self.pop(); - match self.pop() { - V::Dict(d) => { - let items: Vec<(String, V)> = d.borrow().iter().map(|(k, v)| (k.clone(), v.clone())).collect(); - for (k, v) in items { - self.spend(1)?; - self.push(V::Lit(k.into())); - self.push(v); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - V::Str(s) => { - let bytes = s.borrow().clone(); - for c in bytes { - self.spend(1)?; - self.push(V::Num(c as f64)); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - a => { - for v in a.items().unwrap_or_default() { - self.spend(1)?; - self.push(v); - match self.exec_obj(p.clone(), src) { - Err(Flow::Exit) => break, - r => r?, - } - } - } - } - } + "repeat" => self.repeat_proc(src)?, + "for" => self.for_proc(src)?, + "loop" => self.loop_proc(src)?, + "forall" => self.forall_proc(src)?, "exit" => return Err(Flow::Exit), "stop" => return Err(Flow::Stop), "stopped" => { @@ -288,6 +395,13 @@ impl Interp { } "quit" => return Err(Flow::Quit), "showpage" | "copypage" => {} + _ => return Ok(false), + } + Ok(true) + } + + fn try_dictionary_op(&mut self, n: &str) -> bool { + match n { // Dictionaries. "def" => { let v = self.pop(); @@ -385,6 +499,13 @@ impl Interp { self.pop(); self.push(V::Bool(false)); } + _ => return false, + } + true + } + + fn try_collection_op(&mut self, n: &str) -> bool { + match n { // Arrays and strings. "array" => { let k = self.popn().clamp(0.0, 1e6) as usize; @@ -521,6 +642,13 @@ impl Interp { self.push(V::Name(t.into())); } "bind" | "readonly" | "executeonly" | "noaccess" => {} + _ => return false, + } + true + } + + fn try_environment_op(&mut self, n: &str, src: &mut Src) -> Result { + match n { "languagelevel" => self.push(V::Num(3.0)), "version" | "product" => self.push(V::Str(Rc::new(RefCell::new(b"3010".to_vec())))), "save" => { @@ -644,6 +772,13 @@ impl Interp { self.push(V::Num(0.0)); } } + _ => return Ok(false), + } + Ok(true) + } + + fn try_graphics_op(&mut self, n: &str) -> Result { + match n { // Graphics state. "gsave" => { self.spend(self.gs.path.elements().len())?; @@ -733,6 +868,13 @@ impl Interp { self.push(V::Num(c)); } } + _ => return Ok(false), + } + Ok(true) + } + + fn try_coordinate_op(&mut self, n: &str) -> bool { + match n { // Coordinates. "matrix" => self.push(matrix_val(Affine::IDENTITY)), "identmatrix" | "defaultmatrix" => { @@ -805,6 +947,13 @@ impl Interp { self.push(V::Num(p.x)); self.push(V::Num(p.y)); } + _ => return false, + } + true + } + + fn try_path_op(&mut self, n: &str) -> Result { + match n { // Paths. "newpath" => { self.gs.path = BezPath::new(); @@ -869,6 +1018,13 @@ impl Interp { let p = self.page; self.gs.path = kurbo::Shape::to_path(&kurbo::Rect::new(p[0], p[1], p[2], p[3]), 0.1); } + _ => return Ok(false), + } + Ok(true) + } + + fn try_paint_op(&mut self, n: &str) -> Result { + match n { // Painting. "fill" => self.paint(Some(FillRule::NonZero), false), "eofill" => self.paint(Some(FillRule::EvenOdd), false), @@ -903,54 +1059,8 @@ impl Interp { self.pop(); self.b.skip("smooth shading (shfill)"); } - // Text (see `text_op`). - "scalefont" | "makefont" | "selectfont" | "setfont" | "show" | "charpath" | "glyphshow" | "rootfont" | "currentfont" | "setcachedevice" - | "setcharwidth" | "ashow" | "widthshow" | "awidthshow" | "xshow" | "yshow" | "xyshow" | "kshow" | "cshow" | "stringwidth" | "definefont" - | "StandardEncoding" | "ISOLatin1Encoding" | "eexec" | "StartData" | "undefinefont" => self.text_op(n, src)?, - // Images: operands popped, inline data skipped. - "image" | "imagemask" | "colorimage" => { - let mut reads_file = false; - let check = |v: &V, rf: &mut bool| { - if matches!(v, V::File) || matches!(v, V::Proc(p) if p.iter().any(|x| matches!(x, V::Name(n) if &**n == "currentfile"))) { - *rf = true; - } - }; - if n == "colorimage" { - let ncomp = self.popn().max(1.0) as usize; - let multi = matches!(self.pop(), V::Bool(true)); - for _ in 0..if multi { ncomp.min(4) } else { 1 } { - let v = self.pop(); - check(&v, &mut reads_file); - } - for _ in 0..4 { - self.pop(); - } - } else { - match self.pop() { - V::Dict(d) => { - if let Some(ds) = d.borrow().get("DataSource") { - check(ds, &mut reads_file); - } - } - top => { - check(&top, &mut reads_file); - for _ in 0..4 { - self.pop(); - } - } - } - } - if reads_file { - src.skip_image_data(); - } - self.b.skip("image"); - } - _ => { - if self.unknown.len() < 32 && !self.unknown.iter().any(|u| u == n) { - self.unknown.push(n.to_string()); - } - } + _ => return Ok(false), } - Ok(()) + Ok(true) } } diff --git a/crates/pdf/src/ps/eps.rs b/crates/pdf/src/ps/eps.rs index 22391567c..90d60cfbe 100644 --- a/crates/pdf/src/ps/eps.rs +++ b/crates/pdf/src/ps/eps.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{HashMap, Rc, is_white}; /// The PostScript section of an EPS file (DOS EPS binary header handled) and its bounding box /// (`%%HiResBoundingBox` preferred). diff --git a/crates/pdf/src/ps/exec.rs b/crates/pdf/src/ps/exec.rs index dd7f6a579..27697527c 100644 --- a/crates/pdf/src/ps/exec.rs +++ b/crates/pdf/src/ps/exec.rs @@ -1,7 +1,7 @@ use super::eps::embedded_fonts; use super::scanner::Src; use super::value::{Flow, GState, V, new_dict}; -use super::*; +use super::{Affine, BezPath, Builder, Cap, HashMap, Interp, Join}; /// The deepest procedure nesting run (deeper recursion stops the program). const MAX_CALL_DEPTH: usize = 256; diff --git a/crates/pdf/src/ps/paths.rs b/crates/pdf/src/ps/paths.rs index 20af48b9d..b1fe07092 100644 --- a/crates/pdf/src/ps/paths.rs +++ b/crates/pdf/src/ps/paths.rs @@ -1,5 +1,5 @@ use super::value::{Flow, V}; -use super::*; +use super::{Affine, BezPath, FillRule, Interp, MAX_PATH, Paint, PathEl, Point, Stroke}; /// The angle from `a` counterclockwise to `b` (degrees, 0 to 360), worked out from what is left /// of each after whole turns. diff --git a/crates/pdf/src/ps/scanner.rs b/crates/pdf/src/ps/scanner.rs index e407fba55..59933e214 100644 --- a/crates/pdf/src/ps/scanner.rs +++ b/crates/pdf/src/ps/scanner.rs @@ -1,5 +1,5 @@ use super::value::V; -use super::*; +use super::{Rc, RefCell, ascii85, is_white, parse_num}; /// Source tokenizer (the data source for `currentfile`). pub(super) struct Src<'a> { diff --git a/crates/pdf/src/ps/text.rs b/crates/pdf/src/ps/text.rs index 06fdddb4e..b138965bb 100644 --- a/crates/pdf/src/ps/text.rs +++ b/crates/pdf/src/ps/text.rs @@ -1,6 +1,6 @@ use super::scanner::Src; use super::value::{DictRef, Flow, V, array, matrix_of, matrix_val, new_dict}; -use super::*; +use super::{Affine, BezPath, FillRule, Font, HashMap, Interp, Paint, Point, Rc, RefCell}; // ------------------------------------------------------------------ text diff --git a/crates/pdf/src/ps/value.rs b/crates/pdf/src/ps/value.rs index 31754a223..3cfafed6e 100644 --- a/crates/pdf/src/ps/value.rs +++ b/crates/pdf/src/ps/value.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Affine, BezPath, Cap, HashMap, Join, Point, Rc, RefCell}; pub(crate) type DictRef = Rc>>; From f26f30871cba4c128fa5d17f19e8ed0bd9334feb Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 19/38] Refactor track for strict Clippy checks --- crates/track/src/camtrack/bundle.rs | 44 ++++---- crates/track/src/camtrack/geometry.rs | 6 +- crates/track/src/camtrack/solver.rs | 2 +- crates/track/src/face.rs | 143 +++++++++++++++----------- crates/track/src/roto.rs | 68 ++++++------ crates/track/src/roto/matting.rs | 3 +- crates/track/src/roto/refine.rs | 5 +- crates/track/src/roto/tests.rs | 2 +- 8 files changed, 156 insertions(+), 117 deletions(-) diff --git a/crates/track/src/camtrack/bundle.rs b/crates/track/src/camtrack/bundle.rs index 077f04b71..7f8dc5bcc 100644 --- a/crates/track/src/camtrack/bundle.rs +++ b/crates/track/src/camtrack/bundle.rs @@ -12,7 +12,7 @@ use rayon::prelude::*; use super::Distortion; use super::geometry::Pose; -use super::linalg::*; +use super::linalg::{M3, V3, add, cholesky_solve, dot, inv3, mm, mtv, mv, rodrigues, scale, skew, sub}; /// A camera in the adjustment. #[derive(Clone, Copy, Debug, PartialEq)] @@ -174,6 +174,29 @@ fn cam_jac(l: &Layout, ci: usize, c: &BaCam, xc: V3, a: &[[f64; 3]; 2], p: [f64; } } +/// Smooth per-camera log focal lengths in the normal equations. +fn add_focal_prior(cams: &[BaCam], o: &BaOpts, l: &Layout, s_mat: &mut [f64], bc: &mut [f64]) { + let n = l.n; + // Focal prior. + if let FocalMode::PerCamera(k) = o.focal { + for i in 0..cams.len().saturating_sub(1) { + let r = k * (cams[i].f.ln() - cams[i + 1].f.ln()); + let (a, b) = (l.fidx[i], l.fidx[i + 1]); + let ia = (a != usize::MAX).then_some(a); + let ib = (b != usize::MAX).then_some(b); + for (idx, g) in [(ia, k), (ib, -k)] { + let Some(x) = idx else { continue }; + bc[x] -= g * r; + for (idy, h) in [(ia, k), (ib, -k)] { + if let Some(y) = idy { + s_mat[x * n + y] += g * h; + } + } + } + } + } +} + /// Run Levenberg–Marquardt. `cams` and `pts` are updated in place. pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> BaReport { let l = layout(cams.len(), o); @@ -298,24 +321,7 @@ pub fn adjust(cams: &mut [BaCam], pts: &mut [V3], obs: &[BaObs], o: &BaOpts) -> let sys = (0..npts).map(|j| build_point(j, &mut s, &mut b)).collect(); (s, b, sys) }; - // Focal prior. - if let FocalMode::PerCamera(k) = o.focal { - for i in 0..cams.len().saturating_sub(1) { - let r = k * (cams[i].f.ln() - cams[i + 1].f.ln()); - let (a, b) = (l.fidx[i], l.fidx[i + 1]); - let ia = (a != usize::MAX).then_some(a); - let ib = (b != usize::MAX).then_some(b); - for (idx, g) in [(ia, k), (ib, -k)] { - let Some(x) = idx else { continue }; - bc[x] -= g * r; - for (idy, h) in [(ia, k), (ib, -k)] { - if let Some(y) = idy { - s_mat[x * n + y] += g * h; - } - } - } - } - } + add_focal_prior(cams, o, &l, &mut s_mat, &mut bc); let base_s = s_mat.clone(); let base_bc = bc.clone(); let mean_diag = ((0..n).map(|i| base_s[i * n + i]).sum::() / n.max(1) as f64).max(1e-12); diff --git a/crates/track/src/camtrack/geometry.rs b/crates/track/src/camtrack/geometry.rs index 6956ac2b5..5d74ad9b3 100644 --- a/crates/track/src/camtrack/geometry.rs +++ b/crates/track/src/camtrack/geometry.rs @@ -5,7 +5,10 @@ //! All image points here are **normalised** (calibrated) coordinates `x = (u − c) / f`. //! A camera is `(R, C)` with `X_cam = R (X − C)`. -use super::linalg::*; +use super::linalg::{ + I3, M3, V3, add, col, cross, det3, dot, from_cols, madd, mm, mscale, mt, mtv, mv, nearest_rotation, normalize, null_vector, outer, scale, sub, svd3, + sym_eigen, +}; /// A deterministic xorshift generator for RANSAC samples. pub struct Rng(pub u64); @@ -361,6 +364,7 @@ pub fn procrustes(a: &[V3], b: &[V3]) -> Option { #[cfg(test)] mod tests { + use super::super::linalg::{log_rot, norm, rodrigues}; use super::*; fn scene() -> (Vec, Pose) { diff --git a/crates/track/src/camtrack/solver.rs b/crates/track/src/camtrack/solver.rs index a4e2c21e6..d00f7106f 100644 --- a/crates/track/src/camtrack/solver.rs +++ b/crates/track/src/camtrack/solver.rs @@ -7,7 +7,7 @@ use rayon::prelude::*; use super::bundle::{self, BaCam, BaObs, BaOpts, FocalMode}; use super::geometry::{self, Pose}; -use super::linalg::*; +use super::linalg::{V3, add, log_rot, mm, mt, mtv, mv, norm, normalize, rodrigues, scale, slerp, sub, sym_eigen}; use super::{CameraSolve, CameraTracks, Distortion, ShotType, SolveMethod, SolveSettings, SolvedFrame, SolvedPoint}; use crate::fit::{Model, ransac}; diff --git a/crates/track/src/face.rs b/crates/track/src/face.rs index 45c7c4baa..b65dc3d81 100644 --- a/crates/track/src/face.rs +++ b/crates/track/src/face.rs @@ -547,6 +547,32 @@ fn fit_from_face(face: &Face, topo: &Topology, offset: [f64; 2]) -> Option Option<(usize, usize)> { + let (w, h) = size; + let (ci, cj) = center; + for r in 0..radius { + for dj in -r..=r { + for di in -r..=r { + if di.abs() != r && dj.abs() != r { + continue; + } + let (i, j) = (ci as i64 + di, cj as i64 + dj); + if i >= 0 && j >= 0 && (i as usize) < w && (j as usize) < h && skin[j as usize * w + i as usize] { + return Some((i as usize, j as usize)); + } + } + } + } + None +} + +struct FaceGeometry { + center: [f64; 2], + height: f64, + roll: f64, +} + /// A pixel window of a frame in layer coordinates. struct Window { x0: i64, @@ -752,6 +778,59 @@ impl FaceTracker { Some(fit) } + /// Connected dark features inside the fitted face, in normalized face coordinates. + fn feature_blobs(&self, frame: &Frame, win: &Window, skin: &[bool], luma: &[f32], outline: &[[f64; 2]], geometry: FaceGeometry) -> Vec { + let FaceGeometry { center, height: b, roll } = geometry; + let (w, h) = (win.w, win.h); + // Non-skin components inside the face outline (shrunk a little: anti-aliased edges). + let inner: Vec<[f64; 2]> = outline.iter().map(|p| [center[0] + 0.9 * (p[0] - center[0]), center[1] + 0.9 * (p[1] - center[1])]).collect(); + let mut feat = vec![false; w * h]; + for j in 0..h { + for i in 0..w { + if !skin[j * w + i] && crate::mask::inside_polygon(&inner, win.layer(frame, i, j)) { + feat[j * w + i] = true; + } + } + } + let min_area = (0.0006 * b * b).max(2.0) as usize; + let mut seen = vec![false; w * h]; + let mut blobs: Vec = vec![]; + let skin_luma = self.skin.med[0] as f64; + for j0 in 0..h { + for i0 in 0..w { + if !feat[j0 * w + i0] || seen[j0 * w + i0] { + continue; + } + let mut st = vec![(i0, j0)]; + seen[j0 * w + i0] = true; + let mut pts = vec![]; + while let Some((i, j)) = st.pop() { + let q = to_face(center, b, roll, win.layer(frame, i, j)); + pts.push((q, skin_luma - luma[j * w + i] as f64)); + for dj in -1i64..=1 { + for di in -1i64..=1 { + let (a, c) = (i as i64 + di, j as i64 + dj); + if a < 0 || c < 0 || a as usize >= w || c as usize >= h { + continue; + } + let (a, c) = (a as usize, c as usize); + if feat[c * w + a] && !seen[c * w + a] { + seen[c * w + a] = true; + st.push((a, c)); + } + } + } + } + if pts.len() >= min_area { + let n = pts.len() as f64; + let centroid = [pts.iter().map(|p| p.0[0]).sum::() / n, pts.iter().map(|p| p.0[1]).sum::() / n]; + blobs.push(Blob { pts, centroid }); + } + } + } + blobs + } + fn fit(&self, frame: &Frame) -> Option { let pv = &self.prev; let b0 = pv.height; @@ -769,22 +848,7 @@ impl FaceTracker { } // Seed: the skin pixel nearest the predicted centre. let (ci, cj) = win.cell(frame, pv.center)?; - let mut seed = None; - 'search: for r in 0..(0.6 * b0).ceil() as i64 { - for dj in -r..=r { - for di in -r..=r { - if di.abs() != r && dj.abs() != r { - continue; - } - let (i, j) = (ci as i64 + di, cj as i64 + dj); - if i >= 0 && j >= 0 && (i as usize) < w && (j as usize) < h && skin[j as usize * w + i as usize] { - seed = Some((i as usize, j as usize)); - break 'search; - } - } - } - } - let (si, sj) = seed?; + let (si, sj) = nearest_skin(&skin, (w, h), (ci, cj), (0.6 * b0).ceil() as i64)?; // Flood fill (4-connected). let mut comp = vec![false; w * h]; let mut stack = vec![(si, sj)]; @@ -855,52 +919,7 @@ impl FaceTracker { roll += 180.0; } let b = major; - // Non-skin components inside the face outline (shrunk a little: anti-aliased edges). - let inner: Vec<[f64; 2]> = outline.iter().map(|p| [center[0] + 0.9 * (p[0] - center[0]), center[1] + 0.9 * (p[1] - center[1])]).collect(); - let mut feat = vec![false; w * h]; - for j in 0..h { - for i in 0..w { - if !skin[j * w + i] && crate::mask::inside_polygon(&inner, win.layer(frame, i, j)) { - feat[j * w + i] = true; - } - } - } - let min_area = (0.0006 * b * b).max(2.0) as usize; - let mut seen = vec![false; w * h]; - let mut blobs: Vec = vec![]; - let skin_luma = self.skin.med[0] as f64; - for j0 in 0..h { - for i0 in 0..w { - if !feat[j0 * w + i0] || seen[j0 * w + i0] { - continue; - } - let mut st = vec![(i0, j0)]; - seen[j0 * w + i0] = true; - let mut pts = vec![]; - while let Some((i, j)) = st.pop() { - let q = to_face(center, b, roll, win.layer(frame, i, j)); - pts.push((q, skin_luma - luma[j * w + i] as f64)); - for dj in -1i64..=1 { - for di in -1i64..=1 { - let (a, c) = (i as i64 + di, j as i64 + dj); - if a < 0 || c < 0 || a as usize >= w || c as usize >= h { - continue; - } - let (a, c) = (a as usize, c as usize); - if feat[c * w + a] && !seen[c * w + a] { - seen[c * w + a] = true; - st.push((a, c)); - } - } - } - } - if pts.len() >= min_area { - let n = pts.len() as f64; - let centroid = [pts.iter().map(|p| p.0[0]).sum::() / n, pts.iter().map(|p| p.0[1]).sum::() / n]; - blobs.push(Blob { pts, centroid }); - } - } - } + let blobs = self.feature_blobs(frame, &win, &skin, &luma, &outline, FaceGeometry { center, height: b, roll }); // Match components to features near their expected positions. let ex = &self.expect; let mean_of = |ids: &[usize]| { diff --git a/crates/track/src/roto.rs b/crates/track/src/roto.rs index 305065d2c..0948d3fe0 100644 --- a/crates/track/src/roto.rs +++ b/crates/track/src/roto.rs @@ -407,7 +407,9 @@ fn beta(img: &[[f32; 3]], free: &[bool], w: usize, h: usize) -> f64 { /// One min-cut over the `free` pixels; others keep `labels`. `prior`: P(foreground) per pixel /// and how much it weighs against the colour models. -fn cut(img: &[[f32; 3]], w: usize, h: usize, labels: &mut [u8], free: &[bool], hard: &[u8], fg: &Gmm, bg: &Gmm, prior: Option<(&[f32], f64)>) { +fn cut(img: &[[f32; 3]], size: (usize, usize), labels: &mut [u8], free: &[bool], hard: &[u8], models: (&Gmm, &Gmm), prior: Option<(&[f32], f64)>) { + let (w, h) = size; + let (fg, bg) = models; let mut idx = vec![u32::MAX; w * h]; let mut nodes = vec![]; for i in 0..w * h { @@ -622,6 +624,36 @@ pub struct Prior { pub weight: f64, } +/// Fit foreground/background colour models from strokes and the propagated matte. +fn initial_models(rgb: &[[f32; 3]], hard: &[u8], fgs: &[bool], strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, size: (usize, usize)) -> (Gmm, Gmm) { + let (w, h) = size; + // Initial colour models: the strokes (and the propagated matte). + match prior { + Some(p) => { + let fgc = samples(rgb, |i| (hard[i] == HARD_FG) || (hard[i] == 0 && p.soft[i] >= 0.5)); + let bgc = samples(rgb, |i| (hard[i] == HARD_BG) || (hard[i] == 0 && p.soft[i] < 0.5)); + let f = Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES); + let b = Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES); + (if f.is_empty() { p.fg.clone() } else { f }, if b.is_empty() { p.bg.clone() } else { b }) + } + None => { + let fgc = samples(rgb, |i| hard[i] == HARD_FG); + let mut bgc = samples(rgb, |i| hard[i] == HARD_BG); + if bgc.is_empty() { + // No background strokes: what is far from the foreground strokes. + let d = matting::distance(fgs, w, h); + let r = strokes.iter().filter(|s| s.kind == StrokeKind::Fg).map(|s| s.radius).fold(1.0, f64::max) * opts.scale; + let far = (4.0 * r).max(0.25 * w.max(h) as f64) as f32; + bgc = samples(rgb, |i| d[i] > far); + if bgc.is_empty() { + bgc = samples(rgb, |i| hard[i] != HARD_FG); + } + } + (Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES), Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES)) + } + } +} + /// Segment a frame from its strokes (and a propagated prior): GrabCut iterations on a reduced /// copy, then a full-resolution cut in a band around the boundary. pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &SegOpts, refine_radius: f64) -> FrameSeg { @@ -649,31 +681,7 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S let soft_prior: Option> = prior.map(|p| if p.weight > PRIOR { p.soft.clone() } else { matting::gauss(&p.soft, w, h, (opts.search_radius * opts.scale / 4.0).max(1.0)) }); let pw = prior.map_or(PRIOR, |p| p.weight); - // Initial colour models: the strokes (and the propagated matte). - let (fg0, bg0) = match prior { - Some(p) => { - let fgc = samples(&rgb, |i| (hard[i] == HARD_FG) || (hard[i] == 0 && p.soft[i] >= 0.5)); - let bgc = samples(&rgb, |i| (hard[i] == HARD_BG) || (hard[i] == 0 && p.soft[i] < 0.5)); - let f = Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES); - let b = Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES); - (if f.is_empty() { p.fg.clone() } else { f }, if b.is_empty() { p.bg.clone() } else { b }) - } - None => { - let fgc = samples(&rgb, |i| hard[i] == HARD_FG); - let mut bgc = samples(&rgb, |i| hard[i] == HARD_BG); - if bgc.is_empty() { - // No background strokes: what is far from the foreground strokes. - let d = matting::distance(&fgs, w, h); - let r = strokes.iter().filter(|s| s.kind == StrokeKind::Fg).map(|s| s.radius).fold(1.0, f64::max) * opts.scale; - let far = (4.0 * r).max(0.25 * w.max(h) as f64) as f32; - bgc = samples(&rgb, |i| d[i] > far); - if bgc.is_empty() { - bgc = samples(&rgb, |i| hard[i] != HARD_FG); - } - } - (Gmm::fit(&fgc, COMPONENTS, MAX_SAMPLES), Gmm::fit(&bgc, COMPONENTS, MAX_SAMPLES)) - } - }; + let (fg0, bg0) = initial_models(&rgb, &hard, &fgs, strokes, prior, opts, (w, h)); let iters = if opts.best { 4 } else { 3 }; let work = if opts.best { 960 } else { 480 }; let f = w.max(h).div_ceil(work).max(1); @@ -721,7 +729,7 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S }; let free = vec![true; cw * ch]; for it in 0..iters { - cut(&crgb, cw, ch, &mut cl, &free, &chard, &fgm, &bgm, cprior.as_deref().map(|c| (c, pw))); + cut(&crgb, (cw, ch), &mut cl, &free, &chard, (&fgm, &bgm), cprior.as_deref().map(|c| (c, pw))); if it + 1 < iters { let (a, b) = fit_models(&crgb, &cl); if !a.is_empty() && !b.is_empty() { @@ -743,11 +751,11 @@ pub fn segment(img: &Image, strokes: &[&Stroke], prior: Option<&Prior>, opts: &S let sd = matting::signed_distance(&labels, w, h); let band = f as f32 + 1.5; let free: Vec = sd.iter().map(|d| d.abs() <= band).collect(); - cut(&rgb, w, h, &mut labels, &free, &hard, &fgm, &bgm, soft_prior.as_deref().map(|c| (c, pw))); + cut(&rgb, (w, h), &mut labels, &free, &hard, (&fgm, &bgm), soft_prior.as_deref().map(|c| (c, pw))); } else { let free = vec![true; w * h]; for it in 0..iters { - cut(&rgb, w, h, &mut labels, &free, &hard, &fgm, &bgm, soft_prior.as_deref().map(|c| (c, pw))); + cut(&rgb, (w, h), &mut labels, &free, &hard, (&fgm, &bgm), soft_prior.as_deref().map(|c| (c, pw))); if it + 1 < iters { let (a, b) = fit_models(&rgb, &labels); if !a.is_empty() && !b.is_empty() { @@ -843,7 +851,7 @@ pub fn propagate(prev_img: &Image, prev: &FrameSeg, next_img: &Image, strokes: & let hard = vec![0u8; w * h]; let soft = matting::gauss(&prior.soft, w, h, (r as f64 / 4.0).max(1.0)); let (fg, bg) = if prev.fg.is_empty() || prev.bg.is_empty() { fit_models(&rgb, &labels) } else { (prev.fg.clone(), prev.bg.clone()) }; - cut(&rgb, w, h, &mut labels, &free, &hard, &fg, &bg, Some((&soft, PRIOR))); + cut(&rgb, (w, h), &mut labels, &free, &hard, (&fg, &bg), Some((&soft, PRIOR))); let seeds = core_of(&prior.soft, w, h, r as f64); clean(&mut labels, &seeds, &vec![false; w * h], w, h); finish(next_img, &rgb, labels, strokes, Some(&prior.refine), opts, refine_radius) diff --git a/crates/track/src/roto/matting.rs b/crates/track/src/roto/matting.rs index 439d9c43e..cb22b680a 100644 --- a/crates/track/src/roto/matting.rs +++ b/crates/track/src/roto/matting.rs @@ -200,7 +200,8 @@ pub fn guided_filter(img: &[[f32; 3]], p: &[f32], w: usize, h: usize, r: usize, /// (K. He, J. Sun and X. Tang, "Fast Matting Using Large Kernel Matting Laplacian Matrices", /// CVPR 2010): `(L·x)ᵢ = Σ_{k∋i} (xᵢ − a_kᵀIᵢ − b_k)` with `(a_k, b_k)` the ridge-regression /// fit of `x` to the colours in window k. -pub fn closed_form(img: &[[f32; 3]], init: &[f32], known: &[bool], w: usize, h: usize, r: usize, eps: f32, iters: usize) -> Vec { +pub fn closed_form(img: &[[f32; 3]], init: &[f32], known: &[bool], size: (usize, usize), r: usize, eps: f32, iters: usize) -> Vec { + let (w, h) = size; let n = w * h; if n == 0 || known.iter().all(|k| *k) { return init.to_vec(); diff --git a/crates/track/src/roto/refine.rs b/crates/track/src/roto/refine.rs index 2a498e286..71f9261b2 100644 --- a/crates/track/src/roto/refine.rs +++ b/crates/track/src/roto/refine.rs @@ -123,7 +123,7 @@ pub fn matte_alpha( q[j] = if band[j] { q[j].clamp(0.0, 1.0) } else { cp[j] }; } if p.higher_quality { - q = matting::closed_form(&cimg, &q, &outside, cw, ch, 1, 1e-5 * (1.0 + p.smooth.max(0.0)) as f32, 50); + q = matting::closed_form(&cimg, &q, &outside, (cw, ch), 1, 1e-5 * (1.0 + p.smooth.max(0.0)) as f32, 50); } // Refine Edge Matte adjustments. if p.smooth > 1.0 { @@ -177,7 +177,8 @@ pub fn matte_alpha( /// Decontaminate edge colours of `rgb` in place for the matte `alpha` (see /// [`matting::decontaminate`]); returns the decontamination map. `band`: the Refine Edge band /// (pixels where decontamination always applies). -pub fn decontaminate(rgb: &mut [[f32; 3]], alpha: &[f32], band: &[u8], w: usize, h: usize, p: &MatteParams, band_radius: f64, scale: f64) -> Vec { +pub fn decontaminate(rgb: &mut [[f32; 3]], alpha: &[f32], band: &[u8], size: (usize, usize), p: &MatteParams, band_radius: f64, scale: f64) -> Vec { + let (w, h) = size; if !p.decontaminate { return vec![0.0; w * h]; } diff --git a/crates/track/src/roto/tests.rs b/crates/track/src/roto/tests.rs index 3c4645cb2..92c7bec04 100644 --- a/crates/track/src/roto/tests.rs +++ b/crates/track/src/roto/tests.rs @@ -246,7 +246,7 @@ fn refine_edge_recovers_a_soft_edge_and_decontaminates() { assert!(frac as f64 > 0.6 * n as f64, "{frac}/{n} fractional"); // Decontamination: edge pixels' colour is the foreground's, not a blue blend. let mut col = rgb.clone(); - let map = decontaminate(&mut col, &a, &seg.refine, W as usize, H as usize, &p, 9.0, 1.0); + let map = decontaminate(&mut col, &a, &seg.refine, (W as usize, H as usize), &p, 9.0, 1.0); let (mut blue_before, mut blue_after, mut m) = (0.0f64, 0.0f64, 0usize); for i in 0..a.len() { if a[i] > 0.2 && a[i] < 0.8 { From d171d6cf885d59bdaa1c039fb6eb084bf35d6663 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 20/38] Refactor project for strict Clippy checks --- crates/project/src/comp.rs | 2 +- crates/project/src/footage.rs | 2 +- crates/project/src/item.rs | 2 +- crates/project/src/layer.rs | 2 +- crates/project/src/render_templates.rs | 5 ++++- crates/project/src/settings.rs | 2 +- 6 files changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/project/src/comp.rs b/crates/project/src/comp.rs index 2ff97cd37..5a3c8650e 100644 --- a/crates/project/src/comp.rs +++ b/crates/project/src/comp.rs @@ -1,7 +1,7 @@ use super::item::LayerId; use super::layer::{Layer, LayerSource}; use super::settings::{Marker, yes}; -use super::*; +use super::{Deserialize, FrameRate, Node, PropGroup, Serialize, Tick, Value, essential, styles}; /// 3D renderer of a composition. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index ff1983d88..391b1e17c 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -1,5 +1,5 @@ use super::settings::yes; -use super::*; +use super::{ColorSpace, Deserialize, FrameRate, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum FootageKind { diff --git a/crates/project/src/item.rs b/crates/project/src/item.rs index d9d5184c9..3f53bc823 100644 --- a/crates/project/src/item.rs +++ b/crates/project/src/item.rs @@ -2,7 +2,7 @@ use super::comp::Comp; use super::footage::{Footage, FootageKind, Proxy, Solid}; use super::layer::LayerSource; use super::settings::{APP_VERSION, ProjectError, ProjectSettings, SCHEMA_VERSION, SavedView}; -use super::*; +use super::{Arc, BTreeMap, Deserialize, FrameRate, Label, Project, Serialize, Tick, file_check, limits}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] pub struct ItemId(pub u64); diff --git a/crates/project/src/layer.rs b/crates/project/src/layer.rs index b7286a6d5..25e2da7c4 100644 --- a/crates/project/src/layer.rs +++ b/crates/project/src/layer.rs @@ -1,6 +1,6 @@ use super::item::{ItemId, LayerId}; use super::settings::Marker; -use super::*; +use super::{BlendMode, Deserialize, Label, PropGroup, Serialize, Tick}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum LightKind { diff --git a/crates/project/src/render_templates.rs b/crates/project/src/render_templates.rs index cf52b000e..cadfab2ad 100644 --- a/crates/project/src/render_templates.rs +++ b/crates/project/src/render_templates.rs @@ -8,7 +8,9 @@ use serde::{Deserialize, Serialize}; -use crate::render_queue::*; +use crate::render_queue::{ + AlphaMode, Channels, CurrentOrOff, FieldRender, OutputFormat, OutputModule, ProResProfile, ProxyUse, RenderQuality, RenderSettings, SwitchOverride, +}; use effectcraft_time::FrameRate; /// A default slot (the Defaults section of the Templates dialogs). @@ -288,6 +290,7 @@ impl RenderTemplates { #[cfg(test)] mod tests { use super::*; + use crate::render_queue::{ColorDepth, Crop}; #[test] fn builtins_defaults_and_round_trip() { diff --git a/crates/project/src/settings.rs b/crates/project/src/settings.rs index efd75fd14..5d3d57204 100644 --- a/crates/project/src/settings.rs +++ b/crates/project/src/settings.rs @@ -1,5 +1,5 @@ use super::item::{ItemId, LayerId}; -use super::*; +use super::{BTreeMap, ColorSpace, Deserialize, Label, Serialize, Tick}; /// The project file schema. 2: effects on layers without a source rectangle (shape, text) /// measure positions from the top-left of their comp-sized bounds (effect space), not from From 072f2619bd796d3e6a122d898d4da692bdb05a6e Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 21/38] Refactor export for strict Clippy checks --- crates/export/src/lib.rs | 19 ++++++++----------- crates/export/tests/it/render_settings.rs | 8 ++++---- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 9af195204..7ea28b571 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -270,7 +270,7 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) let path = sequence_path(cx.path, f0 + k as i64); // Encode in memory first: the storage hook decides where it fits. let mut buf = std::io::Cursor::new(Vec::new()); - write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?; + write_still(cx, &mut buf, &img, comp, StillOutput { format: fmt, channels, size: (w, h), quality: cx.output.quality })?; let data = buf.into_inner(); let path = cx.place(&path, data.len() as u64); let mut f = cx.create(&path)?; @@ -287,18 +287,15 @@ async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) Ok(Report { path: sequence_path(cx.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] }) } -#[allow(clippy::too_many_arguments)] -fn write_still( - cx: &Cx, - file: &mut W, - fmt: OutputFormat, - img: &Image, - comp: &Comp, +struct StillOutput { + format: OutputFormat, channels: Channels, - w: u32, - h: u32, + size: (u32, u32), quality: u8, -) -> Result<()> { +} + +fn write_still(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> { + let StillOutput { format: fmt, channels, size: (w, h), quality } = output; use image::{ExtendedColorType, ImageEncoder, ImageFormat}; match fmt { OutputFormat::ExrSequence => { diff --git a/crates/export/tests/it/render_settings.rs b/crates/export/tests/it/render_settings.rs index 9e9a0a18f..465d8e5fe 100644 --- a/crates/export/tests/it/render_settings.rs +++ b/crates/export/tests/it/render_settings.rs @@ -45,7 +45,7 @@ fn custom(start: f64, end: f64) -> RenderSettings { } fn run_with(p: &Project, cid: ItemId, s: &RenderSettings, om: &OutputModule, path: &str, options: JobOptions, cancel: bool) -> (Result, Files) { - run_on(p, cid, s, om, path, options, None, cancel) + run_on(p, cid, s, om, path, options, (None, cancel)) } /// [`run_with`] with an accelerator. @@ -56,9 +56,9 @@ fn run_on( om: &OutputModule, path: &str, options: JobOptions, - accel: Option<&dyn Accelerator>, - cancel: bool, + execution: (Option<&dyn Accelerator>, bool), ) -> (Result, Files) { + let (accel, cancel) = execution; let files: Files = Default::default(); let f = files.clone(); let sink: Box = Box::new(move |path: &str, data: Vec| f.lock().unwrap().push((path.to_string(), data))); @@ -456,7 +456,7 @@ fn a_job_renders_on_one_compositor() { let om = OutputModule::for_format(OutputFormat::PngSequence); let s = custom(0.0, 0.4); let render = |p: &Project, accel: &GreenAccel, path: &str| { - let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), Some(accel), false); + let (r, files) = run_on(p, cid, &s, &om, path, JobOptions::default(), (Some(accel), false)); r.expect("export"); (0..10).map(|i| png(&files, i)).collect::>() }; From 7c27d880309ebf7c1b9de5046bbd4d3985fd60c8 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 22/38] Refactor opusenc for strict Clippy checks --- crates/opusenc/src/bands.rs | 42 ++++++-------- crates/opusenc/src/energy.rs | 40 ++++--------- crates/opusenc/src/lib.rs | 58 ++++++++----------- crates/opusenc/src/rate.rs | 89 ++++++++++++++--------------- crates/opusenc/src/silk.rs | 8 ++- crates/opusenc/src/silk/analysis.rs | 2 +- crates/opusenc/src/silk/lpc.rs | 5 +- crates/opusenc/src/silk/nlsf.rs | 4 +- crates/opusenc/src/silk/pulses.rs | 2 +- 9 files changed, 113 insertions(+), 137 deletions(-) diff --git a/crates/opusenc/src/bands.rs b/crates/opusenc/src/bands.rs index 744de3fe3..cf3c590e8 100644 --- a/crates/opusenc/src/bands.rs +++ b/crates/opusenc/src/bands.rs @@ -8,7 +8,8 @@ use crate::range::{BITRES, RangeEncoder, ilog}; use crate::rate::{Mode, QTHETA_OFFSET, get_pulses}; -use crate::tables::*; +use crate::tables::EBANDS; +use crate::{CeltFrame, rate::Allocation}; pub const SPREAD_NORMAL: usize = 2; @@ -55,7 +56,7 @@ fn exp_rotation1_inv(x: &mut [f32], len: usize, stride: usize, c: f32, s: f32) { if len <= stride { return; } - if len >= 2 * stride + 1 { + if len > 2 * stride { for i in 0..=len - 2 * stride - 1 { let x1 = x[i]; let x2 = x[i + stride]; @@ -217,7 +218,7 @@ impl BandEnc<'_, '_> { let q = qn as u32; let half = q >> 1; let ft = (half + 1) * (half + 1); - let (fl, fs) = if it <= half { (it * (it + 1) >> 1, it + 1) } else { (ft - ((q + 1 - it) * (q + 2 - it) >> 1), q + 1 - it) }; + let (fl, fs) = if it <= half { ((it * (it + 1)) >> 1, it + 1) } else { (ft - (((q + 1 - it) * (q + 2 - it)) >> 1), q + 1 - it) }; self.enc.encode(fl, fl + fs, ft); itheta = ((it * 16384) / q) as i32; } @@ -294,26 +295,19 @@ impl BandEnc<'_, '_> { } } -/// Codes all band shapes (`quant_all_bands`, encoder side, long blocks). `x` holds `c` channels -/// of `n` normalised MDCT coefficients each. -#[allow(clippy::too_many_arguments)] -pub fn quant_all_bands( - m: &Mode, - start: usize, - end: usize, - x: &[f32], - n_total: usize, - c: usize, - pulses: &[i32; NB_EBANDS], - spread: usize, - dual_stereo: bool, - intensity: usize, - total_bits: i32, - mut balance: i32, - enc: &mut RangeEncoder, - lm: usize, - coded_bands: usize, -) { +/// Shape-coding controls, independent of the selected band allocation. +pub struct BandCoding { + pub spread: usize, + pub total_bits: i32, +} + +/// Codes all band shapes (`quant_all_bands`, encoder side, long blocks). `x` holds +/// `frame.channels` channels of `n_total` normalised MDCT coefficients each. +pub fn quant_all_bands(m: &Mode, frame: CeltFrame, x: &[f32], n_total: usize, allocation: &Allocation, coding: BandCoding, enc: &mut RangeEncoder) { + let CeltFrame { start, end, channels: c, lm } = frame; + let Allocation { pulses, dual_stereo, intensity, coded_bands, balance, .. } = allocation; + let (dual_stereo, intensity, coded_bands, mut balance) = (*dual_stereo, *intensity, *coded_bands, *balance); + let BandCoding { spread, total_bits } = coding; let mm = 1usize << lm; debug_assert!(c == 1 || dual_stereo || intensity <= start, "stereo frames are coded as dual stereo"); let mut ctx = BandEnc { m, enc, i: 0, spread, remaining_bits: 0, pvq_v: Vec::new(), iy: Vec::new(), tmp: Vec::new() }; @@ -362,7 +356,7 @@ mod tests { x[i + stride] = c * x2 + s * x1; x[i] = c * x1 + ms * x2; } - if len >= 2 * stride + 1 { + if len > 2 * stride { for i in (0..=len - 2 * stride - 1).rev() { let x1 = x[i]; let x2 = x[i + stride]; diff --git a/crates/opusenc/src/energy.rs b/crates/opusenc/src/energy.rs index 0057ae33a..1d4b12719 100644 --- a/crates/opusenc/src/energy.rs +++ b/crates/opusenc/src/energy.rs @@ -4,9 +4,10 @@ //! The quantised energies are updated with exactly the decoder's arithmetic so the prediction //! state never drifts between encoder and decoder. +use crate::CeltFrame; use crate::range::RangeEncoder; -use crate::rate::MAX_FINE_BITS; -use crate::tables::*; +use crate::rate::{Allocation, MAX_FINE_BITS}; +use crate::tables::{BETA_COEF, BETA_INTRA, E_PROB_MODEL, NB_EBANDS, PRED_COEF, SMALL_ENERGY_ICDF}; const LAPLACE_MINP: u32 = 1; const LAPLACE_NMIN: u32 = 16; @@ -56,18 +57,8 @@ pub fn laplace_encode(enc: &mut RangeEncoder, value: i32, mut fs: u32, decay: u3 /// `quant_coarse_energy` for one prediction mode. `e` holds the target log2 energies (relative /// to `E_MEANS`), `old_e` the quantised energies, updated in place. `budget` is the frame size in /// bits. -#[allow(clippy::too_many_arguments)] -pub fn quant_coarse_energy( - start: usize, - end: usize, - e: &[f32; 2 * NB_EBANDS], - old_e: &mut [f32; 2 * NB_EBANDS], - intra: bool, - enc: &mut RangeEncoder, - c: usize, - lm: usize, - budget: i32, -) { +pub fn quant_coarse_energy(frame: CeltFrame, e: &[f32; 2 * NB_EBANDS], old_e: &mut [f32; 2 * NB_EBANDS], intra: bool, enc: &mut RangeEncoder, budget: i32) { + let CeltFrame { start, end, channels: c, lm } = frame; let prob = &E_PROB_MODEL[lm][intra as usize]; let (coef, beta) = if intra { (0.0, BETA_INTRA) } else { (PRED_COEF[lm], BETA_COEF[lm]) }; let mut prev = [0f32; 2]; @@ -111,15 +102,8 @@ pub fn quant_coarse_energy( } /// `quant_fine_energy`: raw bits refining each band's quantised energy. -pub fn quant_fine_energy( - start: usize, - end: usize, - e: &[f32; 2 * NB_EBANDS], - old_e: &mut [f32; 2 * NB_EBANDS], - fine_quant: &[i32; NB_EBANDS], - enc: &mut RangeEncoder, - c: usize, -) { +pub fn quant_fine_energy(frame: CeltFrame, e: &[f32; 2 * NB_EBANDS], old_e: &mut [f32; 2 * NB_EBANDS], fine_quant: &[i32; NB_EBANDS], enc: &mut RangeEncoder) { + let CeltFrame { start, end, channels: c, .. } = frame; for i in start..end { let fq = fine_quant[i]; if fq <= 0 { @@ -139,18 +123,16 @@ pub fn quant_fine_energy( /// `quant_energy_finalise`: spends the bits left at the end of the frame on one more bit of /// energy resolution per band, in priority order. -#[allow(clippy::too_many_arguments)] pub fn quant_energy_finalise( - start: usize, - end: usize, + frame: CeltFrame, e: &[f32; 2 * NB_EBANDS], old_e: &mut [f32; 2 * NB_EBANDS], - fine_quant: &[i32; NB_EBANDS], - fine_priority: &[i32; NB_EBANDS], + allocation: &Allocation, mut bits_left: i32, enc: &mut RangeEncoder, - c: usize, ) { + let CeltFrame { start, end, channels: c, .. } = frame; + let Allocation { fine_quant, fine_priority, .. } = allocation; for prio in 0..2 { let mut i = start; while i < end && bits_left >= c as i32 { diff --git a/crates/opusenc/src/lib.rs b/crates/opusenc/src/lib.rs index 9cc1f6a51..b4346ebd6 100644 --- a/crates/opusenc/src/lib.rs +++ b/crates/opusenc/src/lib.rs @@ -24,8 +24,6 @@ //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -// The bit-allocation code mirrors the normative integer arithmetic; keep C-like shift expressions. -#![allow(clippy::precedence, clippy::int_plus_one)] mod bands; mod energy; @@ -38,14 +36,23 @@ mod tables; use std::sync::OnceLock; -use bands::{SPREAD_NORMAL, quant_all_bands}; +use bands::{BandCoding, SPREAD_NORMAL, quant_all_bands}; use energy::{quant_coarse_energy, quant_energy_finalise, quant_fine_energy}; use mdct::Mdct; use range::{BITRES, RangeEncoder}; -use rate::{Mode as CeltMode, compute_allocation}; +use rate::{AllocationInput, Mode as CeltMode, compute_allocation}; use resample::Downsampler; use silk::SilkEncoder; -use tables::*; +use tables::{E_MEANS, EBANDS, NB_EBANDS, OVERLAP, PREEMPH, SPREAD_ICDF, TF_SELECT_TABLE, TRIM_ICDF}; + +/// Band range, channel count and transform size shared by CELT coding stages. +#[derive(Clone, Copy)] +struct CeltFrame { + start: usize, + end: usize, + channels: usize, + lm: usize, +} /// Samples per channel in one 20 ms frame at 48 kHz. const N: usize = 960; @@ -314,7 +321,7 @@ impl OpusEncoder { Mode::Celt => { let (log_e, silence) = self.celt_analysis(pcm); let len = self.frame_bytes; - packet.extend_from_slice(&self.encode_celt(RangeEncoder::new(), len, 0, NB_EBANDS, &log_e, silence)); + packet.extend_from_slice(&self.encode_celt(RangeEncoder::new(), len, 0..NB_EBANDS, &log_e, silence)); } Mode::Silk => { let x = self.silk_input(pcm); @@ -344,7 +351,7 @@ impl OpusEncoder { } let (log_e, _) = self.celt_analysis(pcm); let end = if self.bandwidth == Bandwidth::SuperWideband { 19 } else { NB_EBANDS }; - packet.extend_from_slice(&self.encode_celt(enc, len, HYBRID_START_BAND, end, &log_e, false)); + packet.extend_from_slice(&self.encode_celt(enc, len, HYBRID_START_BAND..end, &log_e, false)); } } packet @@ -406,10 +413,11 @@ impl OpusEncoder { /// Codes one CELT frame (RFC 6716 §4.3, in bitstream order) of `len` bytes into `enc` /// (which already holds the SILK frame in hybrid mode), coding bands `start..end`, and /// finalises the frame. - #[allow(clippy::too_many_arguments)] - fn encode_celt(&mut self, mut enc: RangeEncoder, len: usize, start: usize, end: usize, log_e: &[f32; 2 * NB_EBANDS], silence: bool) -> Vec { + fn encode_celt(&mut self, mut enc: RangeEncoder, len: usize, bands: std::ops::Range, log_e: &[f32; 2 * NB_EBANDS], silence: bool) -> Vec { let m = celt_mode(); let c = self.channels; + let frame = CeltFrame { start: bands.start, end: bands.end, channels: c, lm: LM }; + let CeltFrame { start, end, .. } = frame; if c == 1 { for i in 0..NB_EBANDS { self.old_band_e[i] = self.old_band_e[i].max(self.old_band_e[NB_EBANDS + i]); @@ -438,10 +446,10 @@ impl OpusEncoder { let mut enc_intra = enc.clone(); let mut old_intra = self.old_band_e; enc_intra.bit_logp(true, 3); - quant_coarse_energy(start, end, log_e, &mut old_intra, true, &mut enc_intra, c, LM, total_bits_raw); + quant_coarse_energy(frame, log_e, &mut old_intra, true, &mut enc_intra, total_bits_raw); enc.bit_logp(false, 3); let mut old_inter = self.old_band_e; - quant_coarse_energy(start, end, log_e, &mut old_inter, false, &mut enc, c, LM, total_bits_raw); + quant_coarse_energy(frame, log_e, &mut old_inter, false, &mut enc, total_bits_raw); if enc_intra.tell_frac() < enc.tell_frac() { enc = enc_intra; self.old_band_e = old_intra; @@ -449,14 +457,14 @@ impl OpusEncoder { self.old_band_e = old_inter; } } else { - quant_coarse_energy(start, end, log_e, &mut self.old_band_e, false, &mut enc, c, LM, total_bits_raw); + quant_coarse_energy(frame, log_e, &mut self.old_band_e, false, &mut enc, total_bits_raw); } // TF resolution: no changes. { let mut budget = total_bits_raw; let mut tell = enc.tell(); let mut logp = 4; - let tf_select_rsv = LM > 0 && tell + logp + 1 <= budget; + let tf_select_rsv = LM > 0 && tell + logp < budget; budget -= tf_select_rsv as i32; for _ in start..end { if tell + logp <= budget { @@ -488,27 +496,11 @@ impl OpusEncoder { enc.icdf(alloc_trim as usize, &TRIM_ICDF, 7); } let bits = (total_bits_raw << BITRES) - enc.tell_frac() - 1; - let alloc = compute_allocation(m, start, end, &offsets, &cap, alloc_trim, bits, c, LM, &mut enc); - quant_fine_energy(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &mut enc, c); - quant_all_bands( - m, - start, - end, - &self.freq, - N, - c, - &alloc.pulses, - SPREAD_NORMAL, - alloc.dual_stereo, - alloc.intensity, - total_bits_raw << BITRES, - alloc.balance, - &mut enc, - LM, - alloc.coded_bands, - ); + let alloc = compute_allocation(m, frame, AllocationInput { offsets: &offsets, cap: &cap, trim: alloc_trim, bits }, &mut enc); + quant_fine_energy(frame, log_e, &mut self.old_band_e, &alloc.fine_quant, &mut enc); + quant_all_bands(m, frame, &self.freq, N, &alloc, BandCoding { spread: SPREAD_NORMAL, total_bits: total_bits_raw << BITRES }, &mut enc); let bits_left = total_bits_raw - enc.tell(); - quant_energy_finalise(start, end, log_e, &mut self.old_band_e, &alloc.fine_quant, &alloc.fine_priority, bits_left, &mut enc, c); + quant_energy_finalise(frame, log_e, &mut self.old_band_e, &alloc, bits_left, &mut enc); debug_assert!(enc.tell() <= total_bits_raw, "frame overflow: {} > {}", enc.tell(), total_bits_raw); if c == 1 { let (a, b) = self.old_band_e.split_at_mut(NB_EBANDS); diff --git a/crates/opusenc/src/rate.rs b/crates/opusenc/src/rate.rs index a6ae81f5c..ff6ccae95 100644 --- a/crates/opusenc/src/rate.rs +++ b/crates/opusenc/src/rate.rs @@ -4,8 +4,9 @@ //! spec-derived `filmcraft-opus` decoder (first-party, MIT OR Apache-2.0) step by step; the only //! difference is that the encoder *writes* the band-skip, intensity and dual-stereo symbols. +use crate::CeltFrame; use crate::range::{BITRES, RangeEncoder, ilog}; -use crate::tables::*; +use crate::tables::{ALLOC, EBANDS, LOG2_FRAC_TABLE, MAX_LM, NB_EBANDS}; const MAX_PSEUDO: i32 = 40; const LOG_MAX_PSEUDO: i32 = 6; @@ -127,7 +128,7 @@ impl Mode { let mut cap = [0i32; NB_EBANDS]; for (i, cp) in cap.iter_mut().enumerate() { let n = (EBANDS[i + 1] - EBANDS[i]) << lm; - *cp = (self.caps[NB_EBANDS * (2 * lm + c - 1) + i] as i32 + 64) * c as i32 * n >> 2; + *cp = ((self.caps[NB_EBANDS * (2 * lm + c - 1) + i] as i32 + 64) * c as i32 * n) >> 2; } cap } @@ -200,7 +201,7 @@ fn compute_caps(m: &Mode) -> Vec { let mut n0 = EBANDS[j + 1] - EBANDS[j]; let max_bits = if n0 << i == 1 { - c * (1 + MAX_FINE_BITS) << BITRES + (c * (1 + MAX_FINE_BITS)) << BITRES } else { let mut lm0 = 0; if n0 > 2 { @@ -240,7 +241,7 @@ fn compute_caps(m: &Mode) -> Vec { let num = mb + ndof * offset; let den = (ndof - 1) << BITRES; let qb = ((num + (den >> 1)) / den).min(MAX_FINE_BITS); - mb += c * qb << BITRES; + mb += (c * qb) << BITRES; mb }; let w = c * ((EBANDS[j + 1] - EBANDS[j]) << i); @@ -263,21 +264,19 @@ pub struct Allocation { pub fine_priority: [i32; NB_EBANDS], } +/// Per-band limits, boosts and available budget for the allocation search. +pub struct AllocationInput<'a> { + pub offsets: &'a [i32; NB_EBANDS], + pub cap: &'a [i32; NB_EBANDS], + pub trim: i32, + pub bits: i32, +} + /// `clt_compute_allocation`, encoder side. Stereo frames are always coded as dual (L/R) stereo /// with no intensity bands, and no band is skipped voluntarily. -#[allow(clippy::too_many_arguments)] -pub fn compute_allocation( - m: &Mode, - start: usize, - end: usize, - offsets: &[i32; NB_EBANDS], - cap: &[i32; NB_EBANDS], - alloc_trim: i32, - total: i32, - c: usize, - lm: usize, - enc: &mut RangeEncoder, -) -> Allocation { +pub fn compute_allocation(m: &Mode, frame: CeltFrame, input: AllocationInput<'_>, enc: &mut RangeEncoder) -> Allocation { + let CeltFrame { start, end, channels: c, lm } = frame; + let AllocationInput { offsets, cap, trim: alloc_trim, bits: total } = input; let ci = c as i32; let lmi = lm as i32; let mut total = total.max(0); @@ -302,8 +301,8 @@ pub fn compute_allocation( let mut trim_offset = [0i32; NB_EBANDS]; for j in start..end { let w = EBANDS[j + 1] - EBANDS[j]; - thresh[j] = (ci << BITRES).max((3 * w << lmi << BITRES) >> 4); - trim_offset[j] = ci * w * (alloc_trim - 5 - lmi) * (end as i32 - j as i32 - 1) * (1 << (lmi + BITRES as i32)) >> 6; + thresh[j] = (ci << BITRES).max(((3 * w) << lmi << BITRES) >> 4); + trim_offset[j] = (ci * w * (alloc_trim - 5 - lmi) * (end as i32 - j as i32 - 1) * (1 << (lmi + BITRES as i32))) >> 6; if w << lmi == 1 { trim_offset[j] -= ci << BITRES; } @@ -317,7 +316,7 @@ pub fn compute_allocation( let mid = (lo + hi) >> 1; for j in (start..end).rev() { let n = EBANDS[j + 1] - EBANDS[j]; - let mut bitsj = ci * n * (ALLOC[mid as usize][j] as i32) << lmi >> 2; + let mut bitsj = (ci * n * (ALLOC[mid as usize][j] as i32)) << lmi >> 2; if bitsj > 0 { bitsj = (bitsj + trim_offset[j]).max(0); } @@ -342,8 +341,8 @@ pub fn compute_allocation( lo -= 1; for j in start..end { let n = EBANDS[j + 1] - EBANDS[j]; - let mut bits1j = ci * n * (ALLOC[lo as usize][j] as i32) << lmi >> 2; - let mut bits2j = if hi >= nb_alloc { cap[j] } else { ci * n * (ALLOC[hi as usize][j] as i32) << lmi >> 2 }; + let mut bits1j = (ci * n * (ALLOC[lo as usize][j] as i32)) << lmi >> 2; + let mut bits2j = if hi >= nb_alloc { cap[j] } else { (ci * n * (ALLOC[hi as usize][j] as i32)) << lmi >> 2 }; if bits1j > 0 { bits1j = (bits1j + trim_offset[j]).max(0); } @@ -361,27 +360,27 @@ pub fn compute_allocation( bits1[j] = bits1j; bits2[j] = bits2j; } - interp_bits2pulses(m, start, end, skip_start, &bits1, &bits2, &thresh, cap, total, skip_rsv, intensity_rsv, dual_stereo_rsv, c, lm, enc) + let interpolation = + AllocationInterpolation { skip_start, bits1: &bits1, bits2: &bits2, thresh: &thresh, cap, total, skip_rsv, intensity_rsv, dual_stereo_rsv }; + interp_bits2pulses(m, frame, interpolation, enc) } -#[allow(clippy::too_many_arguments)] -fn interp_bits2pulses( - m: &Mode, - start: usize, - end: usize, +/// Bracketing allocation vectors and signalling reserves for the interpolation stage. +struct AllocationInterpolation<'a> { skip_start: usize, - bits1: &[i32; NB_EBANDS], - bits2: &[i32; NB_EBANDS], - thresh: &[i32; NB_EBANDS], - cap: &[i32; NB_EBANDS], - mut total: i32, + bits1: &'a [i32; NB_EBANDS], + bits2: &'a [i32; NB_EBANDS], + thresh: &'a [i32; NB_EBANDS], + cap: &'a [i32; NB_EBANDS], + total: i32, skip_rsv: i32, - mut intensity_rsv: i32, - mut dual_stereo_rsv: i32, - c: usize, - lm: usize, - enc: &mut RangeEncoder, -) -> Allocation { + intensity_rsv: i32, + dual_stereo_rsv: i32, +} + +fn interp_bits2pulses(m: &Mode, frame: CeltFrame, interpolation: AllocationInterpolation<'_>, enc: &mut RangeEncoder) -> Allocation { + let CeltFrame { start, end, channels: c, lm } = frame; + let AllocationInterpolation { skip_start, bits1, bits2, thresh, cap, mut total, skip_rsv, mut intensity_rsv, mut dual_stereo_rsv } = interpolation; let ci = c as i32; let alloc_floor = ci << BITRES; let stereo = (c > 1) as i32; @@ -393,7 +392,7 @@ fn interp_bits2pulses( let mut psum = 0; let mut done = false; for j in (start..end).rev() { - let tmp = bits1[j] + (mid * bits2[j] >> ALLOC_STEPS); + let tmp = bits1[j] + ((mid * bits2[j]) >> ALLOC_STEPS); if tmp >= thresh[j] || done { done = true; psum += tmp.min(cap[j]); @@ -411,7 +410,7 @@ fn interp_bits2pulses( let mut psum = 0; let mut done = false; for j in (start..end).rev() { - let mut tmp = bits1[j] + (lo * bits2[j] >> ALLOC_STEPS); + let mut tmp = bits1[j] + ((lo * bits2[j]) >> ALLOC_STEPS); if tmp < thresh[j] && !done { tmp = if tmp >= alloc_floor { alloc_floor } else { 0 }; } else { @@ -500,9 +499,9 @@ fn interp_bits2pulses( if n == 2 { offset += (den << BITRES) >> 2; } - if bits[j] + offset < den * 2 << BITRES { + if bits[j] + offset < (den * 2) << BITRES { offset += nclogn >> 2; - } else if bits[j] + offset < den * 3 << BITRES { + } else if bits[j] + offset < (den * 3) << BITRES { offset += nclogn >> 3; } let mut eb = (bits[j] + offset + (den << (BITRES - 1))).max(0); @@ -513,7 +512,7 @@ fn interp_bits2pulses( eb = eb.min(MAX_FINE_BITS); ebits[j] = eb; fine_priority[j] = (eb * (den << BITRES) >= bits[j] + offset) as i32; - bits[j] -= ci * eb << BITRES; + bits[j] -= (ci * eb) << BITRES; } else { excess = (bit - (ci << BITRES)).max(0); bits[j] = bit - excess; @@ -523,7 +522,7 @@ fn interp_bits2pulses( if excess > 0 { let extra_fine = (excess >> (stereo + BITRES as i32)).min(MAX_FINE_BITS - ebits[j]); ebits[j] += extra_fine; - let extra_bits = extra_fine * ci << BITRES; + let extra_bits = (extra_fine * ci) << BITRES; fine_priority[j] = (extra_bits >= excess - balance) as i32; excess -= extra_bits; } diff --git a/crates/opusenc/src/silk.rs b/crates/opusenc/src/silk.rs index c7a4b8692..2678a96a0 100644 --- a/crates/opusenc/src/silk.rs +++ b/crates/opusenc/src/silk.rs @@ -16,8 +16,12 @@ mod pulses; #[allow(dead_code)] pub(crate) mod tables; -use lpc::*; -use tables::*; +use lpc::{MAX_LPC_ORDER, div32_varq, inverse32_varq, nlsf2a, rshift_round, sat16, smlawb, smulwb, smulww}; +use tables::{ + DELTA_GAIN, GAIN_MSB, LTP_FILTER, LTP_PERIODICITY, LTP_SCALE, LTP_TAPS_0, LTP_TAPS_1, LTP_TAPS_2, MID_ONLY, NLSF_EXT, NLSF_INTERP, NLSF_SEL_NBMB, + NLSF_SEL_WB, NLSF_STAGE1, NLSF_STAGE2, PITCH_CONTOUR, PITCH_HIGH, PITCH_LOW, STEREO_PRED_JOINT, STEREO_WEIGHTS_Q13, TYPE_ACTIVE, TYPE_INACTIVE, UNIFORM3, + UNIFORM4, UNIFORM5, UNIFORM8, +}; use crate::range::RangeEncoder; diff --git a/crates/opusenc/src/silk/analysis.rs b/crates/opusenc/src/silk/analysis.rs index de510433e..439331076 100644 --- a/crates/opusenc/src/silk/analysis.rs +++ b/crates/opusenc/src/silk/analysis.rs @@ -1,7 +1,7 @@ //! Encoder-side signal analysis for SILK (own design): windowed autocorrelation LPC, LPC → NLSF //! conversion, open-loop pitch search and LTP codebook search. -use super::tables::*; +use super::tables::{CB_NB_20, CB_WB_20, LTP_FILTER, LTP_PERIODICITY, LTP_TAPS_0, LTP_TAPS_1, LTP_TAPS_2}; /// Short-term prediction coefficients `a` (prediction `x[n] ≈ Σ a[j] x[n-1-j]`) from the /// windowed segment `x`, with a Gaussian lag window and white-noise correction. diff --git a/crates/opusenc/src/silk/lpc.rs b/crates/opusenc/src/silk/lpc.rs index 7846d2973..e0f8c107e 100644 --- a/crates/opusenc/src/silk/lpc.rs +++ b/crates/opusenc/src/silk/lpc.rs @@ -5,7 +5,10 @@ //! spec-derived `filmcraft-opus` decoder (MIT OR Apache-2.0): the encoder must reconstruct the //! quantised NLSFs and LPC coefficients bit-exactly as the decoder does. -use super::tables::*; +use super::tables::{ + COS_Q12, LSF_ORDER_NBMB, LSF_ORDER_WB, NLSF_CB_NBMB, NLSF_CB_WB, NLSF_MIN_SPACING_NBMB, NLSF_MIN_SPACING_WB, NLSF_PRED_NBMB, NLSF_PRED_WB, NLSF_PSEL_NBMB, + NLSF_PSEL_WB, +}; pub const MAX_LPC_ORDER: usize = 16; diff --git a/crates/opusenc/src/silk/nlsf.rs b/crates/opusenc/src/silk/nlsf.rs index 3f1f17ac6..4695152ff 100644 --- a/crates/opusenc/src/silk/nlsf.rs +++ b/crates/opusenc/src/silk/nlsf.rs @@ -3,7 +3,9 @@ //! dequantisation exactly. The search strategy is our own. use super::lpc::{MAX_LPC_ORDER, nlsf_decode, smlawb, sqrt_approx}; -use super::tables::*; +use super::tables::{ + NLSF_CB_NBMB, NLSF_CB_WB, NLSF_EXT, NLSF_PRED_NBMB, NLSF_PRED_WB, NLSF_PSEL_NBMB, NLSF_PSEL_WB, NLSF_SEL_NBMB, NLSF_SEL_WB, NLSF_STAGE1, NLSF_STAGE2, +}; /// Number of stage-1 candidates refined with stage 2. const SURVIVORS: usize = 8; diff --git a/crates/opusenc/src/silk/pulses.rs b/crates/opusenc/src/silk/pulses.rs index f8cda15a9..ba22b5ec5 100644 --- a/crates/opusenc/src/silk/pulses.rs +++ b/crates/opusenc/src/silk/pulses.rs @@ -2,7 +2,7 @@ //! with LSB escapes, the recursive shell code, LSBs and signs, written in exactly the order the //! decoder reads them. -use super::tables::*; +use super::tables::{EXC_LSB, EXC_SIGN, PULSE_COUNT, RATE_LEVEL, SHELL_2, SHELL_4, SHELL_8, SHELL_16}; use crate::range::RangeEncoder; /// Largest pulse magnitude the quantiser produces (keeps every 16-sample block codable). From 8e67bfce473f0b870f6badf065c207212c086eae Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 23/38] Refactor av1enc for strict Clippy checks --- crates/av1enc/src/cdf.rs | 10 ++++++- crates/av1enc/src/decide.rs | 39 ++++++++++++++++++++------- crates/av1enc/src/loopfilter.rs | 22 +++++++++++---- crates/av1enc/src/pred.rs | 38 +++++++++++++++----------- crates/av1enc/src/transform.rs | 13 ++++++++- crates/av1enc/src/writer.rs | 48 ++++++++++++++++++++------------- 6 files changed, 121 insertions(+), 49 deletions(-) diff --git a/crates/av1enc/src/cdf.rs b/crates/av1enc/src/cdf.rs index 0466a1592..777164d1a 100644 --- a/crates/av1enc/src/cdf.rs +++ b/crates/av1enc/src/cdf.rs @@ -2,7 +2,15 @@ //! init_coeff_cdfs that its syntax uses). Layout as in filmcraft-av1: each innermost array //! holds N cumulative values (the last is 32768) plus the adaptation counter. -use crate::tables::*; +use crate::tables::{ + DEFAULT_ANGLE_DELTA_CDF, DEFAULT_COEFF_BASE_CDF, DEFAULT_COEFF_BASE_EOB_CDF, DEFAULT_COEFF_BR_CDF, DEFAULT_DC_SIGN_CDF, DEFAULT_DRL_MODE_CDF, + DEFAULT_EOB_EXTRA_CDF, DEFAULT_EOB_PT_16_CDF, DEFAULT_EOB_PT_32_CDF, DEFAULT_EOB_PT_64_CDF, DEFAULT_EOB_PT_128_CDF, DEFAULT_EOB_PT_256_CDF, + DEFAULT_EOB_PT_512_CDF, DEFAULT_EOB_PT_1024_CDF, DEFAULT_INTER_TX_TYPE_SET3_CDF, DEFAULT_INTRA_TX_TYPE_SET2_CDF, DEFAULT_IS_INTER_CDF, DEFAULT_MV_BIT_CDF, + DEFAULT_MV_CLASS_CDF, DEFAULT_MV_CLASS0_BIT_CDF, DEFAULT_MV_CLASS0_FR_CDF, DEFAULT_MV_FR_CDF, DEFAULT_MV_JOINT_CDF, DEFAULT_MV_SIGN_CDF, + DEFAULT_NEW_MV_CDF, DEFAULT_PARTITION_W8_CDF, DEFAULT_PARTITION_W16_CDF, DEFAULT_PARTITION_W32_CDF, DEFAULT_PARTITION_W64_CDF, DEFAULT_REF_MV_CDF, + DEFAULT_SINGLE_REF_CDF, DEFAULT_SKIP_CDF, DEFAULT_TXB_SKIP_CDF, DEFAULT_UV_MODE_CFL_ALLOWED_CDF, DEFAULT_UV_MODE_CFL_NOT_ALLOWED_CDF, DEFAULT_Y_MODE_CDF, + DEFAULT_ZERO_MV_CDF, +}; #[derive(Clone)] pub(crate) struct Cdfs { diff --git a/crates/av1enc/src/decide.rs b/crates/av1enc/src/decide.rs index 494e914c0..db18c61c3 100644 --- a/crates/av1enc/src/decide.rs +++ b/crates/av1enc/src/decide.rs @@ -3,8 +3,12 @@ //! processes, so the reconstruction built here is exactly what a decoder produces; the syntax //! is written afterwards by `writer.rs`. -use crate::pred::*; -use crate::tables::*; +use crate::pred::{INTRA_MODES_USED, InterBlock, IntraEdge, Plane, edges, predict_inter, predict_intra, subpel_plane}; +use crate::tables::{ + AC_QLOOKUP, BLOCK_8X8, BLOCK_64X64, DC_PRED, DC_QLOOKUP, DCT_DCT, DEFAULT_SCAN_4X4, DEFAULT_SCAN_8X8, DEFAULT_SCAN_16X16, DEFAULT_SCAN_32X32, MODE_TO_TXFM, + NEWMV, NUM_4X4_BLOCKS_WIDE, PARTITION_SPLIT, PARTITION_SUBSIZE, TX_4X4, TX_8X8, TX_16X16, TX_32X32, TX_64X64, TX_SET_DCTONLY, TX_SET_INTER_3, + TX_SET_INTRA_2, TX_SIZE_SQR_UP, TX_TYPE_IN_SET_INTER, TX_TYPE_IN_SET_INTRA, +}; use crate::transform::{forward_2d, inverse_2d}; /// Decisions for one coded block. @@ -100,6 +104,17 @@ pub(crate) fn intra_uv_tx_type(uv_mode: usize, uv_tx: usize) -> usize { if tx_in_set(tx_set(uv_tx, false), false, t) { t } else { DCT_DCT } } +/// Geometry and coding mode of one square transform block. +#[derive(Clone, Copy)] +struct TransformBlock { + plane: usize, + x: usize, + y: usize, + log2: u32, + tx_type: usize, + intra: bool, +} + struct TxResult { levels: Vec, nonzero: bool, @@ -362,7 +377,7 @@ impl<'a> Decider<'a> { types.push(alt); } for &t in &types { - let (res, recon) = self.try_tx(0, c * 4, r * 4, log2, t, &pred[..n * n], true); + let (res, recon) = self.try_tx(TransformBlock { plane: 0, x: c * 4, y: r * 4, log2, tx_type: t, intra: true }, &pred[..n * n]); let type_bits = if !res.nonzero || tx_set(tx_y, false) == TX_SET_DCTONLY { 0.0 } else if t == DCT_DCT { @@ -422,7 +437,7 @@ impl<'a> Decider<'a> { let uv_type = intra_uv_tx_type(uv_mode, uv_tx); let mut code_uv = |i: usize| { predict_intra(&ced[i], &ce[i], uv_mode, &mut pred); - let t = self.code_tx(1 + i, c * 2, r * 2, clog2, uv_type, &pred[..cn * cn], true); + let t = self.code_tx(TransformBlock { plane: 1 + i, x: c * 2, y: r * 2, log2: clog2, tx_type: uv_type, intra: true }, &pred[..cn * cn]); self.mark_decoded(1 + i, r, c, n4); t }; @@ -450,13 +465,17 @@ impl<'a> Decider<'a> { let (vw, vh) = self.g.visible(p); let pn = n >> s; let (x, y) = ((c * 4) >> s, (r * 4) >> s); - predict_inter(&refr.planes[p], vw as i32 - 1, vh as i32 - 1, x, y, pn, pn, mv, s, self.bd, &mut pred[..pn * pn]); + predict_inter( + &refr.planes[p], + &InterBlock { last_x: vw as i32 - 1, last_y: vh as i32 - 1, x, y, width: pn, height: pn, mv, subsampling: s, bit_depth: self.bd }, + &mut pred[..pn * pn], + ); if bsize == BLOCK_64X64 { // coded as a skip block: the prediction is the reconstruction dist += self.sse(p, x, y, pn, &pred[..pn * pn]); self.put(p, x, y, pn, &pred[..pn * pn]); } else { - let t = self.code_tx(p, x, y, log2 - s as u32, DCT_DCT, &pred[..pn * pn], false); + let t = self.code_tx(TransformBlock { plane: p, x, y, log2: log2 - s as u32, tx_type: DCT_DCT, intra: false }, &pred[..pn * pn]); dist += t.dist; tx.push(t); } @@ -771,8 +790,9 @@ impl<'a> Decider<'a> { /// Transform, quantise and reconstruct one square transform block of `plane` at (x, y) /// over the prediction `pred`; the reconstruction is written to `rec`. - fn code_tx(&mut self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> TxResult { - let (res, recon) = self.try_tx(p, x, y, log2, tx_type, pred, intra); + fn code_tx(&mut self, block: TransformBlock, pred: &[u16]) -> TxResult { + let TransformBlock { plane: p, x, y, log2, .. } = block; + let (res, recon) = self.try_tx(block, pred); let n = 1usize << log2; self.put(p, x, y, n, recon.as_deref().unwrap_or(pred)); res @@ -780,7 +800,8 @@ impl<'a> Decider<'a> { /// Transform and quantise one block; returns the result and its reconstruction (None: the /// prediction) without writing it. - fn try_tx(&self, p: usize, x: usize, y: usize, log2: u32, tx_type: usize, pred: &[u16], intra: bool) -> (TxResult, Option>) { + fn try_tx(&self, block: TransformBlock, pred: &[u16]) -> (TxResult, Option>) { + let TransformBlock { plane: p, x, y, log2, tx_type, intra } = block; let n = 1usize << log2; let tx_sz = tx_size_for(log2); let bd = self.bd; diff --git a/crates/av1enc/src/loopfilter.rs b/crates/av1enc/src/loopfilter.rs index 2add41edc..cf350fbd2 100644 --- a/crates/av1enc/src/loopfilter.rs +++ b/crates/av1enc/src/loopfilter.rs @@ -3,7 +3,7 @@ //! is 0 in the frames this encoder writes. use crate::pred::Plane; -use crate::tables::*; +use crate::tables::{MAX_LOOP_FILTER, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, SUBSAMPLED_SIZE, TX_HEIGHT, TX_WIDTH}; /// Mode info the loop filter reads (per 4x4 luma unit, row stride `mi_cols`), and the /// transform size per 4x4 unit of each plane (`LoopfilterTxSizes`, row stride `mi_cols + 32`). @@ -39,7 +39,7 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4 } let mut col = 0; while col < info.mi_cols { - edge(pl, info, level, bit_depth, plane, pass, row, col); + edge(pl, info, level, bit_depth, plane, pass, (row, col)); col += step; } row += step; @@ -48,7 +48,8 @@ pub(crate) fn loop_filter(planes: &mut [Plane; 3], info: &LfInfo, level: [u32; 4 } } -fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, row: usize, col: usize) { +fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, pass: usize, position: (usize, usize)) { + let (row, col) = position; let s = (plane > 0) as usize; let (dx, dy) = if pass == 0 { (1usize, 0usize) } else { (0, 1) }; let x = col * 4; @@ -88,13 +89,24 @@ fn edge(pl: &mut Plane, info: &LfInfo, level: [u32; 4], bd: u32, plane: usize, p let limit = lvl.max(1); let blimit = 2 * (lvl + 2) + limit; let thresh = lvl >> 4; + let strength = FilterStrength { limit, blimit, thresh }; for k in 0..4 { - sample_filter(pl, xp + dy * k, yp + dx * k, plane, limit, blimit, thresh, dx as isize, dy as isize, filter_size, bd); + sample_filter(pl, (xp + dy * k, yp + dx * k), plane, &strength, (dx as isize, dy as isize), filter_size, bd); } } +/// Thresholds derived from the loop-filter level (spec 7.14.4). +struct FilterStrength { + limit: i32, + blimit: i32, + thresh: i32, +} + #[inline] -fn sample_filter(pl: &mut Plane, x: usize, y: usize, plane: usize, limit: i32, blimit: i32, thresh: i32, dx: isize, dy: isize, filter_size: usize, bd: u32) { +fn sample_filter(pl: &mut Plane, position: (usize, usize), plane: usize, strength: &FilterStrength, direction: (isize, isize), filter_size: usize, bd: u32) { + let (x, y) = position; + let (dx, dy) = direction; + let FilterStrength { limit, blimit, thresh } = *strength; let stride = pl.stride as isize; let step = dy * stride + dx; let base = y as isize * stride + x as isize; diff --git a/crates/av1enc/src/pred.rs b/crates/av1enc/src/pred.rs index d61a6fa94..7b354af1f 100644 --- a/crates/av1enc/src/pred.rs +++ b/crates/av1enc/src/pred.rs @@ -2,7 +2,10 @@ //! modes plus V_PRED / H_PRED without edge filtering, 7.11.3 block inter prediction without //! reference scaling) so that the encoder predicts exactly like a decoder. -use crate::tables::*; +use crate::tables::{ + DC_PRED, EIGHTTAP, H_PRED, PAETH_PRED, SM_WEIGHTS_TX_4X4, SM_WEIGHTS_TX_8X8, SM_WEIGHTS_TX_16X16, SM_WEIGHTS_TX_32X32, SMOOTH_H_PRED, SMOOTH_PRED, + SMOOTH_V_PRED, SUBPEL_FILTERS, V_PRED, +}; use crate::transform::round2; /// A plane buffer (padded to whole superblocks). @@ -184,19 +187,20 @@ pub(crate) fn predict_intra(ed: &Edges, e: &IntraEdge, mode: usize, pred: &mut [ /// final rounding is the identity) of the `w x h` block at plane position (x, y) displaced by /// `mv` (1/8 luma sample units, [row, col]). `last_x` / `last_y` are the largest sample /// coordinates of the reference plane; the result goes to `pred` (row stride `w`). -pub(crate) fn predict_inter( - refp: &Plane, - last_x: i32, - last_y: i32, - x: usize, - y: usize, - w: usize, - h: usize, - mv: [i32; 2], - ss: usize, - bit_depth: u32, - pred: &mut [u16], -) { +pub(crate) struct InterBlock { + pub last_x: i32, + pub last_y: i32, + pub x: usize, + pub y: usize, + pub width: usize, + pub height: usize, + pub mv: [i32; 2], + pub subsampling: usize, + pub bit_depth: u32, +} + +pub(crate) fn predict_inter(refp: &Plane, block: &InterBlock, pred: &mut [u16]) { + let InterBlock { last_x, last_y, x, y, width: w, height: h, mv, subsampling: ss, bit_depth } = *block; // positions in 1/16 sample units ((startX >> 6) & 15 is the filter phase) let px = ((x as i32) << 4) + ((2 * mv[1]) >> ss); let py = ((y as i32) << 4) + ((2 * mv[0]) >> ss); @@ -340,7 +344,11 @@ mod tests { for (bx, by) in [(0i32, 0i32), (8, 8), (24, 16), (-5, 20), (30, -3)] { let mut pred = vec![0u16; 64]; let mv = [by * 8 + fy as i32 / 2, bx * 8 + fx as i32 / 2]; - predict_inter(&p, w as i32 - 1, h as i32 - 1, 0, 0, 8, 8, mv, 0, 8, &mut pred); + predict_inter( + &p, + &InterBlock { last_x: w as i32 - 1, last_y: h as i32 - 1, x: 0, y: 0, width: 8, height: 8, mv, subsampling: 0, bit_depth: 8 }, + &mut pred, + ); for r in 0..8 { for c in 0..8 { let (x, y) = ((bx + c).clamp(0, w as i32 - 1), (by + r).clamp(0, h as i32 - 1)); diff --git a/crates/av1enc/src/transform.rs b/crates/av1enc/src/transform.rs index 90042e26a..048bfc0db 100644 --- a/crates/av1enc/src/transform.rs +++ b/crates/av1enc/src/transform.rs @@ -4,7 +4,7 @@ use std::sync::OnceLock; -use crate::tables::*; +use crate::tables::{ADST_ADST, ADST_DCT, COS128_LOOKUP, DCT_ADST, DCT_DCT, TRANSFORM_ROW_SHIFT, TX_WIDTH_LOG2}; #[inline(always)] fn round2_64(x: i64, n: u32) -> i32 { @@ -71,6 +71,12 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { for i in 0..len { t[i] = copy[brev(n, i)]; } + inverse_dct_initial_stages(t, n, r); + inverse_dct_final_stages(t, n, r); +} + +/// Rotate the high-frequency branches before the four-point core. +fn inverse_dct_initial_stages(t: &mut [i32], n: u32, r: u32) { if n >= 5 { for i in 0..8 { bf(t, 16 + i, 31 - i, 6 + ((brev(3, 7 - i) as i32) << 3), false); @@ -103,6 +109,10 @@ fn inverse_dct(t: &mut [i32], n: u32, r: u32) { } } } +} + +/// Evaluate the four-point core and merge progressively larger branches. +fn inverse_dct_final_stages(t: &mut [i32], n: u32, r: u32) { for i in 0..2 { bf(t, 2 * i, 2 * i + 1, 32 + 16 * i as i32, i == 0); } @@ -454,6 +464,7 @@ pub(crate) fn forward_2d(residual: &[i32], out: &mut [f32], n: usize, tx_type: u #[cfg(test)] mod tests { use super::*; + use crate::tables::{TX_4X4, TX_8X8, TX_16X16, TX_32X32}; /// forward (float) then the normative inverse reproduces the residual: the forward basis /// and scale match the decoder's inverse transforms for every supported size and type. diff --git a/crates/av1enc/src/writer.rs b/crates/av1enc/src/writer.rs index 54c545fa4..ed9bca1c4 100644 --- a/crates/av1enc/src/writer.rs +++ b/crates/av1enc/src/writer.rs @@ -8,7 +8,15 @@ use crate::cdf::Cdfs; use crate::decide::{FrameGeom, Leaf, Node, intra_uv_tx_type, scan_for, tx_in_set, tx_set, tx_size_for}; use crate::loopfilter::LfInfo; use crate::symbol::SymbolWriter; -use crate::tables::*; +use crate::tables::{ + ADST_ADST, ADST_DCT, ALTREF_FRAME, ALTREF2_FRAME, BLOCK_8X8, BLOCK_64X64, BR_CDF_SIZE, BWDREF_FRAME, CLASS0_SIZE, COEFF_BASE_CTX_OFFSET, COEFF_BASE_RANGE, + D67_PRED, DC_PRED, DCT_ADST, DCT_DCT, DEFAULT_INTRA_FRAME_Y_MODE_CDF, GLOBALMV, GOLDEN_FRAME, INTRA_FRAME, INTRA_MODE_CONTEXT, LAST_FRAME, LAST2_FRAME, + LAST3_FRAME, MAG_REF_OFFSET_WITH_TX_CLASS, MAX_ANGLE_DELTA, MAX_REF_MV_STACK_SIZE, MI_HEIGHT_LOG2, MI_WIDTH_LOG2, MV_BORDER, MV_CLASS_0, NEARESTMV, NEARMV, + NEWMV, NUM_4X4_BLOCKS_HIGH, NUM_4X4_BLOCKS_WIDE, NUM_BASE_LEVELS, PARTITION_HORZ, PARTITION_HORZ_4, PARTITION_HORZ_A, PARTITION_HORZ_B, PARTITION_NONE, + PARTITION_SPLIT, PARTITION_SUBSIZE, PARTITION_VERT, PARTITION_VERT_4, PARTITION_VERT_A, PARTITION_VERT_B, REF_CAT_LEVEL, SIG_REF_DIFF_OFFSET, SIZE_GROUP, + SUBSAMPLED_SIZE, TX_32X32, TX_CLASS_2D, TX_HEIGHT, TX_HEIGHT_LOG2, TX_SIZE_SQR, TX_SIZE_SQR_UP, TX_TYPE_INTER_INV_SET3, TX_TYPE_INTRA_INV_SET2, TX_WIDTH, + TX_WIDTH_LOG2, V_PRED, +}; type MvP = [i32; 2]; @@ -455,23 +463,7 @@ impl<'a> TileWriter<'a> { } } else { if plane == 0 { - // transform_type( ) - let set = tx_set(tx_sz, b.is_inter); - let t = b.tx_type_y; - if set > 0 && self.qidx > 0 { - let sqr = TX_SIZE_SQR[tx_sz] as usize; - if b.is_inter { - // The decision only picks types of this set (DCT_DCT otherwise). - let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]); - } else { - let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default(); - self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]); - } - } else { - assert_eq!(t, DCT_DCT); - } - self.set_tx_types(x4, y4, tx_sz, t); + self.write_transform_type(x4, y4, tx_sz, b); } let plane_tx_type = self.compute_tx_type(plane, tx_sz, x4, y4, b); debug_assert!(plane_tx_type == DCT_DCT || matches!(plane_tx_type, ADST_DCT | DCT_ADST | ADST_ADST)); @@ -571,6 +563,26 @@ impl<'a> TileWriter<'a> { } } + fn write_transform_type(&mut self, x4: usize, y4: usize, tx_sz: usize, b: &Leaf) { + // transform_type( ) + let set = tx_set(tx_sz, b.is_inter); + let t = b.tx_type_y; + if set > 0 && self.qidx > 0 { + let sqr = TX_SIZE_SQR[tx_sz] as usize; + if b.is_inter { + // The decision only picks types of this set (DCT_DCT otherwise). + let s = TX_TYPE_INTER_INV_SET3.iter().position(|&v| v as usize == t).unwrap_or_default(); + self.sw.symbol(s, &mut self.cdf.inter_tx_type_set3[sqr]); + } else { + let s = TX_TYPE_INTRA_INV_SET2.iter().position(|&v| v as usize == t).unwrap_or_default(); + self.sw.symbol(s, &mut self.cdf.intra_tx_type_set2[sqr][b.y_mode]); + } + } else { + assert_eq!(t, DCT_DCT); + } + self.set_tx_types(x4, y4, tx_sz, t); + } + fn set_tx_types(&mut self, x4: usize, y4: usize, tx_sz: usize, t: usize) { for i in 0..(TX_WIDTH[tx_sz] as usize >> 2) { for j in 0..(TX_HEIGHT[tx_sz] as usize >> 2) { From 3bc15c7c176735d88b7bd5c521b2dcb08aba252a Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 24/38] Refactor vp9enc for strict Clippy checks --- crates/vp9enc/src/lf.rs | 83 +++++++++++++++++++++---------------- crates/vp9enc/src/lib.rs | 1 - crates/vp9enc/src/probs.rs | 6 ++- crates/vp9enc/src/tables.rs | 2 - crates/vp9enc/src/tile.rs | 37 ++++++++++------- 5 files changed, 75 insertions(+), 54 deletions(-) diff --git a/crates/vp9enc/src/lf.rs b/crates/vp9enc/src/lf.rs index d781144fe..f3a96c558 100644 --- a/crates/vp9enc/src/lf.rs +++ b/crates/vp9enc/src/lf.rs @@ -13,6 +13,14 @@ struct Limits { thresh: i32, } +/// Mode-info grid and thresholds shared by every superblock of this frame. +struct FrameFilter<'a> { + mi: &'a [Mi], + mi_rows: usize, + mi_cols: usize, + limits: Limits, +} + /// Filter the reconstructed planes (Y, U, V, or a prefix of them) in place at `level` (0 = off; /// sharpness 0). pub fn filter_frame(planes: &mut [Plane], mi: &[Mi], mi_rows: usize, mi_cols: usize, level: u8) { @@ -22,55 +30,58 @@ pub fn filter_frame(planes: &mut [Plane], mi: &[Mi], mi_rows: usize, mi_cols: us let l = level as i32; let limit = l.max(1); let lim = Limits { limit, blimit: 2 * (l + 2) + limit, thresh: l >> 4 }; + let filter = FrameFilter { mi, mi_rows, mi_cols, limits: lim }; for sbr in 0..mi_rows.div_ceil(8) { for sbc in 0..mi_cols.div_ceil(8) { for (plane, p) in planes.iter_mut().enumerate() { for pass in 0..2 { - sb_plane(p, mi, mi_rows, mi_cols, plane, pass, sbr * 8, sbc * 8, lim); + filter.sb_plane(p, plane, pass, sbr * 8, sbc * 8); } } } } } -#[allow(clippy::too_many_arguments)] -fn sb_plane(p: &mut Plane, mi: &[Mi], mi_rows: usize, mi_cols: usize, plane: usize, pass: usize, row: usize, col: usize, lim: Limits) { - let (sx, sy) = if plane > 0 { (1, 1) } else { (0, 0) }; - let (sub, edge_len) = if pass == 0 { (sx, 64 >> sy) } else { (sy, 64 >> sx) }; - let stride = p.w; - for edge in 0..(16usize >> sub) { - let mut i = 0; - while i < edge_len { - let (x, y) = if pass == 0 { (col * 8 + edge * (4 << sx), row * 8 + (i << sy)) } else { (col * 8 + (i << sx), row * 8 + edge * (4 << sy)) }; - if x >= 8 * mi_cols || y >= 8 * mi_rows || (pass == 0 && x == 0) || (pass == 1 && y == 0) { - i += 8; - continue; - } - let lc = ((x >> 3) >> sx) << sx; - let lr = ((y >> 3) >> sy) << sy; - let m = mi[lr * mi_cols + lc]; - let size = if sub == 0 { m.size } else { m.size.max(BLOCK_16X16) }; - let n8 = if size >= BLOCK_16X16 { 2 } else { 1 }; - let is_block_edge = if pass == 0 { x & (8 * n8 - 1) == 0 } else { y & (8 * n8 - 1) == 0 }; - let tx_ok = !m.inter || !m.skip; - if !is_block_edge && !tx_ok { - i += 8; - continue; - } - let wide = edge % 8 == 0; - let n = if pass == 0 { (8 * mi_rows - y).div_ceil(1 << sy) } else { (8 * mi_cols - x).div_ceil(1 << sx) }.min(8).min(edge_len - i); - // A transform edge is not filtered in the last odd chroma column. - let odd_last = pass == 1 && sx == 1 && mi_cols & 1 == 1 && edge & 1 == 1; - let (px, py) = (x >> sx, y >> sy); - for l in 0..n { - let is_tx_edge = tx_ok && !(odd_last && x + (l << sx) + 8 >= mi_cols * 8); - if !(is_block_edge || is_tx_edge) { +impl FrameFilter<'_> { + fn sb_plane(&self, p: &mut Plane, plane: usize, pass: usize, row: usize, col: usize) { + let Self { mi, mi_rows, mi_cols, limits: lim } = *self; + let (sx, sy) = if plane > 0 { (1, 1) } else { (0, 0) }; + let (sub, edge_len) = if pass == 0 { (sx, 64 >> sy) } else { (sy, 64 >> sx) }; + let stride = p.w; + for edge in 0..(16usize >> sub) { + let mut i = 0; + while i < edge_len { + let (x, y) = if pass == 0 { (col * 8 + edge * (4 << sx), row * 8 + (i << sy)) } else { (col * 8 + (i << sx), row * 8 + edge * (4 << sy)) }; + if x >= 8 * mi_cols || y >= 8 * mi_rows || (pass == 0 && x == 0) || (pass == 1 && y == 0) { + i += 8; continue; } - let (pos, step) = if pass == 0 { ((py + l) * stride + px, 1) } else { (py * stride + px + l, stride) }; - filter_lane(&mut p.rec, pos, step, wide, lim); + let lc = ((x >> 3) >> sx) << sx; + let lr = ((y >> 3) >> sy) << sy; + let m = mi[lr * mi_cols + lc]; + let size = if sub == 0 { m.size } else { m.size.max(BLOCK_16X16) }; + let n8 = if size >= BLOCK_16X16 { 2 } else { 1 }; + let is_block_edge = if pass == 0 { x & (8 * n8 - 1) == 0 } else { y & (8 * n8 - 1) == 0 }; + let tx_ok = !m.inter || !m.skip; + if !is_block_edge && !tx_ok { + i += 8; + continue; + } + let wide = edge % 8 == 0; + let n = if pass == 0 { (8 * mi_rows - y).div_ceil(1 << sy) } else { (8 * mi_cols - x).div_ceil(1 << sx) }.min(8).min(edge_len - i); + // A transform edge is not filtered in the last odd chroma column. + let odd_last = pass == 1 && sx == 1 && mi_cols & 1 == 1 && edge & 1 == 1; + let (px, py) = (x >> sx, y >> sy); + for l in 0..n { + let is_tx_edge = tx_ok && !(odd_last && x + (l << sx) + 8 >= mi_cols * 8); + if !(is_block_edge || is_tx_edge) { + continue; + } + let (pos, step) = if pass == 0 { ((py + l) * stride + px, 1) } else { (py * stride + px + l, stride) }; + filter_lane(&mut p.rec, pos, step, wide, lim); + } + i += 8; } - i += 8; } } } diff --git a/crates/vp9enc/src/lib.rs b/crates/vp9enc/src/lib.rs index 9364dfaa1..b9b56b693 100644 --- a/crates/vp9enc/src/lib.rs +++ b/crates/vp9enc/src/lib.rs @@ -85,7 +85,6 @@ pub(crate) struct Plane { } impl Plane { - #[allow(clippy::too_many_arguments)] fn new(data: &[u8], vw: usize, vh: usize, w: usize, h: usize, max_x: usize, max_y: usize) -> Plane { let mut src = vec![0u8; w * h]; for y in 0..h { diff --git a/crates/vp9enc/src/probs.rs b/crates/vp9enc/src/probs.rs index 8f55bbba3..57af2e29b 100644 --- a/crates/vp9enc/src/probs.rs +++ b/crates/vp9enc/src/probs.rs @@ -9,7 +9,11 @@ //! entropy codes the recorded symbols with the updated probabilities. use crate::bool::BoolEncoder; -use crate::tables::*; +use crate::tables::{ + DEFAULT_COEF_PROBS, DEFAULT_INTER_MODE_PROBS, DEFAULT_IS_INTER_PROB, DEFAULT_MV_BITS_PROB, DEFAULT_MV_CLASS_PROBS, DEFAULT_MV_CLASS0_BIT_PROB, + DEFAULT_MV_CLASS0_FR_PROBS, DEFAULT_MV_FR_PROBS, DEFAULT_MV_JOINT_PROBS, DEFAULT_MV_SIGN_PROB, DEFAULT_PARTITION_PROBS, DEFAULT_SINGLE_REF_PROB, + DEFAULT_SKIP_PROB, PARETO_TABLE, inv_map_table, +}; /// Coefficient probabilities `[ptype][ref][band][ctx][3]` for 4×4 transforms (band 0 uses /// contexts 0..3 only). diff --git a/crates/vp9enc/src/tables.rs b/crates/vp9enc/src/tables.rs index ffb266419..c70d7d8c2 100644 --- a/crates/vp9enc/src/tables.rs +++ b/crates/vp9enc/src/tables.rs @@ -2,8 +2,6 @@ //! transcribed in FilmCraft's first-party decoder `filmcraft-vp9` (MIT OR Apache-2.0, the same //! licence as this crate). -#![allow(clippy::unreadable_literal)] - pub const DC_QLOOKUP: [i32; 256] = [ 4, 8, 8, 9, 10, 11, 12, 12, 13, 14, 15, 16, 17, 18, 19, 19, 20, 21, 22, 23, 24, 25, 26, 26, 27, 28, 29, 30, 31, 32, 32, 33, 34, 35, 36, 37, 38, 38, 39, 40, 41, 42, 43, 43, 44, 45, 46, 47, 48, 48, 49, 50, 51, 52, 53, 53, 54, 55, 56, 57, 57, 58, 59, 60, 61, 62, 62, 63, 64, 65, 66, 66, 67, 68, 69, 70, 70, 71, 72, diff --git a/crates/vp9enc/src/tile.rs b/crates/vp9enc/src/tile.rs index d7f6cb64f..1f0c37d80 100644 --- a/crates/vp9enc/src/tile.rs +++ b/crates/vp9enc/src/tile.rs @@ -8,7 +8,10 @@ use crate::mc::{Mv, PhasePlanes, RefPlane, clamp_for_plane, predict}; use crate::probs::{self, P, Writer, cost, tree_cost}; -use crate::tables::*; +use crate::tables::{ + COEFBAND_4X4, COL_SCAN_4X4, COUNTER_TO_CONTEXT, DEFAULT_SCAN_4X4, ENERGY_CLASS, KF_PARTITION_PROBS, KF_UV_MODE_PROBS, KF_Y_MODE_PROBS, MODE_2_COUNTER, + MV_REF_8X8, MV_REF_16X16, ROW_SCAN_4X4, +}; use crate::transform::{ADST_ADST, ADST_DCT, DCT_ADST, DCT_DCT, Quant}; use crate::{BLOCK_8X8, BLOCK_16X16, Mi, Plane}; @@ -22,6 +25,13 @@ pub const NEARMV: u8 = 11; pub const ZEROMV: u8 = 12; pub const NEWMV: u8 = 13; +/// Which reconstructed neighbours are available for intra prediction. +#[derive(Clone, Copy)] +struct IntraEdges { + left: bool, + above: bool, +} + const PARTITION_TREE: [i8; 6] = [0, 2, -1, 4, -2, -3]; const INTRA_MODE_TREE: [i8; 18] = [0, 2, -9, 4, -1, 6, 8, 12, -2, 10, -4, -5, -3, 14, -8, 16, -6, -7]; /// Leaves are `mode - NEARESTMV`. @@ -104,7 +114,8 @@ fn levels_cost(levels: &[i32; 16], eob: usize) -> u32 { } /// Intra prediction (8.5.1) of a square block for DC / V / H / TM. -fn predict_intra(p: &Plane, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> [i32; 64] { +fn predict_intra(p: &Plane, x: usize, y: usize, log2: usize, mode: u8, edges: IntraEdges) -> [i32; 64] { + let IntraEdges { left: have_left, above: have_above } = edges; let size = 1usize << log2; let base = 128i32; let buf = &p.rec; @@ -356,11 +367,11 @@ impl<'a> Tile<'a> { fn intra_block(&mut self, r: usize, c: usize) { let avail_u = r > 0; let avail_l = c > self.col_start; - let y_mode = self.choose_intra(0, c * 8, r * 8, 3, avail_l, avail_u); + let y_mode = self.choose_intra(0, c * 8, r * 8, 3, IntraEdges { left: avail_l, above: avail_u }); let uv_mode = { let mut best = (i64::MAX, DC_PRED); for m in MODES { - let cost: i64 = (1..3).map(|p| self.intra_cost(p, c * 4, r * 4, 2, m, avail_l, avail_u)).sum(); + let cost: i64 = (1..3).map(|p| self.intra_cost(p, c * 4, r * 4, 2, m, IntraEdges { left: avail_l, above: avail_u })).sum(); if cost < best.0 { best = (cost, m); } @@ -379,17 +390,16 @@ impl<'a> Tile<'a> { // Residual: luma 2×2 transform blocks, then one 4×4 block per chroma plane. for (dy, dx) in [(0, 0), (0, 1), (1, 0), (1, 1)] { let tx = if self.f.quant.lossless { DCT_DCT } else { mode_tx_type(y_mode) }; - self.intra_tx(0, c * 8 + dx * 4, r * 8 + dy * 4, y_mode, tx, avail_l || dx > 0, avail_u || dy > 0); + self.intra_tx(0, c * 8 + dx * 4, r * 8 + dy * 4, y_mode, tx, IntraEdges { left: avail_l || dx > 0, above: avail_u || dy > 0 }); } for p in 1..3 { - self.intra_tx(p, c * 4, r * 4, uv_mode, DCT_DCT, avail_l, avail_u); + self.intra_tx(p, c * 4, r * 4, uv_mode, DCT_DCT, IntraEdges { left: avail_l, above: avail_u }); } } - #[allow(clippy::too_many_arguments)] - fn intra_cost(&self, plane: usize, x: usize, y: usize, log2: usize, mode: u8, have_left: bool, have_above: bool) -> i64 { + fn intra_cost(&self, plane: usize, x: usize, y: usize, log2: usize, mode: u8, edges: IntraEdges) -> i64 { let p = &self.planes[plane]; - let pred = predict_intra(p, x, y, log2, mode, have_left, have_above); + let pred = predict_intra(p, x, y, log2, mode, edges); let size = 1 << log2; let mut s = 0i64; for i in 0..size { @@ -400,10 +410,10 @@ impl<'a> Tile<'a> { s } - fn choose_intra(&self, plane: usize, x: usize, y: usize, log2: usize, have_left: bool, have_above: bool) -> u8 { + fn choose_intra(&self, plane: usize, x: usize, y: usize, log2: usize, edges: IntraEdges) -> u8 { let mut best = (i64::MAX, DC_PRED); for m in MODES { - let cost = self.intra_cost(plane, x, y, log2, m, have_left, have_above); + let cost = self.intra_cost(plane, x, y, log2, m, edges); if cost < best.0 { best = (cost, m); } @@ -411,8 +421,7 @@ impl<'a> Tile<'a> { best.1 } - #[allow(clippy::too_many_arguments)] - fn intra_tx(&mut self, plane: usize, x: usize, y: usize, mode: u8, tx_type: u8, have_left: bool, have_above: bool) { + fn intra_tx(&mut self, plane: usize, x: usize, y: usize, mode: u8, tx_type: u8, edges: IntraEdges) { let ss = (plane > 0) as usize; let max_x = (self.f.mi_cols * 8) >> ss; let max_y = (self.f.mi_rows * 8) >> ss; @@ -424,7 +433,7 @@ impl<'a> Tile<'a> { return; } let p = &self.planes[plane]; - let pred = predict_intra(p, x, y, 2, mode, have_left, have_above); + let pred = predict_intra(p, x, y, 2, mode, edges); let mut res = [0i32; 16]; for i in 0..4 { for j in 0..4 { From c8994a2cfee8d7a2c37c27c33a07f781b5bc8e11 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 25/38] Refactor hevcenc for strict Clippy checks --- crates/hevcenc/src/deblock.rs | 1 - crates/hevcenc/src/frame.rs | 321 +++++++++++++++++++--------------- crates/hevcenc/src/inter.rs | 6 +- crates/hevcenc/src/lib.rs | 4 +- 4 files changed, 184 insertions(+), 148 deletions(-) diff --git a/crates/hevcenc/src/deblock.rs b/crates/hevcenc/src/deblock.rs index 6f5d17b87..6d326e5d4 100644 --- a/crates/hevcenc/src/deblock.rs +++ b/crates/hevcenc/src/deblock.rs @@ -78,7 +78,6 @@ pub fn deblock(planes: &mut [Vec; 3], blk: &[Blk], w: usize, h: usize, qp: /// Filter one 4-sample luma edge segment. `o` = offset of q0 of the first line, `step` = distance /// between samples across the edge, `along` = distance between lines. -#[allow(clippy::too_many_arguments)] fn filter_luma(s: &mut [u16], o: usize, step: usize, along: usize, tc: i32, beta: i32, bd: u32) { let px = |s: &[u16], k: usize, i: usize| s[o + k * along - (i + 1) * step] as i32; let qx = |s: &[u16], k: usize, i: usize| s[o + k * along + i * step] as i32; diff --git a/crates/hevcenc/src/frame.rs b/crates/hevcenc/src/frame.rs index 760bea2b0..ee141e3e7 100644 --- a/crates/hevcenc/src/frame.rs +++ b/crates/hevcenc/src/frame.rs @@ -7,7 +7,10 @@ use crate::inter::{self, PlaneRef}; use crate::intra::{self, Refs}; use crate::me::MeRef; use crate::params::{LOG2_CTB, LOG2_MAX_TB, LOG2_MIN_CB, MAX_MERGE_CAND}; -use crate::tables::*; +use crate::tables::{ + ABS_MVD_GT0, ABS_MVD_GT1, CBF_CHROMA, CBF_LUMA, CODED_SUB_BLOCK, CTX_IDX_MAP, CU_SKIP, GT1, GT2, INTRA_CHROMA, LAST_X_PREFIX, LAST_Y_PREFIX, MERGE_FLAG, + MERGE_IDX, MVP_FLAG, PART_MODE, PRED_MODE, PREV_INTRA_LUMA, RQT_ROOT_CBF, SCAN_4, SIG_COEFF, SPLIT_CU, qpc_420, scan_order, +}; use crate::transform; pub const F_INTRA: u8 = 1; @@ -73,6 +76,40 @@ pub enum SliceKind { P, } +/// Picture geometry and slice coding parameters shared by the CTU encoder. +pub struct FrameConfig { + pub bit_depth: u32, + pub width: usize, + pub height: usize, + pub kind: SliceKind, + pub slice_qp: i32, +} + +/// A square node in the coding or transform quadtree. +#[derive(Clone, Copy)] +struct TreeNode { + x: usize, + y: usize, + log2: u32, + depth: u32, +} + +impl TreeNode { + fn children(self) -> impl Iterator { + let h = 1usize << (self.log2 - 1); + [(0, 0), (h, 0), (0, h), (h, h)].into_iter().map(move |(dx, dy)| Self { x: self.x + dx, y: self.y + dy, log2: self.log2 - 1, depth: self.depth + 1 }) + } +} + +/// Transform syntax inherited from the parent node and the enclosing CU. +#[derive(Clone, Copy)] +struct TransformState { + blk_idx: u32, + parent: [bool; 2], + intra: bool, + nxn: bool, +} + pub struct FrameCoder<'a> { pub bd: u32, pub w: usize, @@ -114,17 +151,8 @@ fn morton(x: u32, y: u32) -> u32 { const COARSE_MODES: [u32; 11] = [0, 1, 2, 6, 10, 14, 18, 22, 26, 30, 34]; impl<'a> FrameCoder<'a> { - #[allow(clippy::too_many_arguments)] - pub fn new( - bd: u32, - w: usize, - h: usize, - kind: SliceKind, - slice_qp: i32, - src: &'a [Vec; 3], - refp: Option<&'a [Vec; 3]>, - me: Option<&'a MeRef>, - ) -> Self { + pub fn new(config: FrameConfig, src: &'a [Vec; 3], refp: Option<&'a [Vec; 3]>, me: Option<&'a MeRef>) -> Self { + let FrameConfig { bit_depth: bd, width: w, height: h, kind, slice_qp } = config; let off = 6 * (bd as i32 - 8); let qpi = slice_qp.clamp(-off, 57); let qp_c = qpc_420(qpi) + off; @@ -256,7 +284,7 @@ impl<'a> FrameCoder<'a> { let mut decs = Vec::with_capacity(64); self.decide(x, y, LOG2_CTB, 0, &mut est, &mut decs); let mut idx = 0; - self.write_cq(enc, x, y, LOG2_CTB, 0, &decs, &mut idx); + self.write_cq(enc, TreeNode { x, y, log2: LOG2_CTB, depth: 0 }, &decs, &mut idx); debug_assert_eq!(idx, decs.len()); debug_assert_eq!(est.ctx, enc.ctx); enc.terminate((i + 1 == total) as u32); // end_of_slice_segment_flag @@ -276,7 +304,8 @@ impl<'a> FrameCoder<'a> { } /// Final pass: re-code the decided quadtree with the real arithmetic coder. - fn write_cq(&mut self, enc: &mut CabacEncoder, x: usize, y: usize, log2: u32, depth: u32, decs: &[CuDec], idx: &mut usize) { + fn write_cq(&mut self, enc: &mut CabacEncoder, node: TreeNode, decs: &[CuDec], idx: &mut usize) { + let TreeNode { x, y, log2, depth } = node; if x >= self.w || y >= self.h { return; } @@ -294,9 +323,8 @@ impl<'a> FrameCoder<'a> { let (eff, _) = self.encode_cu(enc, x, y, log2, depth, d.mode); debug_assert_eq!(eff, d.mode); } else { - let h = n / 2; - for (dx, dy) in [(0, 0), (h, 0), (0, h), (h, h)] { - self.write_cq(enc, x + dx, y + dy, log2 - 1, depth + 1, decs, idx); + for child in node.children() { + self.write_cq(enc, child, decs, idx); } } } @@ -447,7 +475,7 @@ impl<'a> FrameCoder<'a> { self.write_intra_modes(s, x, y, n, nxn, &modes); s.decision(INTRA_CHROMA, 0); // intra_chroma_pred_mode 4 (DM) let mut ti = 0; - self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], true, nxn, &mut ti); + self.write_tt(s, TreeNode { x, y, log2, depth: 0 }, TransformState { blk_idx: 0, parent: [true, true], intra: true, nxn }, &mut ti); (mode, self.cu_dist(x, y, n)) } _ => { @@ -490,7 +518,12 @@ impl<'a> FrameCoder<'a> { s.decision(MERGE_FLAG, 1); write_merge_idx(s, idx as usize); let mut ti = 0; - self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], false, false, &mut ti); + self.write_tt( + s, + TreeNode { x, y, log2, depth: 0 }, + TransformState { blk_idx: 0, parent: [true, true], intra: false, nxn: false }, + &mut ti, + ); } CuMode::Amvp { mv, mvp } => { s.decision(CU_SKIP + skip_ctx, 0); @@ -503,7 +536,12 @@ impl<'a> FrameCoder<'a> { s.decision(RQT_ROOT_CBF, root as u32); if root { let mut ti = 0; - self.write_tt(s, x, y, x, y, log2, 0, 0, [true, true], false, false, &mut ti); + self.write_tt( + s, + TreeNode { x, y, log2, depth: 0 }, + TransformState { blk_idx: 0, parent: [true, true], intra: false, nxn: false }, + &mut ti, + ); } } // Handled by the arm above. @@ -715,22 +753,9 @@ impl<'a> FrameCoder<'a> { /// transform_tree() (7.3.8.8) over the coded transform blocks of the CU; no split flag is ever /// coded (max_transform_hierarchy_depth_* = 0), splits are inferred. - #[allow(clippy::too_many_arguments)] - fn write_tt( - &self, - s: &mut S, - x0: usize, - y0: usize, - xb: usize, - yb: usize, - log2: u32, - depth: u32, - blk_idx: u32, - parent: [bool; 2], - intra: bool, - nxn: bool, - ti: &mut usize, - ) { + fn write_tt(&self, s: &mut S, node: TreeNode, state: TransformState, ti: &mut usize) { + let TreeNode { x: x0, y: y0, log2, depth } = node; + let TransformState { blk_idx, parent, intra, nxn } = state; let n = 1usize << log2; let split = log2 > LOG2_MAX_TB || (nxn && depth == 0); let mut cbf_c = [false; 2]; @@ -745,13 +770,11 @@ impl<'a> FrameCoder<'a> { cbf_c = parent; } if split { - let h = n / 2; - for (i, (dx, dy)) in [(0, 0), (h, 0), (0, h), (h, h)].into_iter().enumerate() { - self.write_tt(s, x0 + dx, y0 + dy, x0, y0, log2 - 1, depth + 1, i as u32, cbf_c, intra, nxn, ti); + for (i, child) in node.children().enumerate() { + self.write_tt(s, child, TransformState { blk_idx: i as u32, parent: cbf_c, ..state }, ti); } return; } - let _ = (xb, yb); let tb = self.tbs[*ti]; *ti += 1; debug_assert_eq!(tb.c, 0); @@ -874,9 +897,9 @@ impl<'a> FrameCoder<'a> { let (px, py, pn, pw, ph) = if c == 0 { (x, y, n, self.w, self.h) } else { (x / 2, y / 2, n / 2, self.cw, self.ch) }; let pr = PlaneRef { data: &refp[c], w: pw, h: ph }; if c == 0 { - inter::mc_luma(pr, self.bd, px as i32, py as i32, mv, pn, pn, &mut self.pred); + inter::mc_luma(pr, self.bd, px as i32, py as i32, mv, (pn, pn), &mut self.pred); } else { - inter::mc_chroma(pr, self.bd, px as i32, py as i32, mv, pn, pn, &mut self.pred); + inter::mc_chroma(pr, self.bd, px as i32, py as i32, mv, (pn, pn), &mut self.pred); } inter::put_uni(&self.pred, pn, pn, self.bd, &mut self.rec[c][py * pw + px..], pw); } @@ -1153,8 +1176,119 @@ fn last_prefix(v: u32) -> (u32, u32, u32) { } } +/// Context increment for significant_coeff_flag (H.265 9.3.4.2). +fn significance_context(group: (usize, usize), pos: (usize, usize), log2: u32, prev_csbf: u8, c: usize, scan_idx: usize) -> usize { + let (xs, ys) = group; + let (xp, yp) = pos; + let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); + if log2 == 2 { + CTX_IDX_MAP[(yc << 2) + xc] as usize + } else if xc + yc == 0 { + 0 + } else { + let mut sc = match prev_csbf { + 0 => { + if xp + yp == 0 { + 2 + } else if xp + yp < 3 { + 1 + } else { + 0 + } + } + 1 => { + if yp == 0 { + 2 + } else if yp == 1 { + 1 + } else { + 0 + } + } + 2 => { + if xp == 0 { + 2 + } else if xp == 1 { + 1 + } else { + 0 + } + } + _ => 2, + }; + if c == 0 { + if xs + ys > 0 { + sc += 3; + } + sc += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 }; + } else { + sc += if log2 == 3 { 9 } else { 12 }; + } + sc + } +} + +/// Absolute levels, signs and Rice remainders for one significant coefficient group. +fn write_coefficient_levels(s: &mut S, sig: &[u8], at: impl Fn(usize) -> i32, i: usize, c: usize, prev_g1: &mut Option) { + let nsig = sig.len(); + let abs_at = |k: usize| at(sig[k] as usize).unsigned_abs(); + let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 }; + if *prev_g1 == Some(0) { + ctx_set += 1; + } + let mut g1ctx = 1u32; + let mut gt1 = [0u8; 16]; + let mut first_g1: Option = None; + let c_off_g1 = if c > 0 { 16 } else { 0 }; + for k in 0..nsig.min(8) { + let f = abs_at(k) > 1; + s.decision(GT1 + ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1, f as u32); + gt1[k] = f as u8; + if f { + g1ctx = 0; + if first_g1.is_none() { + first_g1 = Some(k); + } + } else if g1ctx > 0 { + g1ctx += 1; + } + } + *prev_g1 = Some(g1ctx); + let mut gt2 = 0u32; + if let Some(k) = first_g1 { + gt2 = (abs_at(k) > 2) as u32; + s.decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }, gt2); + } + // sign data hiding: the sign of the first coefficient in scan order (sig[nsig - 1]) is + // inferred from the parity of the sum of absolute levels + let hidden = sig[0] as i32 - sig[nsig - 1] as i32 > 3; + let nsigns = if hidden { nsig - 1 } else { nsig }; + for k in 0..nsigns { + s.bypass((at(sig[k] as usize) < 0) as u32); + } + if hidden { + let sum: u32 = (0..nsig).map(abs_at).sum(); + debug_assert_eq!(at(sig[nsig - 1] as usize) < 0, sum & 1 == 1, "sign hiding parity"); + } + let mut rice = 0u32; + for k in 0..nsig { + let mut base = 1 + gt1[k] as u32; + if Some(k) == first_g1 { + base += gt2; + } + let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 }; + if base == thresh { + let level = abs_at(k); + write_remaining(s, level - base, rice); + if level > 3 * (1 << rice) { + rice = (rice + 1).min(4); + } + } + } +} + /// residual_coding() (7.3.8.11) of one transform block of levels (row-major, n x n). No transform -/// skip, no sign data hiding. +/// skip; signs use sign data hiding where the coefficient span permits it. fn residual_coding(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx: usize) { let n = 1usize << log2; let log2sb = log2 - 2; @@ -1233,51 +1367,7 @@ fn residual_coding(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx let (xc, yc) = (xs * 4 + xp, ys * 4 + yp); let v = lv[yc * n + xc] != 0; if np > 0 || !infer_dc { - let sig_ctx = if log2 == 2 { - CTX_IDX_MAP[(yc << 2) + xc] as usize - } else if xc + yc == 0 { - 0 - } else { - let mut sc = match prev_csbf { - 0 => { - if xp + yp == 0 { - 2 - } else if xp + yp < 3 { - 1 - } else { - 0 - } - } - 1 => { - if yp == 0 { - 2 - } else if yp == 1 { - 1 - } else { - 0 - } - } - 2 => { - if xp == 0 { - 2 - } else if xp == 1 { - 1 - } else { - 0 - } - } - _ => 2, - }; - if c == 0 { - if xs + ys > 0 { - sc += 3; - } - sc += if log2 == 3 { if scan_idx == 0 { 9 } else { 15 } } else { 21 }; - } else { - sc += if log2 == 3 { 9 } else { 12 }; - } - sc - }; + let sig_ctx = significance_context((xs, ys), (xp, yp), log2, prev_csbf, c, scan_idx); s.decision(SIG_COEFF + c_off_sig + sig_ctx, v as u32); if v { sig[nsig] = p as u8; @@ -1294,60 +1384,7 @@ fn residual_coding(s: &mut S, lv: &[i32], log2: u32, c: usize, scan_idx if nsig == 0 { continue; } - let abs_at = |k: usize| at(xs, ys, sig[k] as usize).unsigned_abs(); - let mut ctx_set = if i == 0 || c > 0 { 0 } else { 2 }; - if prev_g1 == Some(0) { - ctx_set += 1; - } - let mut g1ctx = 1u32; - let mut gt1 = [0u8; 16]; - let mut first_g1: Option = None; - let c_off_g1 = if c > 0 { 16 } else { 0 }; - for k in 0..nsig.min(8) { - let f = abs_at(k) > 1; - s.decision(GT1 + ctx_set * 4 + g1ctx.min(3) as usize + c_off_g1, f as u32); - gt1[k] = f as u8; - if f { - g1ctx = 0; - if first_g1.is_none() { - first_g1 = Some(k); - } - } else if g1ctx > 0 { - g1ctx += 1; - } - } - prev_g1 = Some(g1ctx); - let mut gt2 = 0u32; - if let Some(k) = first_g1 { - gt2 = (abs_at(k) > 2) as u32; - s.decision(GT2 + ctx_set + if c > 0 { 4 } else { 0 }, gt2); - } - // sign data hiding: the sign of the first coefficient in scan order (sig[nsig - 1]) is - // inferred from the parity of the sum of absolute levels - let hidden = sig[0] as i32 - sig[nsig - 1] as i32 > 3; - let nsigns = if hidden { nsig - 1 } else { nsig }; - for k in 0..nsigns { - s.bypass((at(xs, ys, sig[k] as usize) < 0) as u32); - } - if hidden { - let sum: u32 = (0..nsig).map(abs_at).sum(); - debug_assert_eq!(at(xs, ys, sig[nsig - 1] as usize) < 0, sum & 1 == 1, "sign hiding parity"); - } - let mut rice = 0u32; - for k in 0..nsig { - let mut base = 1 + gt1[k] as u32; - if Some(k) == first_g1 { - base += gt2; - } - let thresh = if k < 8 { if Some(k) == first_g1 { 3 } else { 2 } } else { 1 }; - if base == thresh { - let level = abs_at(k); - write_remaining(s, level - base, rice); - if level > 3 * (1 << rice) { - rice = (rice + 1).min(4); - } - } - } + write_coefficient_levels(s, &sig[..nsig], |p| at(xs, ys, p), i, c, &mut prev_g1); } } diff --git a/crates/hevcenc/src/inter.rs b/crates/hevcenc/src/inter.rs index e363871b5..03bf50ffb 100644 --- a/crates/hevcenc/src/inter.rs +++ b/crates/hevcenc/src/inter.rs @@ -35,7 +35,8 @@ impl PlaneRef<'_> { /// Luma prediction samples (14-bit intermediate) of a w x h block at (x, y) displaced by the /// quarter-sample vector `mv`, into `out` (stride w). -pub fn mc_luma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], w: usize, h: usize, out: &mut [i16]) { +pub fn mc_luma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], size: (usize, usize), out: &mut [i16]) { + let (w, h) = size; let (fx, fy) = ((mv[0] & 3) as usize, (mv[1] & 3) as usize); let xi = x + (mv[0] as i32 >> 2); let yi = y + (mv[1] as i32 >> 2); @@ -89,7 +90,8 @@ pub fn mc_luma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], w: usize, h: } /// Chroma prediction samples of a w x h block at chroma position (x, y) with a 1/8-sample vector. -pub fn mc_chroma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], w: usize, h: usize, out: &mut [i16]) { +pub fn mc_chroma(f: PlaneRef, bd: u32, x: i32, y: i32, mv: [i16; 2], size: (usize, usize), out: &mut [i16]) { + let (w, h) = size; let (fx, fy) = ((mv[0] & 7) as usize, (mv[1] & 7) as usize); let xi = x + (mv[0] as i32 >> 3); let yi = y + (mv[1] as i32 >> 3); diff --git a/crates/hevcenc/src/lib.rs b/crates/hevcenc/src/lib.rs index b2e86b6a1..4d38f84d9 100644 --- a/crates/hevcenc/src/lib.rs +++ b/crates/hevcenc/src/lib.rs @@ -15,8 +15,6 @@ //! ``` #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] -// Index loops over fixed-size blocks read more clearly than iterator chains in codec code. -#![allow(clippy::needless_range_loop, clippy::too_many_arguments)] mod bitstream; mod cabac; @@ -311,7 +309,7 @@ impl Encoder { let kind = if idr { SliceKind::I } else { SliceKind::P }; let me = if idr { None } else { self.reference.as_ref().map(|r| me::MeRef::new(&r[0], w, h, bd)) }; let refp = if idr { None } else { self.reference.as_ref() }; - let mut fc = FrameCoder::new(bd, w, h, kind, qp, &src, refp, me.as_ref()); + let mut fc = FrameCoder::new(frame::FrameConfig { bit_depth: bd, width: w, height: h, kind, slice_qp: qp }, &src, refp, me.as_ref()); // slice_segment_header() (7.3.6.1) let mut bw = BitWriter::new(); From 1d2c0d424808080632b5f20174a3a6f2dfcd48d4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 26/38] Refactor script for strict Clippy checks --- crates/script/src/prelude.js | 11 ++++++++--- crates/script/src/socket.rs | 2 +- crates/script/src/tests.rs | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) diff --git a/crates/script/src/prelude.js b/crates/script/src/prelude.js index ce64e221b..f012ce128 100644 --- a/crates/script/src/prelude.js +++ b/crates/script/src/prelude.js @@ -189,7 +189,12 @@ BridgeTalk.pump = function () { return false; }; // ------------------------------------------------------------------ File / Folder -function __join(a, b) { return a.replace(/[\/\\]+$/, "") + "/" + b; } +function __trimPathSeparators(path) { + var end = path.length; + while (end > 0 && (path.charAt(end - 1) === "/" || path.charAt(end - 1) === "\\")) end--; + return path.slice(0, end); +} +function __join(a, b) { return __trimPathSeparators(a) + "/" + b; } function File(path) { if (!(this instanceof File)) return new File(path); @@ -292,9 +297,9 @@ Folder.prototype = { get fsName() { return this.__path; }, get fullName() { return this.__path; }, get absoluteURI() { return this.__path; }, - get name() { return this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/).pop(); }, + get name() { return __trimPathSeparators(this.__path).split(/[\/\\]/).pop(); }, get displayName() { return this.name; }, - get path() { var p = this.__path.replace(/[\/\\]+$/, "").split(/[\/\\]/); p.pop(); return p.join("/"); }, + get path() { var p = __trimPathSeparators(this.__path).split(/[\/\\]/); p.pop(); return p.join("/"); }, get parent() { return new Folder(this.path); }, get exists() { return JSON.parse(__file("isDir", this.__path)) === true; }, toString: function () { return this.__path; }, diff --git a/crates/script/src/socket.rs b/crates/script/src/socket.rs index 6b2976dc1..4103f12c1 100644 --- a/crates/script/src/socket.rs +++ b/crates/script/src/socket.rs @@ -55,7 +55,7 @@ mod imp { use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; - use super::*; + use super::{J, err, json}; enum Sock { Stream { s: TcpStream, buf: Vec, eof: bool }, diff --git a/crates/script/src/tests.rs b/crates/script/src/tests.rs index 8b7973c5d..c0411585b 100644 --- a/crates/script/src/tests.rs +++ b/crates/script/src/tests.rs @@ -456,6 +456,38 @@ fn layers_items_and_markers() { ); } +#[test] +fn folder_paths_trim_trailing_separators() { + let mut s = session(); + let o = ok( + &mut s, + r#" + var paths = ['', '/', '///', 'a/b/', 'a\\b\\/', 'a/b', 'a/\n']; + var result = paths.map(function (path) { + var folder = new Folder(path); + return [folder.name, folder.path, __join(path, 'child')]; + }); + // A long run followed by a non-separator used to cause quadratic regex backtracking. + var longPath = Array(20001).join('/') + 'x'; + result.push([new Folder(longPath).name, new Folder(longPath).path.length, __join(longPath, 'child').length]); + result + "#, + ); + assert_eq!( + o.result, + json!([ + ["", "", "/child"], + ["", "", "/child"], + ["", "", "/child"], + ["b", "a", "a/b/child"], + ["b", "a", "a\\b/child"], + ["b", "a", "a/b/child"], + ["\n", "a", "a/\n/child"], + ["x", 19999, 20007] + ]) + ); +} + #[test] fn security_gate_for_files() { let dir = std::env::temp_dir().join(format!("ec-script-gate-{}", std::process::id())); From 7926b8bd3101af9a8f6636943df510eb3200f1dd Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 27/38] Refactor gpu for strict Clippy checks --- crates/gpu/src/bokeh.rs | 2 +- crates/gpu/src/classic3d.rs | 9 +++++++- crates/gpu/src/context.rs | 28 +++++++++++------------- crates/gpu/src/effects.rs | 10 +++++++-- crates/gpu/src/fx_generate.rs | 2 +- crates/gpu/src/fx_noise.rs | 2 +- crates/gpu/src/fx_stylize.rs | 4 ++-- crates/gpu/src/fx_vr.rs | 16 ++++++++++++-- crates/gpu/src/ops.rs | 40 ++++++++++++----------------------- crates/gpu/src/tests/init.rs | 1 - crates/gpu/src/walk.rs | 4 ++-- 11 files changed, 62 insertions(+), 56 deletions(-) diff --git a/crates/gpu/src/bokeh.rs b/crates/gpu/src/bokeh.rs index ac07448c9..39dc0bdea 100644 --- a/crates/gpu/src/bokeh.rs +++ b/crates/gpu/src/bokeh.rs @@ -26,7 +26,7 @@ pub(crate) fn apply(e: &mut Enc, src: &GpuImage, d: &PlaneDof) -> Option = d.radius.as_ref().clone(); let mut levels = vec![]; diff --git a/crates/gpu/src/classic3d.rs b/crates/gpu/src/classic3d.rs index 7ee2988f9..a3e2a3a59 100644 --- a/crates/gpu/src/classic3d.rs +++ b/crates/gpu/src/classic3d.rs @@ -214,7 +214,14 @@ pub(crate) fn draw_run(e: &mut Enc, run: &Run3d, canvas: &GpuImage) -> Option { owner: u64, } +/// Images bound to a compute kernel's source, optional auxiliary and output slots. +pub(crate) struct KernelImages<'a> { + pub src: &'a GpuImage, + pub aux: Option<&'a GpuImage>, + pub out: &'a GpuImage, +} + impl Drop for Enc<'_> { fn drop(&mut self) { // Unsubmitted work is discarded, so nothing recorded will read pooled textures. @@ -1162,32 +1169,21 @@ impl<'g> Enc<'g> { } /// Run kernel `entry` writing `out` (dispatched over `groups` workgroups). - pub fn dispatch( - &mut self, - entry: &str, - p: &Params, - src: &GpuImage, - aux: Option<&GpuImage>, - out: &GpuImage, - data: Option<&wgpu::Buffer>, - groups: (u32, u32), - ) { - self.dispatch_ext(entry, p, src, aux, out, data, groups, None); + pub fn dispatch(&mut self, entry: &str, p: &Params, images: KernelImages<'_>, data: Option<&wgpu::Buffer>, groups: (u32, u32)) { + self.dispatch_ext(entry, p, images, data, groups, None); } /// [`Enc::dispatch`] for the kernels in `EXT_ENTRIES`, with their group 1 storage buffers. - #[allow(clippy::too_many_arguments)] pub fn dispatch_ext( &mut self, entry: &str, p: &Params, - src: &GpuImage, - aux: Option<&GpuImage>, - out: &GpuImage, + images: KernelImages<'_>, data: Option<&wgpu::Buffer>, groups: (u32, u32), ext: Option<[&wgpu::Buffer; 4]>, ) { + let KernelImages { src, aux, out } = images; let g = self.g; let Some(pipe) = g.pipelines.get(entry) else { if !g.unavailable.iter().any(|(e, _)| *e == entry) { @@ -1243,7 +1239,7 @@ impl<'g> Enc<'g> { /// Per-pixel kernel over `out`'s size (16×16 workgroups). pub fn pixels(&mut self, entry: &str, p: &Params, src: &GpuImage, aux: Option<&GpuImage>, out: &GpuImage, data: Option<&wgpu::Buffer>) { let groups = (out.width.div_ceil(16), out.height.div_ceil(16)); - self.dispatch(entry, p, src, aux, out, data, groups); + self.dispatch(entry, p, KernelImages { src, aux, out }, data, groups); } /// Copy `src` into a larger zeroed image at (`x`, `y`) (Buf::pad). diff --git a/crates/gpu/src/effects.rs b/crates/gpu/src/effects.rs index 5041582f9..087c4961a 100644 --- a/crates/gpu/src/effects.rs +++ b/crates/gpu/src/effects.rs @@ -234,7 +234,13 @@ pub(crate) fn box_passes(e: &mut Enc, img: &GpuImage, rx: &[usize], ry: &[usize] let mut p = Params::default(); p.u[0] = [r as u32, repeat as u32, block, 0]; let out = e.scratch(cur.width, cur.height); - e.dispatch(if vertical { "box_v" } else { "box_h" }, &p, &cur, None, &out, None, (lines.div_ceil(64), n.div_ceil(block))); + e.dispatch( + if vertical { "box_v" } else { "box_h" }, + &p, + crate::context::KernelImages { src: &cur, aux: None, out: &out }, + None, + (lines.div_ceil(64), n.div_ceil(block)), + ); cur = out; } cur @@ -390,7 +396,7 @@ fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { * Mat3::translate(vec2(-anchor.0, -anchor.1)); let sampling = if ctx.params.e("sampling") == 1 { Sampling::Bicubic } else { Sampling::Bilinear }; let empty = e.image(b.img.width, b.img.height); - b.img = ops::warp(e, &empty, &b.img, &m, sampling, effectcraft_color::BlendMode::Normal, opacity, 0, None); + b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling, mode: effectcraft_color::BlendMode::Normal, opacity, seed: 0 }, None); Some(b) } diff --git a/crates/gpu/src/fx_generate.rs b/crates/gpu/src/fx_generate.rs index 0089dd4f4..1bb29f3fe 100644 --- a/crates/gpu/src/fx_generate.rs +++ b/crates/gpu/src/fx_generate.rs @@ -165,7 +165,7 @@ fn camera_lens(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { return None; } let pre = e.image(w + 1, h); - e.dispatch("gen_row_prefix", &Params::default(), &src, None, &pre, None, (h.div_ceil(64), 1)); + e.dispatch("gen_row_prefix", &Params::default(), crate::context::KernelImages { src: &src, aux: None, out: &pre }, None, (h.div_ceil(64), 1)); Some(pre) } else { None diff --git a/crates/gpu/src/fx_noise.rs b/crates/gpu/src/fx_noise.rs index b954f8cf3..22f23fd86 100644 --- a/crates/gpu/src/fx_noise.rs +++ b/crates/gpu/src/fx_noise.rs @@ -214,7 +214,7 @@ pub(crate) fn median_image(e: &mut Enc, img: &GpuImage, r: usize, straight: bool let seg = (4 * r as u32).clamp(32, 512).min(q.width); let out = e.scratch(q.width, q.height); let groups = (q.height.div_ceil(e.g.median_kernel.rows_per_workgroup()), q.width.div_ceil(seg)); - e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), &q, None, &out, Some(&levels), groups); + e.dispatch("fxn_median_huang", &with_u0([r as u32, seg, 0, 0]), crate::context::KernelImages { src: &q, aux: None, out: &out }, Some(&levels), groups); out } diff --git a/crates/gpu/src/fx_stylize.rs b/crates/gpu/src/fx_stylize.rs index d53367e76..4b8ae787a 100644 --- a/crates/gpu/src/fx_stylize.rs +++ b/crates/gpu/src/fx_stylize.rs @@ -879,7 +879,7 @@ fn corner_pin(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option { let o = b.to_px([0.0, 0.0]); let m = quad * Mat3::scale(vec2(1.0 / (w * b.scale), 1.0 / (h * b.scale))) * Mat3::translate(vec2(-o.0, -o.1)); let empty = e.image(b.img.width, b.img.height); - b.img = ops::warp(e, &empty, &b.img, &m, Sampling::Bilinear, BlendMode::Normal, 1.0, 0, None); + b.img = ops::warp(e, &empty, &b.img, &m, ops::WarpOptions { sampling: Sampling::Bilinear, mode: BlendMode::Normal, opacity: 1.0, seed: 0 }, None); Some(b) } @@ -989,7 +989,7 @@ pub(crate) fn transform(e: &mut Enc, ctx: &EffectCtx, mut b: GBuf) -> Option GpuImage { let mut p = Params::default(); p.u[0][0] = 256; let out = e.scratch(cur.width, cur.height); - e.dispatch("fxv_box_h", &p, &cur, None, &out, Some(&buf), (cur.height.div_ceil(64), cur.width.div_ceil(256))); + e.dispatch( + "fxv_box_h", + &p, + crate::context::KernelImages { src: &cur, aux: None, out: &out }, + Some(&buf), + (cur.height.div_ceil(64), cur.width.div_ceil(256)), + ); cur = out; } for &r in &radii { @@ -192,7 +198,13 @@ fn sphere_blur(e: &mut Enc, img: &GpuImage, sigma: f64) -> GpuImage { let mut p = Params::default(); p.u[0] = [block, r as u32, 0, 0]; let out = e.scratch(cur.width, cur.height); - e.dispatch("fxv_box_v", &p, &cur, None, &out, None, (cur.width.div_ceil(64), cur.height.div_ceil(block))); + e.dispatch( + "fxv_box_v", + &p, + crate::context::KernelImages { src: &cur, aux: None, out: &out }, + None, + (cur.width.div_ceil(64), cur.height.div_ceil(block)), + ); cur = out; } cur diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index c9e0900e9..30c8c9bea 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -32,37 +32,23 @@ pub fn half(e: &mut Enc, src: &GpuImage) -> GpuImage { out } -/// `raster::composite_warp` (and, with `accumulate = Some(weight)`, `accumulate_warp`): draw -/// `src` transformed by `m` (src pixels → dst pixels) onto `dst`. -pub fn warp( - e: &mut Enc, - dst: &GpuImage, - src: &GpuImage, - m: &Mat3, - sampling: Sampling, - mode: BlendMode, - opacity: f32, - seed: u32, - accumulate: Option, -) -> GpuImage { - warp_q(e, dst, src, m, sampling, mode, opacity, seed, accumulate, None) +/// Sampling and blending policy for a transformed GPU image. +pub struct WarpOptions { + pub sampling: Sampling, + pub mode: BlendMode, + pub opacity: f32, + pub seed: u32, +} + +/// Draw `src` transformed by `m` onto `dst`, or accumulate it with a sample weight. +pub fn warp(e: &mut Enc, dst: &GpuImage, src: &GpuImage, m: &Mat3, opts: WarpOptions, accumulate: Option) -> GpuImage { + warp_q(e, dst, src, m, opts, accumulate, None) } /// [`warp`], clamping and quantising the result to `levels` (8/16 bpc: the comp after a layer, /// in the same pass). -#[allow(clippy::too_many_arguments)] -pub fn warp_q( - e: &mut Enc, - dst: &GpuImage, - src: &GpuImage, - m: &Mat3, - sampling: Sampling, - mode: BlendMode, - opacity: f32, - seed: u32, - accumulate: Option, - levels: Option, -) -> GpuImage { +pub fn warp_q(e: &mut Enc, dst: &GpuImage, src: &GpuImage, m: &Mat3, opts: WarpOptions, accumulate: Option, levels: Option) -> GpuImage { + let WarpOptions { sampling, mode, opacity, seed } = opts; if src.width == 0 || src.height == 0 || opacity <= 0.0 { return dst.clone(); } diff --git a/crates/gpu/src/tests/init.rs b/crates/gpu/src/tests/init.rs index 9c579ae69..2292544cd 100644 --- a/crates/gpu/src/tests/init.rs +++ b/crates/gpu/src/tests/init.rs @@ -2,7 +2,6 @@ //! Set EFFECTCRAFT_REQUIRE_GPU=1 for acceptance: unavailable adapters/devices then fail //! instead of taking the explicitly logged headless-CI skip path. #![cfg(not(target_arch = "wasm32"))] -#![allow(clippy::expect_used)] // Test helpers require successful setup to exercise rejection. use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index b6e78870c..fc39345b8 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -466,12 +466,12 @@ fn place_src( ) -> Option { let pl = r.placement(ctx, layer, buf); if let [m] = pl.matrices.as_slice() { - return Some(ops::warp_q(e, target, &src, m, pl.sampling, mode, opacity, pl.seed, None, levels)); + return Some(ops::warp_q(e, target, &src, m, ops::WarpOptions { sampling: pl.sampling, mode, opacity, seed: pl.seed }, None, levels)); } let mut acc = e.zeros(target.width, target.height); let k = 1.0 / pl.matrices.len() as f32; for m in &pl.matrices { - acc = ops::warp(e, &acc, &src, m, pl.sampling, BlendMode::Normal, 1.0, 0, Some(k)); + acc = ops::warp(e, &acc, &src, m, ops::WarpOptions { sampling: pl.sampling, mode: BlendMode::Normal, opacity: 1.0, seed: 0 }, Some(k)); } Some(ops::blend_full(e, target, &acc, mode, opacity, pl.seed, levels)) } From 5d71dce7fcda7b56f48b1e3b2fdafea9136130f3 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 28/38] Refactor render for strict Clippy checks --- crates/render/src/accel.rs | 8 ++- crates/render/src/audio.rs | 26 ++++---- crates/render/src/cache.rs | 1 - crates/render/src/footage.rs | 2 +- crates/render/src/frames.rs | 2 +- crates/render/src/fx_host.rs | 2 +- crates/render/src/layers.rs | 29 ++++----- crates/render/src/opts.rs | 2 +- crates/render/src/pipeline.rs | 37 ++++++------ crates/render/src/shapes.rs | 18 +++--- crates/render/src/text.rs | 84 ++++++++++++++++---------- crates/render/src/three_d/adv/shade.rs | 15 ++++- crates/render/src/three_d/adv/tests.rs | 6 +- crates/render/src/three_d/compose.rs | 23 +++---- 14 files changed, 141 insertions(+), 114 deletions(-) diff --git a/crates/render/src/accel.rs b/crates/render/src/accel.rs index b26175117..026fe6da1 100644 --- a/crates/render/src/accel.rs +++ b/crates/render/src/accel.rs @@ -2,7 +2,9 @@ use super::frames::content_bounds; use super::fx_host::MAX_FX_DEPTH; use super::layers::StyledLayer; use super::opts::LayerTiming; -use super::*; +use super::{ + Arc, AutoPick, Buf, EffectCtx, EvalCtx, Image, ItemId, ItemKind, Layer, LayerSource, Mat3, MatteKind, Renderer, Tick, cache, color, masks, three_d, vec2, +}; /// Which compositor renders a frame (Project Settings ▸ Video Rendering and Effects). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] @@ -283,7 +285,7 @@ impl<'a> Renderer<'a> { /// layers). pub fn run_effects_on(&self, ctx: &EvalCtx, layer: &Layer, adjustment: bool, target: &mut dyn FxTarget) { // `target` is used as it is: the comp below for adjustment layers (the callers). - self.run_effect_stack(ctx, layer, adjustment, usize::MAX, None, [0.0; 2], target); + self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment, limit: usize::MAX, origin: [0.0; 2] }, None, target); } /// [`Renderer::run_effects_on`] for a (non-adjustment) layer's own pixels: `target` holds @@ -291,7 +293,7 @@ impl<'a> Renderer<'a> { /// there. Returns the effect-space origin in layer coordinates (rebase by its negation). pub fn run_layer_effects_on(&self, ctx: &EvalCtx, layer: &Layer, target: &mut dyn FxTarget) -> [f64; 2] { let origin = ctx.effect_bounds(layer).1; - self.run_effect_stack(ctx, layer, false, usize::MAX, None, origin, target); + self.run_effect_stack(ctx, layer, crate::pipeline::EffectStack { adjustment: false, limit: usize::MAX, origin }, None, target); origin } diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 68be63fe4..66b54b6d0 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -25,7 +25,7 @@ const MAX_DEPTH: usize = 16; /// interleaved (L R L R …). pub fn mix_comp(project: &Project, footage: &dyn FootageSource, expr: Option<&dyn ExprHost>, comp: ItemId, start: Tick, frames: usize, rate: u32) -> Vec { let mut out = vec![0.0f32; frames * 2]; - mix_into(project, footage, expr, comp, start, rate, &mut out, 1.0, 1.0, 0); + mix_into(&AudioSources { project, footage, expr, rate }, comp, start, &mut out, 0); out } @@ -151,7 +151,7 @@ impl MixEnv<'_> { return out; } let mut sub = vec![0.0f32; src_n * 2]; - mix_into(self.project, self.footage, self.expr, *item, lt, rate, &mut sub, 1.0, 1.0, self.depth + 1); + mix_into(&AudioSources { project: self.project, footage: self.footage, expr: self.expr, rate }, *item, lt, &mut sub, self.depth + 1); sub } _ => return out, @@ -206,19 +206,16 @@ impl MixEnv<'_> { } } -#[allow(clippy::too_many_arguments)] -fn mix_into( - project: &Project, - footage: &dyn FootageSource, - expr: Option<&dyn ExprHost>, - comp_id: ItemId, - start: Tick, +/// Shared inputs for composition audio mixing, including nested compositions. +struct AudioSources<'a> { + project: &'a Project, + footage: &'a dyn FootageSource, + expr: Option<&'a dyn ExprHost>, rate: u32, - out: &mut [f32], - gl: f32, - gr: f32, - depth: usize, -) { +} + +fn mix_into(sources: &AudioSources<'_>, comp_id: ItemId, start: Tick, out: &mut [f32], depth: usize) { + let AudioSources { project, footage, expr, rate } = *sources; let Some(comp) = project.comp(comp_id) else { return }; if depth >= MAX_DEPTH || rate == 0 { return; @@ -239,7 +236,6 @@ fn mix_into( } let t_a = sample_tick(s0 + a as i64, rate); let (ll, lr) = levels(&env, l, t_a); - let (ll, lr) = (ll * gl, lr * gr); if ll == 0.0 && lr == 0.0 { continue; } diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index 89e9fd1c9..21f745c0e 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -580,7 +580,6 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, f /// `blur`: the layer is motion blurred in this render (`Renderer::mb_on`). `effects`: key only /// the first that many effects (a layer's input, see [`input_key`]). -#[allow(clippy::too_many_arguments)] fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, footage: bool, effects: Option) -> Option { let mut h = KeyHasher(0xcbf2_9ce4_8422_2325); ctx.comp.frame_rate.as_f64().to_bits().hash(&mut h); diff --git a/crates/render/src/footage.rs b/crates/render/src/footage.rs index e58ef2a57..04dd706db 100644 --- a/crates/render/src/footage.rs +++ b/crates/render/src/footage.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, AtomicBool, Footage, Image, ItemId, Ordering, Tick}; /// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are /// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 19ea785ec..7dd0fc0de 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,6 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::*; +use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, source_size, text}; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / diff --git a/crates/render/src/fx_host.rs b/crates/render/src/fx_host.rs index 6275af5f0..8366cf7c0 100644 --- a/crates/render/src/fx_host.rs +++ b/crates/render/src/fx_host.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, Buf, EffectHost, EvalCtx, ItemKind, Layer, LayerPixels, LayerSource, Params, Renderer, Tick, three_d}; /// Layer parameters and audio for effects (see [`effectcraft_effects::EffectHost`]). What it /// hands out is in effect space (see [`effect_bounds`]): the other layer's for its pixels, the diff --git a/crates/render/src/layers.rs b/crates/render/src/layers.rs index c24c37ff8..cac2add43 100644 --- a/crates/render/src/layers.rs +++ b/crates/render/src/layers.rs @@ -1,6 +1,16 @@ use super::frames::adaptive_samples; use super::opts::{LayerTiming, Through}; -use super::*; +use super::{ + Arc, BlendMode, Buf, Comp, EvalCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, Layer, LayerSource, Mat3, + MatteKind, ParallelIterator, Quality, Region, Renderer, Sampling, Tick, WarpOpts, cache, color, composite_warp_changed, masks, source_size, styles, vec2, +}; + +/// Blend policy for placing a layer, including optional output-depth quantization. +struct PlacementBlend { + mode: BlendMode, + opacity: f32, + quantize: Option, +} /// A motion-blur shutter: the comp's, or for a collapsed precomp's layers the outermost /// containing comp's (they are drawn into its frames). @@ -342,22 +352,13 @@ impl<'a> Renderer<'a> { /// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`. pub(crate) fn place(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, mode: BlendMode, opacity: f32) { - self.place_changed(ctx, layer, buf, target, mode, opacity, None); + self.place_changed(ctx, layer, buf, target, PlacementBlend { mode, opacity, quantize: None }); } /// [`Self::place`], returning the target pixels it changed when the warp tracked them (then /// also quantised to `quantize` levels; see [`composite_warp_changed`]). - #[allow(clippy::too_many_arguments)] - fn place_changed( - &self, - ctx: &EvalCtx, - layer: &Layer, - buf: &Buf, - target: &mut Image, - mode: BlendMode, - opacity: f32, - quantize: Option, - ) -> Option<[u32; 4]> { + fn place_changed(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, target: &mut Image, blend: PlacementBlend) -> Option<[u32; 4]> { + let PlacementBlend { mode, opacity, quantize } = blend; let pl = self.placement(ctx, layer, buf); let opts = WarpOpts { sampling: pl.sampling, opacity, mode, seed: pl.seed, clip: None }; if let [m] = pl.matrices.as_slice() { @@ -459,7 +460,7 @@ impl<'a> Renderer<'a> { fn composite_layer(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf, canvas: &mut Image, opacity: f32, quantize: Option) -> Option<[u32; 4]> { let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); if !matte && !layer.preserve_transparency { - return self.place_changed(ctx, layer, buf, canvas, layer.blend_mode, opacity, quantize); + return self.place_changed(ctx, layer, buf, canvas, PlacementBlend { mode: layer.blend_mode, opacity, quantize }); } // Render in isolation, then matte / preserve transparency, then blend. let mut iso = Image::new(canvas.width, canvas.height); diff --git a/crates/render/src/opts.rs b/crates/render/src/opts.rs index 9d0b2a39d..c6175bf39 100644 --- a/crates/render/src/opts.rs +++ b/crates/render/src/opts.rs @@ -1,5 +1,5 @@ use super::accel::Backend; -use super::*; +use super::{Comp, EvalCtx, ItemId, Layer, Mat4, Tick, three_d}; #[derive(Clone, Copy, Debug, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 92b5b7f5c..bc1879369 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -3,7 +3,18 @@ use super::footage::{FootageSource, is_vector_footage}; use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; -use super::*; +use super::{ + Arc, AutoKey, AutoPick, BlendMode, Buf, Comp, EffectCtx, EffectEnv, EvalCtx, Footage, FootageKind, FrameBlend, FrameRate, GroupKind, Image, ItemId, + ItemKind, Layer, LayerCache, LayerSource, Params, Project, Quality, Region, Renderer, Tick, cache, color, masks, shapes, styles, text, three_d, vec2, +}; + +/// Coordinate space and stack range shared by CPU and accelerated effect evaluation. +#[derive(Clone, Copy)] +pub(crate) struct EffectStack { + pub adjustment: bool, + pub limit: usize, + pub origin: [f64; 2], +} impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { @@ -423,26 +434,16 @@ impl<'a> Renderer<'a> { // The comp below an adjustment layer is not in its layer space: it stays as it is. let origin = if adjustment { [0.0; 2] } else { ctx.effect_bounds(layer).1 }; buf.rebase(origin); - let mut out = self.effect_space_stack(ctx, layer, buf, adjustment, limit, timing, origin); + let mut out = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment, limit, origin }, timing); out.rebase([-origin[0], -origin[1]]); out } /// [`Renderer::apply_effects_timed`] on a buffer already in effect space (measured from /// `origin`, in layer coordinates); the result stays in effect space. - #[allow(clippy::too_many_arguments)] - fn effect_space_stack( - &self, - ctx: &EvalCtx, - layer: &Layer, - buf: Buf, - adjustment: bool, - limit: usize, - timing: Option<&mut Vec<(String, f64)>>, - origin: [f64; 2], - ) -> Buf { + fn effect_space_stack(&self, ctx: &EvalCtx, layer: &Layer, buf: Buf, stack: EffectStack, timing: Option<&mut Vec<(String, f64)>>) -> Buf { let mut t = CpuFx { accel: self.active_accel(), levels: self.pipe.levels, buf: Some(buf) }; - self.run_effect_stack(ctx, layer, adjustment, limit, timing, origin, &mut t); + self.run_effect_stack(ctx, layer, stack, timing, &mut t); t.buf.unwrap_or_else(|| Buf { img: Image::new(1, 1), offset: [0.0; 2], scale: self.opts.scale }) } @@ -450,17 +451,15 @@ impl<'a> Renderer<'a> { /// in layer coordinates): runs of GPU-capable effects go to [`FxTarget::gpu`] (when an /// accelerator is active and supports them), the rest to [`FxTarget::cpu`] one by one /// (quantised to the bit depth after each). - #[allow(clippy::too_many_arguments)] pub(crate) fn run_effect_stack( &self, ctx: &EvalCtx, layer: &Layer, - adjustment: bool, - limit: usize, + stack: EffectStack, mut timing: Option<&mut Vec<(String, f64)>>, - origin: [f64; 2], target: &mut dyn FxTarget, ) { + let EffectStack { adjustment, limit, origin } = stack; if !layer.switches.effects { return; } @@ -873,7 +872,7 @@ impl<'a> Renderer<'a> { let origin = ctx.effect_bounds(layer).1; buf.rebase(origin); if effects > 0 { - buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin); + buf = self.effect_space_stack(ctx, layer, buf, EffectStack { adjustment: false, limit: effects, origin }, None); } let buf = Arc::new(buf); // The buffer the cache keeps (another worker's, if it kept one first). diff --git a/crates/render/src/shapes.rs b/crates/render/src/shapes.rs index f8de5a290..a4e4077f8 100644 --- a/crates/render/src/shapes.rs +++ b/crates/render/src/shapes.rs @@ -211,16 +211,16 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec { let star = ctx.e(layer, g, "type") == 0; - let p = effectcraft_path::polystar( + let p = effectcraft_path::polystar(effectcraft_path::Polystar { star, - ctx.f(layer, g, "points", 5.0), - ctx.v2(layer, g, "position", [0.0; 2]), - ctx.f(layer, g, "rotation", 0.0), - ctx.f(layer, g, "innerRadius", 50.0), - ctx.f(layer, g, "outerRadius", 100.0), - ctx.f(layer, g, "innerRoundness", 0.0), - ctx.f(layer, g, "outerRoundness", 0.0), - ); + points: ctx.f(layer, g, "points", 5.0), + pos: ctx.v2(layer, g, "position", [0.0; 2]), + rotation: ctx.f(layer, g, "rotation", 0.0), + inner: ctx.f(layer, g, "innerRadius", 50.0), + outer: ctx.f(layer, g, "outerRadius", 100.0), + inner_round: ctx.f(layer, g, "innerRoundness", 0.0), + outer_round: ctx.f(layer, g, "outerRoundness", 0.0), + }); push_shape(ctx, layer, g, p, arena, &mut live); } "path" => { diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 496bc3a60..e1954b597 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -287,11 +287,13 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex let pos = get("position").map(|v| c3(v, 0.0)); let anchor = get("anchor").map(|v| c3(v, 0.0)); let scale = get("scale").map(|v| c3(v, 100.0)); - let rot = get1("rotation"); - let rx = get1("rotationX"); - let ry = get1("rotationY"); - let skew = get1("skew"); - let skew_axis = get1("skewAxis"); + let angles = CharacterAngles { + rotation: get1("rotation"), + rotation_x: get1("rotationX"), + rotation_y: get1("rotationY"), + skew: get1("skew"), + skew_axis: get1("skewAxis"), + }; let op = get1("opacity"); let fill = props.get("fillColor").map(|p| ctx.value(layer, p).as_color()); let stroke = props.get("strokeColor").map(|p| ctx.value(layer, p).as_color()); @@ -338,21 +340,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex c.scale[d] *= 1.0 + (s[d] / 100.0 - 1.0) * k3[d]; } } - if let Some(r) = rot { - c.rotation += r * k; - } - if let Some(r) = rx { - c.rotation_x += r * k; - } - if let Some(r) = ry { - c.rotation_y += r * k; - } - if let Some(s) = skew { - c.skew += s * k; - } - if let Some(a) = skew_axis { - c.skew_axis = a; - } + angles.apply(c, k); if let Some(o) = op { c.opacity *= 1.0 + (o / 100.0 - 1.0) * k; } @@ -381,17 +369,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex if let Some(w) = sw { c.stroke_width += w * k; } - if let Some(t) = tracking { - let t = t * k; - match tracking_type { - 1 => c.track_before += t, - 2 => c.track_after += t, - _ => { - c.track_before += t * 0.5; - c.track_after += t * 0.5; - } - } - } + apply_tracking(c, tracking, tracking_type, k); if let Some(a) = line_anchor && k.abs() >= c.line_anchor_k { @@ -432,6 +410,50 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex out } +/// Optional animator orientation properties; an omitted property leaves the glyph unchanged. +struct CharacterAngles { + rotation: Option, + rotation_x: Option, + rotation_y: Option, + skew: Option, + skew_axis: Option, +} + +impl CharacterAngles { + fn apply(&self, c: &mut CharXf, k: f64) { + if let Some(r) = self.rotation { + c.rotation += r * k; + } + if let Some(r) = self.rotation_x { + c.rotation_x += r * k; + } + if let Some(r) = self.rotation_y { + c.rotation_y += r * k; + } + if let Some(s) = self.skew { + c.skew += s * k; + } + if let Some(a) = self.skew_axis { + c.skew_axis = a; + } + } +} + +/// Distribute selected tracking before and/or after a glyph according to Tracking Type. +fn apply_tracking(c: &mut CharXf, tracking: Option, tracking_type: u32, k: f64) { + if let Some(t) = tracking { + let t = t * k; + match tracking_type { + 1 => c.track_before += t, + 2 => c.track_after += t, + _ => { + c.track_before += t * 0.5; + c.track_after += t * 0.5; + } + } + } +} + /// Character Offset / Character Value substitution (None = unchanged). pub fn substitute(ch: char, x: &CharXf) -> Option { if ch.is_whitespace() { diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs index 9acc852fe..0ebd0b1ac 100644 --- a/crates/render/src/three_d/adv/shade.rs +++ b/crates/render/src/three_d/adv/shade.rs @@ -82,9 +82,20 @@ pub fn f_schlick(f0: [f32; 3], v_h: f32) -> [f32; 3] { [f0[0] + (1.0 - f0[0]) * k, f0[1] + (1.0 - f0[1]) * k, f0[2] + (1.0 - f0[2]) * k] } +/// Surface parameters used by the direct-light BRDF after texture sampling. +#[derive(Clone, Copy)] +pub struct BrdfSurface { + pub base: [f32; 3], + pub metallic: f32, + pub roughness: f32, + pub specular: f32, + pub diffuse: f32, +} + /// Cook–Torrance BRDF × N·L × π for one light: (diffuse colour factor, specular colour). /// Returned as the reflected fraction of the light's irradiance. -pub fn brdf(base: [f32; 3], metallic: f32, rough: f32, spec_k: f32, diff_k: f32, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] { +pub fn brdf(surface: BrdfSurface, n: [f32; 3], v: [f32; 3], l: [f32; 3]) -> [f32; 3] { + let BrdfSurface { base, metallic, roughness: rough, specular: spec_k, diffuse: diff_k } = surface; let n_l = dot(n, l); if n_l <= 0.0 { return [0.0; 3]; @@ -484,7 +495,7 @@ pub fn shade(s: &Scene, f: &Frag) -> Option<[f32; 4]> { continue; } let sh = if m.receives_shadows { light_shadow(s, l, f.pos, ng, n_l) } else { 1.0 }; - let b = brdf(rgb, metallic, rough, m.specular_k, m.diffuse_k, n, v, to_l); + let b = brdf(BrdfSurface { base: rgb, metallic, roughness: rough, specular: m.specular_k, diffuse: m.diffuse_k }, n, v, to_l); for c in 0..3 { out[c] += b[c] * l.color[c] * k * sh; } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 3f5ee4813..f9a0f2066 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -16,7 +16,7 @@ fn brdf_matches_cook_torrance_formula() { let v = shade::norm([0.3, 0.1, 1.0]); let l = shade::norm([-0.4, 0.2, 1.0]); let (base, metal, rough) = ([0.8, 0.4, 0.2], 0.3f32, 0.45f32); - let got = shade::brdf(base, metal, rough, 1.0, 1.0, n, v, l); + let got = shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, l); let h = shade::norm([v[0] + l[0], v[1] + l[1], v[2] + l[2]]); let (nv, nl, nh, vh) = (shade::dot(n, v), shade::dot(n, l), shade::dot(n, h), shade::dot(v, h)); let a2 = (rough * rough).powi(2); @@ -30,9 +30,9 @@ fn brdf_matches_cook_torrance_formula() { assert!((got[c] - expect).abs() < 1e-5, "channel {c}: {} vs {expect}", got[c]); } // No light from behind the surface. - assert_eq!(shade::brdf(base, metal, rough, 1.0, 1.0, n, v, [0.0, 0.0, -1.0]), [0.0; 3]); + assert_eq!(shade::brdf(shade::BrdfSurface { base, metallic: metal, roughness: rough, specular: 1.0, diffuse: 1.0 }, n, v, [0.0, 0.0, -1.0]), [0.0; 3]); // A white rough dielectric lit head-on reflects about its base colour (π folded in). - let w = shade::brdf([1.0; 3], 0.0, 1.0, 1.0, 1.0, n, n, n); + let w = shade::brdf(shade::BrdfSurface { base: [1.0; 3], metallic: 0.0, roughness: 1.0, specular: 1.0, diffuse: 1.0 }, n, n, n); assert!(w[0] > 0.95 && w[0] < 1.05, "{w:?}"); } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 929b48d45..8e932819a 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -197,7 +197,7 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { let buf = r.blend_layer_buf(ctx, layer)?; - prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY) + prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf, local: Mat4::IDENTITY }) } /// Prepare a layer as planes: one per character for per-character 3D text, else one. @@ -205,24 +205,21 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m)) + .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, PlaneInput { buf: r.to_blend(Arc::new(buf), None), local: m })) .collect(); } prepare(r, ctx, layer, order, out, in_run).into_iter().collect() } -/// Prepare a plane showing `buf`, whose space maps into the layer through `local`. -#[allow(clippy::too_many_arguments)] -fn prepare_with<'a>( - r: &Renderer, - ctx: &EvalCtx<'a>, - layer: &'a Layer, - order: usize, - out: (u32, u32), - in_run: bool, - mut buf: Arc, +/// A processed plane and its mapping into the layer's coordinate system. +struct PlaneInput { + buf: Arc, local: Mat4, -) -> Option> { +} + +/// Prepare the plane's geometry, motion samples and material. +fn prepare_with<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool, plane: PlaneInput) -> Option> { + let PlaneInput { mut buf, local } = plane; if buf.img.is_empty() { return None; } From d01871516a419579432708c72699ea022fe8caf7 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 29/38] Refactor effects for strict Clippy checks --- crates/effects/src/generate2.rs | 41 ++++++++++++++++-------- crates/effects/src/generate3/audio.rs | 2 +- crates/effects/src/generate3/common.rs | 2 +- crates/effects/src/generate3/fill.rs | 5 ++- crates/effects/src/generate3/fractal.rs | 2 +- crates/effects/src/generate3/paths.rs | 5 ++- crates/effects/src/generate3/raster.rs | 3 +- crates/effects/src/generate3/registry.rs | 2 +- crates/effects/src/object_matte.rs | 37 ++++++++++++++------- crates/effects/src/ocio/cdl.rs | 2 +- crates/effects/src/ocio/effects.rs | 2 +- crates/effects/src/ocio/lut.rs | 2 +- crates/effects/src/ocio/program.rs | 2 +- crates/effects/src/ocio/registry.rs | 2 +- crates/effects/src/ocio/space.rs | 2 +- crates/effects/src/roto.rs | 2 +- crates/effects/src/sim/common.rs | 2 +- crates/effects/src/sim/engine.rs | 5 +-- crates/effects/src/sim/particle_fx.rs | 6 ++-- crates/effects/src/sim/registry.rs | 2 +- crates/effects/src/sim/sprites.rs | 2 +- crates/effects/src/sim/weather.rs | 2 +- crates/effects/src/sim2/foam.rs | 5 ++- crates/effects/src/sim2/pieces.rs | 2 +- crates/effects/src/sim2/pixels.rs | 2 +- crates/effects/src/sim2/registry.rs | 2 +- crates/effects/src/sim2/shatter.rs | 5 ++- crates/effects/src/sim2/surfaces.rs | 2 +- crates/effects/src/sim3.rs | 5 +-- crates/effects/src/warp_stab.rs | 25 +++++++-------- 30 files changed, 109 insertions(+), 71 deletions(-) diff --git a/crates/effects/src/generate2.rs b/crates/effects/src/generate2.rs index 62bce1706..b4781b793 100644 --- a/crates/effects/src/generate2.rs +++ b/crates/effects/src/generate2.rs @@ -692,18 +692,33 @@ impl BoltCfg<'_> { } } -/// Grow a bolt from `a` to `z` by midpoint displacement (`depth` levels), forking at random. -/// Segments go to `out` with their intensity; `mains` marks the main core's segments. -#[allow(clippy::too_many_arguments)] -fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, main: bool, rng: &mut Rng, out: &mut Vec, mains: &mut Vec) { - if out.len() > 20_000 { +/// One branch's geometry and recursive growth state. +struct BoltBranch { + start: (f64, f64), + end: (f64, f64), + depth: u32, + intensity: f32, + main: bool, +} + +/// Segments and their matching main-core flags, kept in the same traversal order. +#[derive(Default)] +struct BoltOutput { + segments: Vec, + mains: Vec, +} + +/// Grow a branch by midpoint displacement, recursively appending its segments and forks. +fn bolt(cfg: &BoltCfg, branch: BoltBranch, rng: &mut Rng, output: &mut BoltOutput) { + let BoltBranch { start: a, end: z, depth, intensity: inten, main } = branch; + if output.segments.len() > 20_000 { return; } let (dx, dy) = (z.0 - a.0, z.1 - a.1); let len = (dx * dx + dy * dy).sqrt(); if depth == 0 || len < 1.0 { - out.push((a, z, inten)); - mains.push(main); + output.segments.push((a, z, inten)); + output.mains.push(main); return; } let (nx, ny) = (-dy / len.max(1e-9), dx / len.max(1e-9)); @@ -725,8 +740,8 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai if cfg.obstacle > 0.0 && !main && cfg.alpha_at(m) > 0.5 && rng.f() < cfg.obstacle / 10.0 { return; } - bolt(cfg, a, m, depth - 1, inten, main, rng, out, mains); - bolt(cfg, m, z, depth - 1, inten, main, rng, out, mains); + bolt(cfg, BoltBranch { start: a, end: m, depth: depth - 1, intensity: inten, main }, rng, output); + bolt(cfg, BoltBranch { start: m, end: z, depth: depth - 1, intensity: inten, main }, rng, output); if rng.f() < cfg.fork && inten > cfg.term && len >= cfg.min_fork { let ang = rng.s() * 0.7; let (s, c) = ang.sin_cos(); @@ -734,7 +749,7 @@ fn bolt(cfg: &BoltCfg, a: (f64, f64), z: (f64, f64), depth: u32, inten: f32, mai let k = 1.2; // Fork Strength 50 % keeps the decayed intensity; Fork Variation randomises it. let fi = inten * (1.0 - cfg.decay) * cfg.fork_strength * 2.0 * if cfg.fork_var > 0.0 { 1.0 - cfg.fork_var * rng.f() as f32 } else { 1.0 }; - bolt(cfg, m, (m.0 + fx * k, m.1 + fy * k), depth.saturating_sub(1), fi.min(1.0), false, rng, out, mains); + bolt(cfg, BoltBranch { start: m, end: (m.0 + fx * k, m.1 + fy * k), depth: depth.saturating_sub(1), intensity: fi.min(1.0), main: false }, rng, output); } } @@ -772,10 +787,9 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { fork_strength: (g("expertSettings/forkStrength", 50.0) / 100.0).clamp(0.0, 1.0) as f32, fork_var: (g("expertSettings/forkVariation", 0.0) / 100.0).clamp(0.0, 1.0) as f32, }; - let mut segs = Vec::new(); - let mut mains = Vec::new(); + let mut output = BoltOutput::default(); let (h, w) = (b.img.height as f64, b.img.width as f64); - let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, a, z, depth, 1.0, true, rng, &mut segs, &mut mains); + let mut go = |a, z, depth, cfg: &BoltCfg, rng: &mut Rng| bolt(cfg, BoltBranch { start: a, end: z, depth, intensity: 1.0, main: true }, rng, &mut output); // LIGHTNING_TYPES order. match kind { // Direction: travels toward the Direction point and beyond. @@ -811,6 +825,7 @@ pub fn lightning_segments(ctx: &EffectCtx, b: &Buf) -> Vec { // Strike. _ => go(o, d, depth, &cfg, &mut rng), } + let BoltOutput { segments: mut segs, mains } = output; let dist = |p: (f64, f64)| ((p.0 - o.0).powi(2) + (p.1 - o.1).powi(2)).sqrt(); let far = segs.iter().map(|(_, z, _)| dist(*z)).fold(1e-9, f64::max); // Decay Main Core: the whole bolt fades with distance from the origin. diff --git a/crates/effects/src/generate3/audio.rs b/crates/effects/src/generate3/audio.rs index f1ac54db8..6c8d66e4a 100644 --- a/crates/effects/src/generate3/audio.rs +++ b/crates/effects/src/generate3/audio.rs @@ -1,5 +1,5 @@ use super::raster::{Seg, mask_px, over, poly_segs, raster_segs}; -use super::*; +use super::{Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefMutIterator, PI, ParallelIterator, poly_length, premul}; // ---------------------------------------------------------------- Audio Spectrum / Waveform diff --git a/crates/effects/src/generate3/common.rs b/crates/effects/src/generate3/common.rs index b996cdc99..7e341ad8d 100644 --- a/crates/effects/src/generate3/common.rs +++ b/crates/effects/src/generate3/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectSpec, ParamUi, Value, slider}; pub(super) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Generate", params, render, gpu: false, float: true } diff --git a/crates/effects/src/generate3/fill.rs b/crates/effects/src/generate3/fill.rs index a86f123ee..5df782a89 100644 --- a/crates/effects/src/generate3/fill.rs +++ b/crates/effects/src/generate3/fill.rs @@ -1,5 +1,8 @@ use super::raster::over; -use super::*; +use super::{ + Buf, EffectCtx, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, PI, ParallelIterator, Plane, Px, gauss_plane, hash1, + hsl_to_rgb, morph_frac, premul, rgb_to_hsl, smoothstep, unpremul, +}; pub(super) fn glue_gun(ctx: &EffectCtx, mut b: Buf) -> Buf { let pr = ctx.params; diff --git a/crates/effects/src/generate3/fractal.rs b/crates/effects/src/generate3/fractal.rs index 519bec640..05a76d5f0 100644 --- a/crates/effects/src/generate3/fractal.rs +++ b/crates/effects/src/generate3/fractal.rs @@ -1,5 +1,5 @@ use super::raster::over; -use super::*; +use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Px, hsl_to_rgb}; // ---------------------------------------------------------------- Fractal diff --git a/crates/effects/src/generate3/paths.rs b/crates/effects/src/generate3/paths.rs index 779892cc7..319368a82 100644 --- a/crates/effects/src/generate3/paths.rs +++ b/crates/effects/src/generate3/paths.rs @@ -1,5 +1,8 @@ use super::raster::{Seg, all_masks_px, chaikin, mask_px, over, poly_segs, raster_convex, raster_segs, scan_runs, stroke_outline, trim_poly}; -use super::*; +use super::{ + Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Params, Plane, gauss_plane, hash1, + layer_or_self, luminance, poly_length, smoothstep, unpremul, +}; /// How painted coverage meets the layer: 0 On Original Image, 1 On Transparent, 2 Reveal /// Original Image. diff --git a/crates/effects/src/generate3/raster.rs b/crates/effects/src/generate3/raster.rs index f9acd88a3..1b85818d0 100644 --- a/crates/effects/src/generate3/raster.rs +++ b/crates/effects/src/generate3/raster.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Buf, EffectCtx, IndexedParallelIterator, ParallelIterator, ParallelSliceMut, Plane, Px, smoothstep}; // ---------------------------------------------------------------- shared rasterisation @@ -147,7 +147,6 @@ pub(super) fn chaikin(pts: &[[f64; 2]], iters: usize) -> Vec<[f64; 2]> { // ---------------------------------------------------------------- Scribble /// Scan runs (start, end along direction `d`) of the fill region on the line `n·p = s`. -#[allow(clippy::too_many_arguments)] pub(super) fn scan_runs(polys: &[(Vec<[f64; 2]>, bool, bool)], fill: u32, ew: f64, d: [f64; 2], n: [f64; 2], s: f64, out: &mut Vec<(f64, f64)>) { out.clear(); for (pts, _closed, inverted) in polys { diff --git a/crates/effects/src/generate3/registry.rs b/crates/effects/src/generate3/registry.rs index f7f6b040f..06c2b35fb 100644 --- a/crates/effects/src/generate3/registry.rs +++ b/crates/effects/src/generate3/registry.rs @@ -4,7 +4,7 @@ use super::fill::{eyedropper_fill, glue_gun, paint_bucket, threads}; use super::fractal::{FRACTAL_OVERSAMPLE, FRACTAL_PALETTES, FRACTAL_SETS, fractal}; use super::paths::{scribble, stroke, vegas, write_on}; use super::raster::{END_CAPS, JOINS}; -use super::*; +use super::{EffectSpec, ParamUi, Value, col, num, p, popup, slider}; // ---------------------------------------------------------------- registry diff --git a/crates/effects/src/object_matte.rs b/crates/effects/src/object_matte.rs index d98ec73ad..e61357c9f 100644 --- a/crates/effects/src/object_matte.rs +++ b/crates/effects/src/object_matte.rs @@ -358,20 +358,24 @@ pub enum FrameMatte { Outside, } -/// The matte of layer frame `f` on the layer grid of `img` (the effect's input on that frame at -/// `scale`), computing what is missing. `frame(g)`: the input on frame g on the same grid. -#[allow(clippy::too_many_arguments)] +/// The layer grid and timing used to compute one object matte. +pub struct MatteFrame<'a> { + pub layer_size: [f64; 2], + pub fps: f64, + pub frame: i64, + pub scale: f64, + pub img: &'a Image, +} + +/// Compute the object's alpha for one layer frame, loading other frames on the same grid as needed. pub fn frame_matte( params: &Params, - layer_size: [f64; 2], - fps: f64, - f: i64, - scale: f64, - img: &Image, + input: &MatteFrame<'_>, model: Option>, frame: &mut dyn FnMut(i64) -> Option>, cancel: &dyn Fn() -> bool, ) -> FrameMatte { + let MatteFrame { layer_size, fps, frame: f, scale, img } = *input; let Some(chain) = chain(params, layer_size, fps, f, scale, model) else { return FrameMatte::NoPrompt }; if !chain.data.in_span(f) { return FrameMatte::Outside; @@ -408,7 +412,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { if let Some(a) = frozen_alpha(ctx.params, layer_size, fps, f, w, h, model.as_deref()) { return compose(&buf, Some(&a), w, h, b(ctx.params, "invert"), param(ctx.params, "view").map(Value::as_enum).unwrap_or(0) == 1); } - let alpha = match frame_matte(ctx.params, layer_size, fps, f, sc, &img, model, &mut frame, &|| false) { + let alpha = match frame_matte(ctx.params, &MatteFrame { layer_size, fps, frame: f, scale: sc, img: &img }, model, &mut frame, &|| false) { FrameMatte::Alpha(a) if a.len() == w * h => Some(a), FrameMatte::NoPrompt => return buf, _ => None, @@ -608,7 +612,11 @@ mod tests { assert_ne!(seed(&p, [100.0, 80.0], 25.0, Some(stub.as_ref())), seed(&p, [100.0, 80.0], 25.0, None)); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert!(a[40 * 100 + 40] > 0.5 && a[5 * 100 + 5] < 0.5); assert_eq!(stub.calls.load(std::sync::atomic::Ordering::Relaxed), 1); assert_eq!(*stub.bbox.lock().unwrap(), None); @@ -634,7 +642,11 @@ mod tests { let stub = Arc::new(Stub::default()); let img = frame_at(0); let mut frame = |g: i64| Some(Arc::new(frame_at(g))); - let FrameMatte::Alpha(a) = frame_matte(&p, [100.0, 80.0], 25.0, 0, 1.0, &img, Some(stub.clone()), &mut frame, &|| false) else { panic!("no matte") }; + let FrameMatte::Alpha(a) = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 1.0, img: &img }, Some(stub.clone()), &mut frame, &|| false) + else { + panic!("no matte") + }; assert_eq!(*stub.bbox.lock().unwrap(), Some([74.0, 54.0, 100.0, 80.0])); assert!(a[70 * 100 + 90] > 0.5 && a[40 * 100 + 40] < 0.5); // At half resolution the box is scaled with the frame (another Input Key, so the @@ -643,7 +655,8 @@ mod tests { p.values.insert(INPUT_KEY.into(), Value::Str("half".into())); let half = effectcraft_raster::resample(&img, 50, 40); let mut frame = |g: i64| Some(Arc::new(effectcraft_raster::resample(&frame_at(g), 50, 40))); - let _ = frame_matte(&p, [100.0, 80.0], 25.0, 0, 0.5, &half, Some(stub.clone()), &mut frame, &|| false); + let _ = + frame_matte(&p, &MatteFrame { layer_size: [100.0, 80.0], fps: 25.0, frame: 0, scale: 0.5, img: &half }, Some(stub.clone()), &mut frame, &|| false); assert_eq!(*stub.bbox.lock().unwrap(), Some([37.0, 27.0, 50.0, 40.0])); } diff --git a/crates/effects/src/ocio/cdl.rs b/crates/effects/src/ocio/cdl.rs index 1feab04a2..dba8c68fe 100644 --- a/crates/effects/src/ocio/cdl.rs +++ b/crates/effects/src/ocio/cdl.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ParamUi, slider}; // ---------------------------------------------------------------- ASC CDL diff --git a/crates/effects/src/ocio/effects.rs b/crates/effects/src/ocio/effects.rs index 7d07011a4..5df24096d 100644 --- a/crates/effects/src/ocio/effects.rs +++ b/crates/effects/src/ocio/effects.rs @@ -1,7 +1,7 @@ use super::cdl::Cdl; use super::lut::load_file; use super::space::{ACES_CCT, ACES_CG, ACES2065_1, D65, IDENTITY, P3, REC709, REC2020, SPACES, Space, Tf, Xform, matrix, sp, space_names}; -use super::*; +use super::{Buf, ColorSpace, EffectCtx, EffectSpec, IntoParallelRefMutIterator, ParallelIterator, Value, invert, mul, mul_vec, p, popup, unpremul}; // ---------------------------------------------------------------- displays, views and looks diff --git a/crates/effects/src/ocio/lut.rs b/crates/effects/src/ocio/lut.rs index ff4cbd935..8a6481eb6 100644 --- a/crates/effects/src/ocio/lut.rs +++ b/crates/effects/src/ocio/lut.rs @@ -1,5 +1,5 @@ use super::cdl::{Cdl, parse_cdl_xml}; -use super::*; +use super::{Arc, HashMap, Lut, Mutex, OnceLock, parse_cube}; // ---------------------------------------------------------------- LUT files diff --git a/crates/effects/src/ocio/program.rs b/crates/effects/src/ocio/program.rs index ab9c4ea78..a223dc7b4 100644 --- a/crates/effects/src/ocio/program.rs +++ b/crates/effects/src/ocio/program.rs @@ -2,7 +2,7 @@ use super::cdl::Cdl; use super::effects::{DISPLAYS, cdl_from_params, custom_xf, looks, parse_looks, profile, space_at}; use super::lut::{FileXform, load_file}; use super::space::{ACES_CCT, D65, IDENTITY, Space, Tf, Xform, matrix}; -use super::*; +use super::{Arc, EffectCtx, Lut, invert, mul}; // ---- Colour programs (the GPU compositor's colour-management kernels) diff --git a/crates/effects/src/ocio/registry.rs b/crates/effects/src/ocio/registry.rs index b020b053b..cd3bd02a7 100644 --- a/crates/effects/src/ocio/registry.rs +++ b/crates/effects/src/ocio/registry.rs @@ -4,7 +4,7 @@ use super::effects::{ space_param, spec, }; use super::space::SPACES; -use super::*; +use super::{EffectSpec, ParamUi, Value, num, p, popup, slider}; pub fn specs() -> Vec { let cc = "Color Correction"; diff --git a/crates/effects/src/ocio/space.rs b/crates/effects/src/ocio/space.rs index 44fcf84d0..1a1d8f69b 100644 --- a/crates/effects/src/ocio/space.rs +++ b/crates/effects/src/ocio/space.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{invert, mul, mul_vec, rgb_to_xyz}; pub(super) type M3 = [[f64; 3]; 3]; diff --git a/crates/effects/src/roto.rs b/crates/effects/src/roto.rs index 4917cc231..2467a719d 100644 --- a/crates/effects/src/roto.rs +++ b/crates/effects/src/roto.rs @@ -634,7 +634,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { let (w, h) = (img.width as usize, img.height as usize); let mp = matte_params(ctx.params); let mut rgb = rb::rgb_of(&img); - let map = refine::decontaminate(&mut rgb, &alpha, &band, w, h, &mp, rr * sc, sc); + let map = refine::decontaminate(&mut rgb, &alpha, &band, (w, h), &mp, rr * sc, sc); let view_map = b(ctx.params, "refineEdgeMatte/decontamination/viewDecontaminationMap"); let (ox, oy) = (buf.offset[0].round() as i64, buf.offset[1].round() as i64); for y in 0..h { diff --git a/crates/effects/src/sim/common.rs b/crates/effects/src/sim/common.rs index c11c3f886..294d787f1 100644 --- a/crates/effects/src/sim/common.rs +++ b/crates/effects/src/sim/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectSpec, ParamUi, Value, hash1, slider}; pub(crate) fn spec(id: &'static str, name: &'static str, params: Vec, render: crate::RenderFn) -> EffectSpec { EffectSpec { id, name, category: "Simulation", params, render, gpu: false, float: true } diff --git a/crates/effects/src/sim/engine.rs b/crates/effects/src/sim/engine.rs index 973b7b5e8..b379636ce 100644 --- a/crates/effects/src/sim/engine.rs +++ b/crates/effects/src/sim/engine.rs @@ -1,7 +1,7 @@ use super::common::{h, hs}; use super::particle_fx::{PS2_CACHE, PW_CACHE, ps2_phys, pw_phys}; use super::sprites::{Shape, Sprite}; -use super::*; +use super::{Buf, EffectCtx, Image, IntoParallelRefMutIterator, ParallelIterator, SimCache, params_key_except, value_noise}; // ------------------------------------------------------------------ particle engine @@ -263,7 +263,8 @@ pub(super) fn particle_look(q: &Particle, birth: [f32; 4], death: [f32; 4], bsiz (c, size.max(0.0)) } -pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], vx: f32, vy: f32, rot: f32) -> Sprite { +pub(super) fn type_sprite(kind: u32, x: f32, y: f32, r: f32, c: [f32; 4], velocity: [f32; 2], rot: f32) -> Sprite { + let [vx, vy] = velocity; let vl = (vx * vx + vy * vy).sqrt().max(1e-6); let shape = match kind { 0 => Shape::Line { dx: -vx / vl * r * 2.0, dy: -vy / vl * r * 2.0 }, diff --git a/crates/effects/src/sim/particle_fx.rs b/crates/effects/src/sim/particle_fx.rs index f8af6cba0..6b6c27b01 100644 --- a/crates/effects/src/sim/particle_fx.rs +++ b/crates/effects/src/sim/particle_fx.rs @@ -1,6 +1,6 @@ use super::engine::{PState, PW_EFFECT_CAMERA, Phys, accelerated, norm3, particle_look, sim_key, simulate, type_sprite}; use super::sprites::{Acc, Post, Sprite, SpritePlan, raster, splat}; -use super::*; +use super::{Buf, EffectCtx, Image, IntoParallelRefIterator, ParallelIterator, SimCache, Value, params_key, unpremul}; // ------------------------------------------------------------------ CC Particle World @@ -169,7 +169,7 @@ pub(super) fn particle_world_plan(ctx: &EffectCtx, b: &Buf) -> Option SpritePlan { let (bx, by) = b.to_px([(q.p[0] * unit) as f64, (q.p[1] * unit) as f64]); let (c, size) = particle_look(q, birth, death, bs, ds, svar, max_op); let r = size * unit * 0.02 * s; - type_sprite(kind, bx as f32, by as f32, r, c, q.v[0] * unit * s, q.v[1] * unit * s, (q.id % 628) as f32 * 0.01 + q.age * 2.0) + type_sprite(kind, bx as f32, by as f32, r, c, [q.v[0] * unit * s, q.v[1] * unit * s], (q.id % 628) as f32 * 0.01 + q.age * 2.0) }) .collect(); particle_sprites(sprites, pr.e("particle/transferMode")) diff --git a/crates/effects/src/sim/registry.rs b/crates/effects/src/sim/registry.rs index a688f1046..f29eb2653 100644 --- a/crates/effects/src/sim/registry.rs +++ b/crates/effects/src/sim/registry.rs @@ -3,7 +3,7 @@ use super::engine::{PARTICLE_TYPES, PW_ANIMS}; use super::particle_fx::{PW_FOV, mr_mercury, particle_systems2, particle_systems2_plan, particle_world, particle_world_plan}; use super::sprites::SpritePlan; use super::weather::{bubbles, drizzle, rainfall, rainfall_plan, snowfall, snowfall_plan, star_burst, star_burst_plan}; -use super::*; +use super::{Buf, EffectCtx, EffectSpec, ParamUi, Value, col, num, p, popup, slider}; /// The sprite plan of a sprite-drawing simulation effect for buffer geometry `b` (its pixels /// are not read), `None` for other effects or settings drawn otherwise (the GPU compositor's diff --git a/crates/effects/src/sim/sprites.rs b/crates/effects/src/sim/sprites.rs index 982579a5c..78c405abd 100644 --- a/crates/effects/src/sim/sprites.rs +++ b/crates/effects/src/sim/sprites.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Buf, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, ParallelSliceMut, Px, unpremul}; // ------------------------------------------------------------------ band rasteriser diff --git a/crates/effects/src/sim/weather.rs b/crates/effects/src/sim/weather.rs index cac0b023c..b93c0e48b 100644 --- a/crates/effects/src/sim/weather.rs +++ b/crates/effects/src/sim/weather.rs @@ -1,6 +1,6 @@ use super::common::{h, hs}; use super::sprites::{Acc, Post, Shape, Sprite, SpritePlan, Tint, aa, raster}; -use super::*; +use super::{Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, unpremul}; // ------------------------------------------------------------------ CC Rainfall / CC Snowfall diff --git a/crates/effects/src/sim2/foam.rs b/crates/effects/src/sim2/foam.rs index cf9009492..fc187d191 100644 --- a/crates/effects/src/sim2/foam.rs +++ b/crates/effects/src/sim2/foam.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelRefIterator, IntoParallelRefMutIterator, ParallelIterator, Plane, Px, Shape, SimCache, + Sprite, Value, hs, params_key, splat, unpremul, value_noise, +}; // ------------------------------------------------------------------ Foam diff --git a/crates/effects/src/sim2/pieces.rs b/crates/effects/src/sim2/pieces.rs index 3aa3a68f6..bff5943c0 100644 --- a/crates/effects/src/sim2/pieces.rs +++ b/crates/effects/src/sim2/pieces.rs @@ -1,5 +1,5 @@ use super::shatter::{ShatterPlan, card_dance_plan, shatter_impl}; -use super::*; +use super::{Acc, Buf, EffectCtx, Image, Lighting, Proj, raster, unpremul}; // ------------------------------------------------------------------ 3D pieces diff --git a/crates/effects/src/sim2/pixels.rs b/crates/effects/src/sim2/pixels.rs index 2079f03c3..2cda428db 100644 --- a/crates/effects/src/sim2/pixels.rs +++ b/crates/effects/src/sim2/pixels.rs @@ -1,6 +1,6 @@ use super::pieces::{Cam, Piece, PiecePlan, PieceTex, rot_axis, sort_far_first}; use super::shatter::{ShatterPlan, shatter_impl}; -use super::*; +use super::{Acc, Buf, EffectCtx, IntoParallelIterator, ParallelIterator, Post, Shape, Sprite, SpritePlan, h, hs, unpremul}; // ------------------------------------------------------------------ CC Ball Action diff --git a/crates/effects/src/sim2/registry.rs b/crates/effects/src/sim2/registry.rs index 93508e7b5..333916a16 100644 --- a/crates/effects/src/sim2/registry.rs +++ b/crates/effects/src/sim2/registry.rs @@ -2,7 +2,7 @@ use super::foam::{FOAM_TEXTURES, foam}; use super::pixels::{ball_action, pixel_polly, scatterize}; use super::shatter::{CD_PROPS, CD_SOURCES, SHATTER_PATTERNS, SHATTER_TEXTURE_MODES, card_dance, shatter}; use super::surfaces::{caustics, wave_world}; -use super::*; +use super::{EffectSpec, ParamUi, Value, col, num, p, pct, popup, pt, slider, spec}; // ------------------------------------------------------------------ specs diff --git a/crates/effects/src/sim2/shatter.rs b/crates/effects/src/sim2/shatter.rs index 90b5163f1..844fbb4b3 100644 --- a/crates/effects/src/sim2/shatter.rs +++ b/crates/effects/src/sim2/shatter.rs @@ -1,5 +1,8 @@ use super::pieces::{M3, Piece, PiecePlan, PieceTex, inv3, rot_axis, rot_xyz, sort_far_first}; -use super::*; +use super::{ + Acc, Buf, EffectCtx, Image, IndexedParallelIterator, IntoParallelIterator, IntoParallelRefIterator, Lighting, ParallelIterator, Post, Shape, Sprite, + SpritePlan, Value, h, hs, layer_or_self, unpremul, +}; // ------------------------------------------------------------------ Card Dance diff --git a/crates/effects/src/sim2/surfaces.rs b/crates/effects/src/sim2/surfaces.rs index 0bcc639c4..7c3c032f0 100644 --- a/crates/effects/src/sim2/surfaces.rs +++ b/crates/effects/src/sim2/surfaces.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Acc, Buf, EffectCtx, Image, IntoParallelIterator, ParallelIterator, Plane, Px, Shape, SimCache, Sprite, Value, gauss_plane, params_key, unpremul}; // ------------------------------------------------------------------ Caustics diff --git a/crates/effects/src/sim3.rs b/crates/effects/src/sim3.rs index 688b45950..88a16d981 100644 --- a/crates/effects/src/sim3.rs +++ b/crates/effects/src/sim3.rs @@ -575,7 +575,8 @@ fn mapper_from(ctx: &EffectCtx, group: &str, ephemeral: bool) -> Option /// Draw a map layer frame as a particle: centred on (x, y) buffer px, rotated `angle` degrees /// and scaled, over `out`. -fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, x: f64, y: f64, angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { +fn blit_layer(out: &mut Image, lp: &crate::LayerPixels, position: [f64; 2], angle: f64, scale: [f64; 2], buf_scale: f64, alpha: f32) { + let [x, y] = position; let (w, h) = (lp.size[0] * scale[0].abs() * buf_scale, lp.size[1] * scale[1].abs() * buf_scale); let r = (w * w + h * h).sqrt() * 0.5 + 1.0; let (x0, x1) = ((x - r).floor().max(0.0) as i64, (x + r).ceil().min(out.width as f64) as i64); @@ -925,7 +926,7 @@ impl PgPlan { pub fn finish(&self, mut b: Buf) -> Buf { let mut fx = splat(b.img.width, b.img.height, &self.sprites.sprites, self.sprites.acc); for q in &self.blits { - blit_layer(&mut fx, &self.frames[q.frame], q.x, q.y, q.angle, q.scale, b.scale, q.alpha); + blit_layer(&mut fx, &self.frames[q.frame], [q.x, q.y], q.angle, q.scale, b.scale, q.alpha); } // Particles replace the layer's own pixels. b.img = combine(&b.img, &fx, 5); diff --git a/crates/effects/src/warp_stab.rs b/crates/effects/src/warp_stab.rs index f850bdcf3..226654a04 100644 --- a/crates/effects/src/warp_stab.rs +++ b/crates/effects/src/warp_stab.rs @@ -453,7 +453,7 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { }); let need = need_m.into_inner().unwrap_or_else(std::sync::PoisonError::into_inner); if synth && need.iter().any(|w| *w > 0.0) { - synthesize(ctx, &a, k, &src_of, &crop, sc, &mut out, &need); + synthesize(ctx, &a, k, &SynthesisGrid { src_of: &src_of, crop: &crop, scale: sc }, &mut out, &need); } if b(ctx.params, "advanced/showTrackPoints") { let r = (2.5 * f(ctx.params, "advanced/trackPointSize") / 100.0 * sc).max(0.5); @@ -465,19 +465,16 @@ fn render(ctx: &EffectCtx, buf: Buf) -> Buf { Buf { img: out, offset: [0.0; 2], scale: sc } } -/// Fill the pixels flagged in `need` (weight = how much synthesized content they take) from -/// neighbouring frames, nearest first. -#[allow(clippy::too_many_arguments)] -fn synthesize( - ctx: &EffectCtx, - a: &WarpAnalysis, - k: usize, - src_of: &(dyn Fn([f64; 2]) -> [f64; 2] + Sync), - crop: &[f64; 4], - sc: f64, - out: &mut Image, - need: &[f32], -) { +/// Maps an output sample into the uncropped source grid shared by neighbouring frames. +struct SynthesisGrid<'a> { + src_of: &'a (dyn Fn([f64; 2]) -> [f64; 2] + Sync), + crop: &'a [f64; 4], + scale: f64, +} + +/// Fill the pixels flagged in `need` from neighbouring frames, nearest first. +fn synthesize(ctx: &EffectCtx, a: &WarpAnalysis, k: usize, grid: &SynthesisGrid<'_>, out: &mut Image, need: &[f32]) { + let SynthesisGrid { src_of, crop, scale: sc } = *grid; let Some(host) = ctx.env.host else { return }; let n = a.frames.len(); let range = ((f(ctx.params, "advanced/synthesizeInputRange").max(0.0) / a.frame_duration.max(1e-6)).round() as usize).min(60); From f20d22bf36b2e1a91d8e1f01d01d6a50bfeac01d Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 30/38] Refactor engine for strict Clippy checks --- crates/engine/src/analysis/roto.rs | 13 ++- crates/engine/src/cmd.rs | 2 +- crates/engine/src/cmd/enabled.rs | 2 +- crates/engine/src/cmd/layer/contents.rs | 5 +- crates/engine/src/cmd/layer/create.rs | 5 +- crates/engine/src/cmd/layer/parent.rs | 3 +- crates/engine/src/cmd/layer/replace.rs | 5 +- crates/engine/src/cmd/layer/select.rs | 2 +- crates/engine/src/cmd/layer/switches.rs | 5 +- crates/engine/src/cmd/layer/timing.rs | 5 +- crates/engine/src/cmd/layer/transform.rs | 5 +- crates/engine/src/cmd/params.rs | 2 +- crates/engine/src/cmd/paths.rs | 20 ++-- crates/engine/src/cmd/render_queue.rs | 82 ++++++++----- crates/engine/src/cmd/schema.rs | 2 +- crates/engine/src/cmd/shape_tool.rs | 11 +- crates/engine/src/doc_state.rs | 2 +- crates/engine/src/event.rs | 2 +- crates/engine/src/fonts.rs | 2 +- crates/engine/src/home/demo.rs | 22 +++- crates/engine/src/services.rs | 2 +- crates/engine/src/session.rs | 4 +- crates/engine/src/session/comps.rs | 2 +- crates/engine/src/session/dispatch.rs | 2 +- crates/engine/src/session/edit.rs | 2 +- crates/engine/src/session/render.rs | 2 +- crates/engine/src/settings/prefs/dialog.rs | 2 +- crates/engine/src/settings/prefs/document.rs | 2 +- crates/engine/src/settings/prefs/queries.rs | 2 +- crates/engine/src/settings/prefs/recent.rs | 2 +- crates/engine/src/settings/prefs/sections.rs | 2 +- crates/engine/src/tests/camera_track.rs | 116 ++++++++++++------- crates/engine/src/tests/mask_warp.rs | 42 ++++--- crates/engine/src/tests/rig3d.rs | 76 ++++++------ crates/engine/src/tests/vector_import.rs | 65 +++++++---- 35 files changed, 323 insertions(+), 197 deletions(-) diff --git a/crates/engine/src/analysis/roto.rs b/crates/engine/src/analysis/roto.rs index d9ce6e5af..813561969 100644 --- a/crates/engine/src/analysis/roto.rs +++ b/crates/engine/src/analysis/roto.rs @@ -450,7 +450,18 @@ async fn run_work(w: Work, shared: &RotoShared) { (RotoTask::Propagate, true) => om::ensure(&w.params, w.size, w.fps, *f, model.clone(), &mut get, &cancel).is_some(), (RotoTask::Freeze, true) => { let img = get(*f); - match img.map(|img| (om::frame_matte(&w.params, w.size, w.fps, *f, 1.0, &img, model.clone(), &mut get, &cancel), img)) { + match img.map(|img| { + ( + om::frame_matte( + &w.params, + &om::MatteFrame { layer_size: w.size, fps: w.fps, frame: *f, scale: 1.0, img: &img }, + model.clone(), + &mut get, + &cancel, + ), + img, + ) + }) { Some((om::FrameMatte::Alpha(a), img)) => { frozen.size = [img.width as usize, img.height as usize]; frozen.frames.insert(*f, fx::freeze_frame(&a, &[])); diff --git a/crates/engine/src/cmd.rs b/crates/engine/src/cmd.rs index 47b3cbde9..bceef85fb 100644 --- a/crates/engine/src/cmd.rs +++ b/crates/engine/src/cmd.rs @@ -156,7 +156,7 @@ mod enabled; mod params; mod schema; -use super::*; +use super::commands; pub use enabled::always; pub(crate) use enabled::{has_comp, has_keys, has_layers, has_project_selection, not_yet, not_yet_run}; pub use params::match_path_of; diff --git a/crates/engine/src/cmd/enabled.rs b/crates/engine/src/cmd/enabled.rs index ba2631b20..fb28b60c4 100644 --- a/crates/engine/src/cmd/enabled.rs +++ b/crates/engine/src/cmd/enabled.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Result, Session, Value}; // ---------- enablement ---------- diff --git a/crates/engine/src/cmd/layer/contents.rs b/crates/engine/src/cmd/layer/contents.rs index 884d0df5c..d8824fc9e 100644 --- a/crates/engine/src/cmd/layer/contents.rs +++ b/crates/engine/src/cmd/layer/contents.rs @@ -1,5 +1,8 @@ use super::create::{apply_text_params, text_range_p}; -use super::*; +use super::{ + EngineError, GroupKind, Ids, ItemId, KV, LayerId, MaskMode, Node, Project, PropGroup, Result, Session, TextDoc, Value, b_p, bad, build, json, layer_mut, + layer_p, merge_p, str_p, +}; pub(super) fn add_mask(s: &mut Session, p: &Value) -> Result { let (cid, lid) = layer_p(s, p, "layer.addMask")?; diff --git a/crates/engine/src/cmd/layer/create.rs b/crates/engine/src/cmd/layer/create.rs index 7a01aa538..7962c2485 100644 --- a/crates/engine/src/cmd/layer/create.rs +++ b/crates/engine/src/cmd/layer/create.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, Ids, ItemId, ItemKind, Justify, KV, Label, Layer, LayerId, LayerSource, Project, Result, Session, Solid, TextDoc, Tick, Value, b_p, bad, + build, comp_id, f_p, json, limits, str_p, +}; pub(crate) fn color_p(p: &Value, k: &str) -> Option<[f32; 3]> { match p.get(k)? { diff --git a/crates/engine/src/cmd/layer/parent.rs b/crates/engine/src/cmd/layer/parent.rs index 8565cbbb1..fbaec14e4 100644 --- a/crates/engine/src/cmd/layer/parent.rs +++ b/crates/engine/src/cmd/layer/parent.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, EngineError, ItemId, KV, Layer, LayerId, PropGroup, Result, Session, Tick, Value, b_p, bad, layers_p, resolve_layer}; /// Re-express a layer in its new parent space without moving its anchor. pub(crate) struct ParentFix { @@ -336,6 +336,7 @@ pub(super) fn set_parent(s: &mut Session, p: &Value) -> Result { #[cfg(test)] mod parent_chain_tests { + use super::super::json; use super::*; use std::sync::Arc; diff --git a/crates/engine/src/cmd/layer/replace.rs b/crates/engine/src/cmd/layer/replace.rs index 3fa208d15..80e5be807 100644 --- a/crates/engine/src/cmd/layer/replace.rs +++ b/crates/engine/src/cmd/layer/replace.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, FootageKind, Ids, ItemId, ItemKind, Layer, LayerSource, Project, Result, Session, Value, bad, build, has_comp, json, layer_mut, layers_p, + unlocked, +}; /// 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 diff --git a/crates/engine/src/cmd/layer/select.rs b/crates/engine/src/cmd/layer/select.rs index b7fded70d..0edf7a72c 100644 --- a/crates/engine/src/cmd/layer/select.rs +++ b/crates/engine/src/cmd/layer/select.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, LayerId, Result, Session, Value, b_p, comp_id, json, resolve_layer}; pub(super) fn select(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; diff --git a/crates/engine/src/cmd/layer/switches.rs b/crates/engine/src/cmd/layer/switches.rs index 046cc37a4..38689afbb 100644 --- a/crates/engine/src/cmd/layer/switches.rs +++ b/crates/engine/src/cmd/layer/switches.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + BlendMode, EngineError, FrameBlend, ItemId, KV, Layer, LayerId, MatteKind, Quality, Result, Session, TrackMatte, Value, b_p, bad, json, layer_mut, layer_p, + layers_p, merge_p, resolve_layer, str_p, +}; pub(super) fn set_switch(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/timing.rs b/crates/engine/src/cmd/layer/timing.rs index a1f83dc8f..711eefee2 100644 --- a/crates/engine/src/cmd/layer/timing.rs +++ b/crates/engine/src/cmd/layer/timing.rs @@ -1,4 +1,7 @@ -use super::*; +use super::{ + EngineError, ItemKind, Label, Layer, LayerId, LayerSource, Result, Session, Tick, Value, b_p, bad, build, f_p, json, layer_mut, layers_p, merge_p, + resolve_layer, str_p, unlocked, with, +}; pub(super) fn timing(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; diff --git a/crates/engine/src/cmd/layer/transform.rs b/crates/engine/src/cmd/layer/transform.rs index 6612a2d07..8b75dcbc8 100644 --- a/crates/engine/src/cmd/layer/transform.rs +++ b/crates/engine/src/cmd/layer/transform.rs @@ -1,5 +1,8 @@ use super::create::{color_p, pixel_aspect_p}; -use super::*; +use super::{ + EngineError, ItemKind, KV, LayerId, LayerSource, PropGroup, Result, Session, Tick, Value, bad, has_layers, json, layer_mut, layer_p, layers_p, limits, + str_p, unlocked, +}; /// Move Position by `(dx, dy)`, including every key. Separated dimensions move X and Y as well /// as the combined property the channels were split from. diff --git a/crates/engine/src/cmd/params.rs b/crates/engine/src/cmd/params.rs index 296ab6f6f..7c4e1c292 100644 --- a/crates/engine/src/cmd/params.rs +++ b/crates/engine/src/cmd/params.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, EngineError, ItemId, Layer, LayerId, Result, Session, Tick, Value}; /// Hand a frontend-only command to the UI ([`crate::Event::Frontend`]). pub(crate) fn frontend(s: &mut Session, id: &str, p: &Value) -> Result { diff --git a/crates/engine/src/cmd/paths.rs b/crates/engine/src/cmd/paths.rs index 96767d967..afd7aba12 100644 --- a/crates/engine/src/cmd/paths.rs +++ b/crates/engine/src/cmd/paths.rs @@ -121,16 +121,16 @@ fn parametric_path(g: &PropGroup, lt: effectcraft_time::Tick) -> Option effectcraft_path::rect(v2("size", [100.0; 2]), v2("position", [0.0; 2]), f("roundness", 0.0)), "ellipse" => effectcraft_path::ellipse(v2("size", [100.0; 2]), v2("position", [0.0; 2])), - "star" => effectcraft_path::polystar( - g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, - f("points", 5.0), - v2("position", [0.0; 2]), - f("rotation", 0.0), - f("innerRadius", 50.0), - f("outerRadius", 100.0), - f("innerRoundness", 0.0), - f("outerRoundness", 0.0), - ), + "star" => effectcraft_path::polystar(effectcraft_path::Polystar { + star: g.prop("type").map_or(0, |pr| pr.value_at(lt).as_enum()) == 0, + points: f("points", 5.0), + pos: v2("position", [0.0; 2]), + rotation: f("rotation", 0.0), + inner: f("innerRadius", 50.0), + outer: f("outerRadius", 100.0), + inner_round: f("innerRoundness", 0.0), + outer_round: f("outerRoundness", 0.0), + }), _ => return None, }; // A closed parametric shape is one subpath; anything else would not be this shape. diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 09a7c7d2f..1cda510e0 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -205,23 +205,8 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { /// The `format` parameter's values. const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff"; -/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest -/// (captured by `crop.useRoi`). -fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { - let mut any = false; - if let Some(t) = str_p(p, "template") { - let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?; - apply_om_template(om, &t); - any = true; - } - let before = om.clone(); - if let Some(f) = str_p(p, "format") { - let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?; - om.set_format(f); - } - // Before Channels: the WebM codec decides whether alpha can be written, so an explicit - // `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166). - any |= apply_codec_options(om, p, cmd)?; +/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`. +fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { if let Some(c) = str_p(p, "channels") { om.channels = match c.to_ascii_lowercase().replace([' ', '+'], "").as_str() { "rgb" => Channels::Rgb, @@ -244,21 +229,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< _ => return Err(bad(cmd, "color: straight|premultiplied")), }; } - if let Some(q) = f_p(p, "quality") { - om.quality = q.clamp(1.0, 100.0) as u8; - } - if let Some(b) = f_p(p, "bitrate") { - om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32; - } - if let Some(b) = b_p(p, "webmBitrate") { - om.webm_bitrate = b; - } - if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) { - om.keyframe_interval = k.min(100_000) as u32; - } - if let Some(pp) = str_p(p, "proresProfile") { - om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; - } + Ok(()) +} + +/// `crop` plus the flat dialog keys (`cropTop`… and `resizeWidth`/`resizeHeight`). +fn apply_crop(om: &mut OutputModule, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result<()> { match p.get("crop") { Some(Value::Bool(b)) => om.crop.enabled = *b, Some(Value::Object(c)) => { @@ -298,6 +273,11 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< om.resize.enabled = true; } } + Ok(()) +} + +/// `resize` (bool or object). +fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { match p.get("resize") { Some(Value::Bool(b)) => om.resize.enabled = *b, Some(Value::Object(r)) => { @@ -328,6 +308,44 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< Some(Value::Null) | None => {} Some(_) => return Err(bad(cmd, "resize: bool or {enabled?, preset?, width?, height?, lockAspect?, quality?: low|high}")), } + Ok(()) +} + +/// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest +/// (captured by `crop.useRoi`). +fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { + let mut any = false; + if let Some(t) = str_p(p, "template") { + let t = templates.output_module(t).ok_or_else(|| bad(cmd, format!("no Output Module template “{t}”")))?; + apply_om_template(om, &t); + any = true; + } + let before = om.clone(); + if let Some(f) = str_p(p, "format") { + let f = OutputFormat::from_name(f).ok_or_else(|| bad(cmd, FORMATS))?; + om.set_format(f); + } + // Before Channels: the WebM codec decides whether alpha can be written, so an explicit + // `channels: rgba` with `webmCodec: av1` is refused rather than dropped (#166). + any |= apply_codec_options(om, p, cmd)?; + apply_channels_and_alpha(om, p, cmd)?; + if let Some(q) = f_p(p, "quality") { + om.quality = q.clamp(1.0, 100.0) as u8; + } + if let Some(b) = f_p(p, "bitrate") { + om.bitrate_kbps = b.clamp(50.0, 500_000.0) as u32; + } + if let Some(b) = b_p(p, "webmBitrate") { + om.webm_bitrate = b; + } + if let Some(k) = p.get("keyframeInterval").and_then(Value::as_u64) { + om.keyframe_interval = k.min(100_000) as u32; + } + if let Some(pp) = str_p(p, "proresProfile") { + om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; + } + apply_crop(om, roi, p, cmd)?; + apply_resize(om, p, cmd)?; if let Some(a) = p.get("audio") { om.audio = match a { Value::Bool(true) => AudioOutput::On, diff --git a/crates/engine/src/cmd/schema.rs b/crates/engine/src/cmd/schema.rs index 11e2ef9f2..c74ab277e 100644 --- a/crates/engine/src/cmd/schema.rs +++ b/crates/engine/src/cmd/schema.rs @@ -1,5 +1,5 @@ use super::params::bad; -use super::*; +use super::{CommandSpec, Result, Value}; // ---------- parameter validation (agents) ---------- diff --git a/crates/engine/src/cmd/shape_tool.rs b/crates/engine/src/cmd/shape_tool.rs index a48c32b7f..e1f3607b1 100644 --- a/crates/engine/src/cmd/shape_tool.rs +++ b/crates/engine/src/cmd/shape_tool.rs @@ -221,7 +221,16 @@ pub fn mask_path(kind: &str, centre: [f64; 2], size: [f64; 2]) -> Option effectcraft_path::rect(size, centre, roundness(size)), k => { let (star, points, outer, inner) = polystar_of(k, size)?; - effectcraft_path::polystar(star, points, centre, 0.0, inner, outer, 0.0, 0.0) + effectcraft_path::polystar(effectcraft_path::Polystar { + star, + points, + pos: centre, + rotation: 0.0, + inner, + outer, + inner_round: 0.0, + outer_round: 0.0, + }) } }; effectcraft_path::from_kurbo(&bez).into_iter().next() diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index e5fb1791b..954a7d24e 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Deserialize, ItemId, Layer, LayerId, Serialize, Tick, Uid, commands, mask_track, media_browser, roto, viewer}; /// A keyframe reference: layer, property uid, key time (layer time). #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] diff --git a/crates/engine/src/event.rs b/crates/engine/src/event.rs index 0e60575de..e1f5e0535 100644 --- a/crates/engine/src/event.rs +++ b/crates/engine/src/event.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{ItemId, Serialize, Value}; /// Events for frontends (drained each frame). #[derive(Clone, Debug, PartialEq, Serialize)] diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index 039fd2bad..6b06d6f5b 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -1,4 +1,4 @@ -use super::*; +use super::prefs; pub fn text_families() -> Vec { effectcraft_text::families().into_iter().map(|(f, _)| f).collect() diff --git a/crates/engine/src/home/demo.rs b/crates/engine/src/home/demo.rs index df07f5204..656bd7708 100644 --- a/crates/engine/src/home/demo.rs +++ b/crates/engine/src/home/demo.rs @@ -130,8 +130,20 @@ fn solid_sized(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, color: build::layer(p, comp, name, LayerSource::Solid { item: sid }, (w, h), None) } +/// What `card` draws: name, gradient end colors (hex), size, position and Y rotation. +#[derive(Clone, Copy)] +struct CardSpec<'a> { + name: &'a str, + a: &'a str, + b: &'a str, + size: (u32, u32), + pos: [f64; 3], + rot_y: f64, +} + /// A 3D card: a solid with a diagonal gradient and a thin border grid. -fn card(p: &mut Project, comp: &Comp, folder: ItemId, name: &str, a: &str, b: &str, size: (u32, u32), pos: [f64; 3], rot_y: f64) -> Layer { +fn card(p: &mut Project, comp: &Comp, folder: ItemId, spec: &CardSpec) -> Layer { + let CardSpec { name, a, b, size, pos, rot_y } = *spec; let mut l = solid_sized(p, comp, folder, name, [1.0, 1.0, 1.0], size.0, size.1); let (w, h) = (size.0 as f64, size.1 as f64); effect( @@ -192,11 +204,11 @@ fn showcase_3d(p: &mut Project, solids: ItemId) -> Comp { set(&mut floor, "materialOptions/specularIntensity", Value::Scalar(10.0)); // Cards at different depths and angles; the centre pair intersects. - let left = card(p, &c, solids, "Card Blue", "#2E7BF0", "#7A4DFF", (520, 340), [520.0, 560.0, 120.0], 28.0); - let right = card(p, &c, solids, "Card Coral", "#FF6A5C", "#FFB347", (520, 340), [1420.0, 560.0, 260.0], -32.0); - let mut x1 = card(p, &c, solids, "Cross A", "#1FD1A5", "#2E7BF0", (460, 460), [960.0, 470.0, 520.0], 40.0); + let left = card(p, &c, solids, &CardSpec { name: "Card Blue", a: "#2E7BF0", b: "#7A4DFF", size: (520, 340), pos: [520.0, 560.0, 120.0], rot_y: 28.0 }); + let right = card(p, &c, solids, &CardSpec { name: "Card Coral", a: "#FF6A5C", b: "#FFB347", size: (520, 340), pos: [1420.0, 560.0, 260.0], rot_y: -32.0 }); + let mut x1 = card(p, &c, solids, &CardSpec { name: "Cross A", a: "#1FD1A5", b: "#2E7BF0", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: 40.0 }); set(&mut x1, "materialOptions/lightTransmission", Value::Scalar(30.0)); - let x2 = card(p, &c, solids, "Cross B", "#F0E14A", "#FF6A5C", (460, 460), [960.0, 470.0, 520.0], -40.0); + let x2 = card(p, &c, solids, &CardSpec { name: "Cross B", a: "#F0E14A", b: "#FF6A5C", size: (460, 460), pos: [960.0, 470.0, 520.0], rot_y: -40.0 }); // Hero title in 3D space, gently turning. let mut title = text_layer( p, diff --git a/crates/engine/src/services.rs b/crates/engine/src/services.rs index 01a2102ec..d962bf62d 100644 --- a/crates/engine/src/services.rs +++ b/crates/engine/src/services.rs @@ -1,4 +1,4 @@ -use super::*; +use super::config; /// Host services (file access) injected by the frontend. pub trait Services: Send + Sync { diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index d4feb6d67..18b0ff4cf 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -1,7 +1,9 @@ use super::doc_state::{CompSelection, EditorState}; use super::error::{EngineError, Result}; use super::event::Event; -use super::*; +use super::{ + Arc, Comp, History, Image, ItemId, LayerId, Project, RenderOpts, Renderer, Session, ThumbnailJob, Tick, Value, camera_track, commands, guard, roto, warp, +}; mod comps; mod dispatch; diff --git a/crates/engine/src/session/comps.rs b/crates/engine/src/session/comps.rs index 3c663e22f..ed3810e79 100644 --- a/crates/engine/src/session/comps.rs +++ b/crates/engine/src/session/comps.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Comp, CompSelection, Event, ItemId, LayerId, Result, Session, Tick, Value, commands}; /// `t` as a current time of `comp`: a frame within its duration. fn comp_frame_at(comp: &Comp, t: Tick) -> Tick { diff --git a/crates/engine/src/session/dispatch.rs b/crates/engine/src/session/dispatch.rs index 815de866b..36c7f0a51 100644 --- a/crates/engine/src/session/dispatch.rs +++ b/crates/engine/src/session/dispatch.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Event, Result, Session, Value, commands, guard}; impl Session { pub fn new() -> Session { diff --git a/crates/engine/src/session/edit.rs b/crates/engine/src/session/edit.rs index f9df9ef20..a8b03461a 100644 --- a/crates/engine/src/session/edit.rs +++ b/crates/engine/src/session/edit.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EditorState, EngineError, Event, History, ItemId, Project, Result, Session, camera_track, commands, roto, warp}; impl Session { /// Apply an undoable edit. `merge`: consecutive edits with the same key fold into one step. diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 855c9c401..53cd6d753 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EngineError, Image, ItemId, RenderOpts, Renderer, Result, Session, ThumbnailJob, Tick}; impl Session { /// Render a comp frame with the session's footage + expression hosts. diff --git a/crates/engine/src/settings/prefs/dialog.rs b/crates/engine/src/settings/prefs/dialog.rs index e0f49a8e9..e3f2006d8 100644 --- a/crates/engine/src/settings/prefs/dialog.rs +++ b/crates/engine/src/settings/prefs/dialog.rs @@ -1,5 +1,5 @@ use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, WINDOW_GRAPHICS}; -use super::*; +use super::{Value, json}; // ---------------------------------------------------------------- schema (Settings dialog) diff --git a/crates/engine/src/settings/prefs/document.rs b/crates/engine/src/settings/prefs/document.rs index 49f2e5e9d..9e991da46 100644 --- a/crates/engine/src/settings/prefs/document.rs +++ b/crates/engine/src/settings/prefs/document.rs @@ -1,7 +1,7 @@ use super::choices::{APPEARANCE_MODES, DARK_THEMES, LANGUAGES, LIGHT_THEMES, THEMES, WINDOW_GRAPHICS, choices, is_choice, theme_family}; use super::recent::MAX_EFFECTS_FAVORITES; use super::sections::{CompPreset, default_labels, parse_hex}; -use super::*; +use super::{Appearance, General, Map, PREFS_VERSION, Prefs, Startup, Value, json}; /// Bring an older settings document up to [`PREFS_VERSION`]. /// diff --git a/crates/engine/src/settings/prefs/queries.rs b/crates/engine/src/settings/prefs/queries.rs index e55f086ed..74be30f8f 100644 --- a/crates/engine/src/settings/prefs/queries.rs +++ b/crates/engine/src/settings/prefs/queries.rs @@ -1,5 +1,5 @@ use super::sections::parse_hex; -use super::*; +use super::{Label, Prefs, Serialize, Tick}; impl Prefs { /// Display name of a label (Labels settings). diff --git a/crates/engine/src/settings/prefs/recent.rs b/crates/engine/src/settings/prefs/recent.rs index c5321490d..2a77a61c2 100644 --- a/crates/engine/src/settings/prefs/recent.rs +++ b/crates/engine/src/settings/prefs/recent.rs @@ -1,4 +1,4 @@ -use super::*; +use super::Prefs; impl Prefs { /// Remember a project in File ▸ Open Recent. diff --git a/crates/engine/src/settings/prefs/sections.rs b/crates/engine/src/settings/prefs/sections.rs index 2b8d8adf6..6f2a1923c 100644 --- a/crates/engine/src/settings/prefs/sections.rs +++ b/crates/engine/src/settings/prefs/sections.rs @@ -1,5 +1,5 @@ use super::choices::theme_family; -use super::*; +use super::{BTreeMap, Deserialize, Label, Serialize, Value}; /// Unknown keys inside a page, kept so a newer version's settings survive a round trip. pub(super) type Extra = BTreeMap; diff --git a/crates/engine/src/tests/camera_track.rs b/crates/engine/src/tests/camera_track.rs index cc6378c4e..3ca8578a9 100644 --- a/crates/engine/src/tests/camera_track.rs +++ b/crates/engine/src/tests/camera_track.rs @@ -3,7 +3,7 @@ use effectcraft_effects::camera_tracker as ct; use effectcraft_geom::vec3; -use effectcraft_project::LayerId; +use effectcraft_project::{Comp, Layer, LayerId, PropGroup}; use effectcraft_raster::Image; use effectcraft_render::EvalCtx; use effectcraft_render::three_d::camera::active_camera; @@ -11,6 +11,7 @@ use effectcraft_track::camtrack::linalg::{self, M3, V3}; use serde_json::{Value, json}; use crate::Session; +use crate::camera_track::Placed; use crate::tests::track::{H, W, frame_time, setup, texture}; const F: f64 = 260.0; @@ -77,10 +78,9 @@ fn status(s: &mut Session) -> Value { s.execute("camera.solveStatus", json!({})).unwrap() } -#[test] -fn track_camera_solves_and_creates_layers_on_the_plane() { - let (mut s, clip) = tracked(json!({})); - let st = status(&mut s); +/// Solved with a sane error and focal length; returns the Camera Tracker effect group. +fn assert_solved(s: &mut Session, clip: LayerId) -> PropGroup { + let st = status(s); assert_eq!(st["solved"], true, "{st}"); let err = st["averageError"].as_f64().unwrap(); assert!(err < 0.5, "average error {err}"); @@ -90,9 +90,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { // Method Used / Average Error are reported in the effect. let g = s.active_comp().unwrap().layer(clip).unwrap().effects().unwrap().groups().next().unwrap().clone(); assert!(matches!(g.prop(ct::METHOD_USED).map(|p| &p.value), Some(effectcraft_keyframe::Value::Str(m)) if !m.is_empty())); + g +} - // Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the - // ones seen below the horizon at the bottom of the frame). +/// Pick floor points on frame 12 (their 3D positions lie on y = 1.5 in the true scene: the +/// ones seen below the horizon at the bottom of the frame). +fn select_floor_points(s: &mut Session) -> Vec { s.execute("time.set", json!({"time": frame_time(12).seconds()})).unwrap(); let pts = s.execute("camera.points", json!({})).unwrap(); let list = pts["points"].as_array().unwrap(); @@ -101,32 +104,19 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert!(floor.len() >= 3, "{floor:?}"); s.execute("camera.selectPoints", json!({"points": floor})).unwrap(); assert_eq!(s.state.camera_points.len(), floor.len()); + floor +} - // Create Solid and Camera: one undo step, camera + solid. - let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap(); - let cam = LayerId(r["camera"].as_u64().unwrap()); - let solid = LayerId(r["layers"][0].as_u64().unwrap()); - let comp = s.active_comp().unwrap().clone(); - assert!(comp.layer(cam).unwrap().is_camera()); - assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera"); - assert_eq!(comp.layers[0].id, cam); - let sl = comp.layer(solid).unwrap(); - assert!(sl.switches.three_d); - assert!(sl.name.starts_with("Track Solid")); - // The camera is keyed on every frame. - assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25); - - // Every solved point projects through the created camera exactly where the solve puts it, - // and onto its tracked position (the footage fills the comp, so layer pixels are comp - // pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers: - // features on occlusion edges), 0.5 px on average. +/// Every solved point projects through the created camera exactly where the solve puts it, +/// and onto its tracked position (the footage fills the comp, so layer pixels are comp +/// pixels): within 1 px for 95 % of the observations (the rest are the solve's outliers: +/// features on occlusion edges), 0.5 px on average. +fn assert_reprojection(s: &Session, comp: &Comp, pl: &Placed, g: &PropGroup) { let cid = s.active_comp_id().unwrap(); - let uid = g.uid; - let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap(); - let tracks = ct::tracks(&crate::camera_track::static_params(&g)).unwrap(); + let tracks = ct::tracks(&crate::camera_track::static_params(g)).unwrap(); let mut errs = vec![]; for k in [0u32, 6, 12, 18, 24] { - let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(k)); + let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(k)); let camst = active_camera(&ctx); for sp in pl.solve.visible(k as usize) { let Some(obs) = tracks.track(sp.id).and_then(|t| t.at(k)) else { continue }; @@ -142,34 +132,31 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { let p95 = errs[errs.len() * 95 / 100]; let mean = errs.iter().sum::() / errs.len() as f64; assert!(p95 < 1.0 && mean < 0.5, "p95 {p95} px, mean {mean} px"); +} - // The solid lies on the selected points' plane. +/// The solid lies on the selected points' plane and faces the camera. +fn assert_solid_on_plane(s: &Session, comp: &Comp, sl: &Layer, pl: &Placed, size: f64) { + let cid = s.active_comp_id().unwrap(); let tr = sl.props.sub("transform").unwrap(); let pos = tr.get("position").unwrap().value.as_vec3(); let ori = tr.get("orientation").unwrap().value.as_vec3(); let normal = linalg::mv(&linalg::from_euler_xyz(ori), [0.0, 0.0, -1.0]); - let size = r["target"]["size"].as_f64().unwrap(); for id in &s.state.camera_points { let w = pl.to_world(pl.solve.point(*id).unwrap().pos); let d = linalg::dot(linalg::sub(w, pos), normal).abs(); assert!(d < 0.05 * size.max(1.0) + 1.0, "point {id} is {d} px off the plane"); } - // The solid faces the camera. - let ctx = EvalCtx::new(&s.project, cid, &comp, frame_time(12)); + let ctx = EvalCtx::new(&s.project, cid, comp, frame_time(12)); let eye = active_camera(&ctx).eye; assert!(linalg::dot(normal, linalg::sub([eye.x, eye.y, eye.z], pos)) > 0.0); +} - // Undo removes both layers; redo brings them back. - let before = comp.layers.len(); - assert!(s.undo()); - assert_eq!(s.active_comp().unwrap().layers.len(), before - 2); - assert!(s.redo()); - assert_eq!(s.active_comp().unwrap().layers.len(), before); - - // A second create reuses the camera: Create Multiple Nulls and Camera. +/// A second create reuses the camera (Create Multiple Nulls and Camera), then Text and +/// Shadow Catcher (+ light). +fn assert_create_kinds(s: &mut Session, cam: LayerId, n_floor: usize) { let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null", "multiple": true})).unwrap(); assert_eq!(r["camera"].as_u64().unwrap(), cam.0); - assert_eq!(r["layers"].as_array().unwrap().len(), floor.len()); + assert_eq!(r["layers"].as_array().unwrap().len(), n_floor); // Text and Shadow Catcher (+ light). let r = s.execute_checked("camera.createFromSolve", json!({"kind": "text"})).unwrap(); assert_eq!(r["layers"].as_array().unwrap().len(), 1); @@ -180,11 +167,12 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert_eq!(comp.layer(ids[0]).unwrap().props.prop("materialOptions/acceptsShadows").unwrap().value.as_enum(), 2); assert!(comp.layer(ids[1]).unwrap().is_light()); assert!(s.execute_checked("camera.createFromSolve", json!({"kind": "teapot"})).is_err()); +} - // Set Ground Plane and Origin: the target becomes the origin; a null created there sits at - // (0, 0, 0) lying on the X-Z plane (its front facing up, −y). +/// Set Ground Plane and Origin, then an explicit target. +fn assert_ground_and_target(s: &mut Session) { s.execute_checked("camera.setGroundPlane", json!({})).unwrap(); - assert_eq!(status(&mut s)["groundPlane"], true); + assert_eq!(status(s)["groundPlane"], true); let r = s.execute_checked("camera.createFromSolve", json!({"kind": "null"})).unwrap(); let null = LayerId(r["layers"][0].as_u64().unwrap()); let tr = s.active_comp().unwrap().layer(null).unwrap().props.sub("transform").unwrap().clone(); @@ -203,6 +191,44 @@ fn track_camera_solves_and_creates_layers_on_the_plane() { assert!(linalg::norm(linalg::sub(pos, [10.0, 0.0, 20.0])) < 1e-6, "{pos:?}"); let front = linalg::mv(&linalg::from_euler_xyz(tr.get("orientation").unwrap().value.as_vec3()), [0.0, 0.0, -1.0]); assert!(linalg::norm(linalg::sub(front, [0.0, 0.0, -1.0])) < 1e-6, "{front:?}"); +} + +#[test] +fn track_camera_solves_and_creates_layers_on_the_plane() { + let (mut s, clip) = tracked(json!({})); + let g = assert_solved(&mut s, clip); + let floor = select_floor_points(&mut s); + + // Create Solid and Camera: one undo step, camera + solid. + let r = s.execute_checked("camera.createFromSolve", json!({"kind": "solid"})).unwrap(); + let cam = LayerId(r["camera"].as_u64().unwrap()); + let solid = LayerId(r["layers"][0].as_u64().unwrap()); + let comp = s.active_comp().unwrap().clone(); + assert!(comp.layer(cam).unwrap().is_camera()); + assert_eq!(comp.layer(cam).unwrap().name, "3D Tracker Camera"); + assert_eq!(comp.layers[0].id, cam); + let sl = comp.layer(solid).unwrap(); + assert!(sl.switches.three_d); + assert!(sl.name.starts_with("Track Solid")); + // The camera is keyed on every frame. + assert_eq!(comp.layer(cam).unwrap().props.prop("transform/position").unwrap().keys.len(), 25); + + let cid = s.active_comp_id().unwrap(); + let uid = g.uid; + let pl = crate::camera_track::placed(&s.project, cid, clip, uid).unwrap(); + assert_reprojection(&s, &comp, &pl, &g); + assert_solid_on_plane(&s, &comp, sl, &pl, r["target"]["size"].as_f64().unwrap()); + + // Undo removes both layers; redo brings them back. + let before = comp.layers.len(); + assert!(s.undo()); + assert_eq!(s.active_comp().unwrap().layers.len(), before - 2); + assert!(s.redo()); + assert_eq!(s.active_comp().unwrap().layers.len(), before); + + assert_create_kinds(&mut s, cam, floor.len()); + + assert_ground_and_target(&mut s); // Serde keeps the analysis. let back = effectcraft_project::Project::from_json(&s.project.to_json()).unwrap(); diff --git a/crates/engine/src/tests/mask_warp.rs b/crates/engine/src/tests/mask_warp.rs index e20ec7cb6..adcea44be 100644 --- a/crates/engine/src/tests/mask_warp.rs +++ b/crates/engine/src/tests/mask_warp.rs @@ -239,6 +239,28 @@ fn set_param(s: &mut Session, layer: LayerId, path: &str, v: serde_json::Value) s.execute("prop.set", json!({"layer": layer.0, "path": format!("effects/#1/{path}"), "value": v})).unwrap(); } +/// Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre. +fn assert_crop_framing(s: &mut Session, clip: LayerId) { + set_param(s, clip, "borders/framing", json!(2)); + let st = s.execute("warp.status", json!({})).unwrap(); + assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}"); + for f in 0..25 { + assert_eq!(transparent(&render(s, f)), 0, "frame {f}"); + } + set_param(s, clip, "borders/framing", json!(1)); + let st = s.execute("warp.status", json!({})).unwrap(); + let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap(); + for f in [0, 9, 24] { + let img = render(s, f); + for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) { + for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) { + assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})"); + } + } + assert!(img.get(0, 0)[3] < 0.01); + } +} + #[test] fn warp_stabilizer_analyzes_and_stabilizes() { let (mut s, clip, solid) = setup(shaky); @@ -289,25 +311,7 @@ fn warp_stabilizer_analyzes_and_stabilizes() { } set_param(&mut s, clip, "stabilization/result", json!(0)); - // Stabilize, Crop, Auto-scale (the default) hides the borders; Crop leaves an opaque centre. - set_param(&mut s, clip, "borders/framing", json!(2)); - let st = s.execute("warp.status", json!({})).unwrap(); - assert!(st["autoScale"].as_f64().unwrap() > 100.5, "{st}"); - for f in 0..25 { - assert_eq!(transparent(&render(&s, f)), 0, "frame {f}"); - } - set_param(&mut s, clip, "borders/framing", json!(1)); - let st = s.execute("warp.status", json!({})).unwrap(); - let crop: [f64; 4] = serde_json::from_value(st["crop"].clone()).unwrap(); - for f in [0, 9, 24] { - let img = render(&s, f); - for y in (crop[1].ceil() as u32 + 1)..(crop[3].floor() as u32 - 1) { - for x in (crop[0].ceil() as u32 + 1)..(crop[2].floor() as u32 - 1) { - assert!(img.get(x as i64, y as i64)[3] > 0.99, "frame {f} ({x}, {y})"); - } - } - assert!(img.get(0, 0)[3] < 0.01); - } + assert_crop_framing(&mut s, clip); // Synthesize Edges fills most of the borders from neighbouring frames. set_param(&mut s, clip, "borders/framing", json!(3)); let synth: usize = (0..25).map(|f| transparent(&render(&s, f))).sum(); diff --git a/crates/engine/src/tests/rig3d.rs b/crates/engine/src/tests/rig3d.rs index d3b965107..d801a743a 100644 --- a/crates/engine/src/tests/rig3d.rs +++ b/crates/engine/src/tests/rig3d.rs @@ -49,45 +49,38 @@ fn camera(s: &mut Session) -> u64 { s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap() } -#[test] -fn stereo_rig_builds_eye_comps_and_glasses() { - let mut s = session(); - let src = cid(&s); - let cam = camera(&mut s); - let items_before = s.project.items.len(); - let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); - assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps"); - assert_eq!(r["master"].as_u64(), Some(cam)); - // Controls on a hidden null in the source comp. - let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap()); +/// Controls on a hidden null in the source comp. +fn assert_stereo_controls(ctl: &Layer) { assert_eq!(ctl.name, "Stereo 3D Controls"); assert!(!ctl.switches.video); let fx = ctl.effects().unwrap().groups().next().unwrap().clone(); assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS)); assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0)); - // Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp. - let d = 0.04 * 640.0; - for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] { - let ec = ItemId(r[key].as_u64().unwrap()); - assert_eq!(s.project.item(ec).unwrap().name, name); - let c = s.project.comp(ec).unwrap(); - assert_eq!((c.width, c.height), (640, 360)); - assert_eq!(c.layers.len(), 2); - let cam = &c.layers[0]; - assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam)); - assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position")); - // Parallel eyes (no convergence): each looks straight ahead. - assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi")); - assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0)); - for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] { - let e = cam.props.prop(p).unwrap().expr.clone().unwrap(); - assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text); - } - let nest = &c.layers[1]; - assert_eq!(nest.source, LayerSource::Comp { item: src }); - assert!(nest.switches.collapse && nest.switches.three_d); +} + +/// An eye comp: an eye camera (two-node, linked by expressions) at `x` and the collapsed source comp. +fn assert_eye_comp(s: &Session, src: ItemId, ec: ItemId, name: &str, x: f64) { + assert_eq!(s.project.item(ec).unwrap().name, name); + let c = s.project.comp(ec).unwrap(); + assert_eq!((c.width, c.height), (640, 360)); + assert_eq!(c.layers.len(), 2); + let cam = &c.layers[0]; + assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam)); + assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position")); + // Parallel eyes (no convergence): each looks straight ahead. + assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi")); + assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0)); + for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] { + let e = cam.props.prop(p).unwrap().expr.clone().unwrap(); + assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text); } - // The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden. + let nest = &c.layers[1]; + assert_eq!(nest.source, LayerSource::Comp { item: src }); + assert!(nest.switches.collapse && nest.switches.three_d); +} + +/// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden. +fn assert_stereo_comp(s: &Session, r: &serde_json::Value) { let oc = ItemId(r["stereoComp"].as_u64().unwrap()); let out = s.project.comp(oc).unwrap(); assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D"); @@ -98,6 +91,23 @@ fn stereo_rig_builds_eye_comps_and_glasses() { assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses")); assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l))); assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr))); +} + +#[test] +fn stereo_rig_builds_eye_comps_and_glasses() { + let mut s = session(); + let src = cid(&s); + let cam = camera(&mut s); + let items_before = s.project.items.len(); + let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); + assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps"); + assert_eq!(r["master"].as_u64(), Some(cam)); + assert_stereo_controls(&layer_in(&s, src, r["controls"].as_u64().unwrap())); + let d = 0.04 * 640.0; + for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] { + assert_eye_comp(&s, src, ItemId(r[key].as_u64().unwrap()), name, x); + } + assert_stereo_comp(&s, &r); // One undo step; redo; serde. let json = serde_json::to_string(&*s.project).unwrap(); let back: Project = serde_json::from_str(&json).unwrap(); diff --git a/crates/engine/src/tests/vector_import.rs b/crates/engine/src/tests/vector_import.rs index 63513b910..9cfb94ecc 100644 --- a/crates/engine/src/tests/vector_import.rs +++ b/crates/engine/src/tests/vector_import.rs @@ -2,7 +2,7 @@ use effectcraft_color::BlendMode; use effectcraft_keyframe::Value as KV; -use effectcraft_project::{ItemKind, LayerSource, MaskMode}; +use effectcraft_project::{Comp, ItemKind, Layer, LayerSource, MaskMode}; use effectcraft_psd::write::*; use effectcraft_psd::{DValue, Descriptor, Rect}; use serde_json::json; @@ -74,22 +74,8 @@ pub(crate) fn sample_psd() -> Vec { write(&d) } -#[test] -fn psd_imports_as_composition() { - let path = tmp("sample.psd"); - std::fs::write(&path, sample_psd()).unwrap(); - let mut s = Session::default(); - let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); - assert_eq!(r["errors"], json!([]), "{r}"); - let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); - assert_eq!(s.state.active_comp, Some(cid)); - assert_eq!(s.project.item(cid).unwrap().name, "sample"); - let c = s.project.comp(cid).unwrap().clone(); - assert_eq!((c.width, c.height), (120, 80)); - let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); - // Text layer: editable text, positioned at the type origin. - let title = &c.layers[0]; +/// Text layer: editable text, positioned at the type origin. +fn assert_title_layer(title: &Layer) { assert!(matches!(title.source, LayerSource::Text)); let Some(KV::Text(doc)) = title.props.prop("text/sourceText").map(|p| p.value.clone()) else { panic!("text") }; assert_eq!(doc.text, "Hello PSD"); @@ -97,14 +83,18 @@ fn psd_imports_as_composition() { assert_eq!(doc.font, "Inter"); assert_eq!(doc.style, "Bold"); assert_eq!(title.props.prop("transform/position").unwrap().value.as_vec2(), [8.0, 70.0]); - // Adjustment layer with Hue/Saturation. - let hue = &c.layers[1]; +} + +/// Adjustment layer with Hue/Saturation. +fn assert_hue_layer(hue: &Layer) { assert!(hue.switches.adjustment); let fx = hue.effects().unwrap().groups().next().unwrap(); assert_eq!(fx.get("hue").unwrap().value.as_f64(), 40.0); assert_eq!(fx.get("saturation").unwrap().value.as_f64(), 10.0); - // Vector mask → mask; Stroke style. - let vm = &c.layers[2]; +} + +/// Vector mask → mask; Stroke style. +fn assert_vector_mask_layer(vm: &Layer) { let m = vm.masks().unwrap().groups().next().unwrap(); assert!(matches!(m.kind, effectcraft_project::GroupKind::Mask { mode: MaskMode::Add, .. })); let Some(KV::Path(p)) = m.get("path").map(|p| p.value.clone()) else { panic!("mask path") }; @@ -113,19 +103,23 @@ fn psd_imports_as_composition() { let st = vm.props.group("layerStyles/stroke").unwrap(); assert_eq!(st.get("size").unwrap().value.as_f64(), 2.0); assert_eq!(st.get("position").unwrap().value, KV::Enum(1)); - // Clipped → preserve transparency; opacity and blend mode. +} + +/// Clipped → preserve transparency (opacity, blend mode); drop shadow. +fn assert_clipped_and_shadow(c: &Comp) { let clipped = &c.layers[3]; assert!(clipped.preserve_transparency); assert_eq!(clipped.blend_mode, BlendMode::Screen); assert!((clipped.props.prop("transform/opacity").unwrap().value.as_f64() - 128.0 / 255.0 * 100.0).abs() < 1e-9); - // Drop shadow. let ds = c.layers[4].props.group("layerStyles/dropShadow").unwrap(); assert_eq!(ds.get("distance").unwrap().value.as_f64(), 9.0); assert_eq!(ds.get("angle").unwrap().value.as_f64(), 45.0); assert_eq!(ds.get("useGlobalLight").unwrap().value, KV::Bool(false)); assert_eq!(ds.get("opacity").unwrap().value.as_f64(), 60.0); - // Group → collapsed precomp (pass through) with its layers. - let g = &c.layers[5]; +} + +/// Group → collapsed precomp (pass through) with its layers; footage items name their Photoshop layer. +fn assert_group_precomp(s: &Session, g: &Layer) { assert!(g.switches.collapse); let LayerSource::Comp { item } = g.source else { panic!("precomp") }; let inner = s.project.comp(item).unwrap(); @@ -140,6 +134,27 @@ fn psd_imports_as_composition() { assert_eq!((sl.name.as_str(), sl.layer_size), ("Masked Square", false)); assert_eq!((f.width, f.height), (120, 80)); assert_eq!(s.project.item(item).unwrap().name, "Masked Square/sample.psd"); +} + +#[test] +fn psd_imports_as_composition() { + let path = tmp("sample.psd"); + std::fs::write(&path, sample_psd()).unwrap(); + let mut s = Session::default(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = effectcraft_project::ItemId(r["comps"][0].as_u64().unwrap()); + assert_eq!(s.state.active_comp, Some(cid)); + assert_eq!(s.project.item(cid).unwrap().name, "sample"); + let c = s.project.comp(cid).unwrap().clone(); + assert_eq!((c.width, c.height), (120, 80)); + let names: Vec<&str> = c.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, ["Title", "Hue Shift", "Vector Masked", "Clipped", "Base", "Group A", "Background"]); + assert_title_layer(&c.layers[0]); + assert_hue_layer(&c.layers[1]); + assert_vector_mask_layer(&c.layers[2]); + assert_clipped_and_shadow(&c); + assert_group_precomp(&s, &c.layers[5]); // One undo step removes everything. s.execute("edit.undo", json!({})).unwrap(); assert!(s.project.comp(cid).is_none()); From 6e72ab3b57eb62e8dc32f62e64c0fc949f31c1af Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 28 Oct 2026 06:09:21 +0000 Subject: [PATCH 31/38] Refactor ui-egui for strict Clippy checks --- crates/ui-egui/src/app/files.rs | 2 +- crates/ui-egui/src/app/gpu.rs | 3 +- crates/ui-egui/src/app/prefetch.rs | 2 +- crates/ui-egui/src/app/prefs.rs | 3 +- crates/ui-egui/src/app/screenshot.rs | 2 +- crates/ui-egui/src/header.rs | 337 +- crates/ui-egui/src/menus/bar.rs | 2 +- crates/ui-egui/src/menus/commands.rs | 2 +- crates/ui-egui/src/menus/frontend.rs | 2 +- crates/ui-egui/src/menus/shortcuts.rs | 141 +- crates/ui-egui/src/panels/anim_tools.rs | 72 +- .../src/panels/effect_controls/common.rs | 2 +- .../src/panels/effect_controls/editors.rs | 2 +- .../src/panels/effect_controls/panel.rs | 231 +- .../src/panels/effect_controls/rows.rs | 612 ++- .../src/panels/effect_controls/viewer.rs | 279 +- crates/ui-egui/src/panels/effects_presets.rs | 55 +- crates/ui-egui/src/panels/expr_editor.rs | 10 +- crates/ui-egui/src/panels/forms.rs | 544 +-- crates/ui-egui/src/panels/fx_editors.rs | 20 +- crates/ui-egui/src/panels/fx_widgets.rs | 3 +- crates/ui-egui/src/panels/graph.rs | 275 +- crates/ui-egui/src/panels/key_dialogs.rs | 20 +- crates/ui-egui/src/panels/layer_panel.rs | 239 +- crates/ui-egui/src/panels/media_panels.rs | 38 +- crates/ui-egui/src/panels/misc.rs | 28 +- crates/ui-egui/src/panels/paint_panels.rs | 112 +- crates/ui-egui/src/panels/panel_kit.rs | 2 +- crates/ui-egui/src/panels/project.rs | 1129 +++-- crates/ui-egui/src/panels/properties.rs | 39 +- crates/ui-egui/src/panels/render_queue.rs | 355 +- crates/ui-egui/src/panels/roto_tool.rs | 18 +- crates/ui-egui/src/panels/settings.rs | 39 +- crates/ui-egui/src/panels/text_panels.rs | 195 +- crates/ui-egui/src/panels/timeline.rs | 4148 +++++++++++------ crates/ui-egui/src/panels/tracker.rs | 52 +- crates/ui-egui/src/panels/viewer.rs | 556 ++- crates/ui-egui/src/panels/viewer/frame.rs | 2 +- crates/ui-egui/src/panels/viewer/geometry.rs | 2 +- crates/ui-egui/src/panels/viewer/gesture.rs | 2 +- crates/ui-egui/src/panels/viewer/shapes.rs | 5 +- crates/ui-egui/src/panels/viewer/tools.rs | 2 +- crates/ui-egui/src/panels/viewer/view.rs | 2 +- crates/ui-egui/src/panels/viewer_text.rs | 380 +- crates/ui-egui/src/panels/viewer_tools.rs | 62 +- crates/ui-egui/src/pointer_wrap/tests.rs | 5 +- crates/ui-egui/src/widgets.rs | 25 +- 47 files changed, 6246 insertions(+), 3812 deletions(-) diff --git a/crates/ui-egui/src/app/files.rs b/crates/ui-egui/src/app/files.rs index da6aef559..b1beab972 100644 --- a/crates/ui-egui/src/app/files.rs +++ b/crates/ui-egui/src/app/files.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Dialog, EffectcraftApp}; impl EffectcraftApp { /// Show the crash-recovery dialog for a previous run that didn't exit cleanly. diff --git a/crates/ui-egui/src/app/gpu.rs b/crates/ui-egui/src/app/gpu.rs index 6b486997e..9febfa413 100644 --- a/crates/ui-egui/src/app/gpu.rs +++ b/crates/ui-egui/src/app/gpu.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{Arc, EffectcraftApp, RenderSource, gpu_failure, menus, panels}; impl EffectcraftApp { pub fn render_source(&self) -> RenderSource { @@ -184,6 +184,7 @@ impl EffectcraftApp { #[cfg(test)] mod gpu_failure_tests { use super::*; + use crate::{FrameKey, ItemId, RenderOpts, Session, Tick, json}; #[test] fn host_failure_preserves_document_undo_and_cpu_pixels_and_detaches_late_readbacks() { diff --git a/crates/ui-egui/src/app/prefetch.rs b/crates/ui-egui/src/app/prefetch.rs index b55884117..1fdf0aad1 100644 --- a/crates/ui-egui/src/app/prefetch.rs +++ b/crates/ui-egui/src/app/prefetch.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio, frames}; /// Seconds without a project change before paused prefetch resumes (see /// [`EffectcraftApp::editing`]). diff --git a/crates/ui-egui/src/app/prefs.rs b/crates/ui-egui/src/app/prefs.rs index 5ccb3ca18..6fc424e01 100644 --- a/crates/ui-egui/src/app/prefs.rs +++ b/crates/ui-egui/src/app/prefs.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, Tokens, json, theme}; impl EffectcraftApp { /// Apply changed settings: theme and brightness, label colours, tool tips, preview caches. @@ -67,6 +67,7 @@ impl EffectcraftApp { #[cfg(test)] mod appearance_tests { use super::*; + use crate::menus; use crate::theme::ThemeKind; fn app() -> EffectcraftApp { diff --git a/crates/ui-egui/src/app/screenshot.rs b/crates/ui-egui/src/app/screenshot.rs index 12092f2f3..a80a76bd6 100644 --- a/crates/ui-egui/src/app/screenshot.rs +++ b/crates/ui-egui/src/app/screenshot.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{DrawGeometry, EffectcraftApp, PendingShot, Sender, ShotTarget, control, json}; /// How long a screenshot request waits for a frame, from admission (app clock seconds). pub(crate) const SCREENSHOT_TIMEOUT_S: f64 = 8.0; diff --git a/crates/ui-egui/src/header.rs b/crates/ui-egui/src/header.rs index 861d6fd91..3eee89453 100644 --- a/crates/ui-egui/src/header.rs +++ b/crates/ui-egui/src/header.rs @@ -25,11 +25,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.rect_filled(rect, 0.0, t.header_bg); p.line_segment([rect.left_bottom(), rect.right_bottom()], Stroke::new(1.0, t.app_bg)); let cy = rect.center().y; - let mut x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 }; + let x = rect.min.x + if app.integrated_titlebar && !app.ui.show_menu_bar { 80.0 } else { 10.0 }; + let x = brand_and_home(app, ui, &p, x, cy); + let x = tool_slots(app, ui, &p, x, cy); + let x = tool_options_bar(app, ui, &p, x + 10.0, cy); + let x = snapping_toggle(app, ui, &p, x, cy); + right_side(app, ui, &p, rect, x, cy); +} - // Brand mark. +/// The brand mark (About) and the Home button; returns the x after them. +fn brand_and_home(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; let brand = Rect::from_min_size(pos2(x, cy - 12.0), vec2(24.0, 24.0)); - paint_logo(&p, brand); + paint_logo(p, brand); let bresp = ui.interact(brand, egui::Id::unique("brand"), Sense::click()); app.auto.add_response("header.about", &bresp, "About EffectCraft"); widgets::focus_outline(ui, &bresp, &t); @@ -38,7 +46,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } x += 32.0; - // Home. let home = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0)); let home_response = widgets::icon_button(ui, home, Icon::Home, app.ui.start_screen, &t, egui::Id::unique("tool-home")).on_hover_text(tr("Home")); app.auto.add_response("header.home", &home_response, "Home"); @@ -47,78 +54,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } x += 30.0; p.line_segment([pos2(x, cy - 10.0), pos2(x, cy + 10.0)], Stroke::new(1.0, t.separator)); - x += 6.0; + x + 6.0 +} - // Tool slots (with group separators after camera tools and pan-behind). +/// The tool slots (with group separators after camera tools and pan-behind); returns the x after them. +fn tool_slots(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + let t = app.tokens; let held_id = egui::Id::unique("tool-slot-held"); if ui.input(|i| i.pointer.any_pressed()) { ui.data_mut(|d| d.remove::(held_id)); } for (si, slot) in Tool::SLOTS.iter().enumerate() { - let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]); let r = Rect::from_min_size(pos2(x, cy - 13.0), vec2(26.0, 26.0)); - let active = slot.contains(&app.ui.tool); - let id = egui::Id::unique(("tool-slot", si)); - let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id); - if slot.len() > 1 { - // Flyout corner triangle. - p.add(egui::Shape::convex_polygon( - vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)], - if active { Color32::WHITE } else { t.text_dim }, - Stroke::NONE, - )); - } - app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); - let tip = match cur.shortcut() { - Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), - None => cur.label().to_string(), - }; - let resp = resp.on_hover_text(tip); - let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); - if resp.clicked() && !held { - app.ui.tool = cur; - } - // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. - if cur == Tool::PanBehind - && resp.double_clicked() - && ui.input(|i| i.modifiers.command) - && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) - { - app.ui.status = e; - } - if slot.len() > 1 { - let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); - if let Some(seconds) = held_for - && seconds < 0.35 - { - ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); - } - let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held; - let command = if resp.secondary_clicked() || long_press { - if long_press { - ui.data_mut(|d| d.insert_temp(held_id, id)); - } - Some(egui::SetOpenCommand::Bool(true)) - } else if resp.clicked() && !held { - Some(egui::SetOpenCommand::Bool(false)) - } else { - None - }; - let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; - egui::Popup::menu(&resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { - for tool in slot.iter() { - let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); - app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); - if row.clicked() { - app.ui.tool = *tool; - if let Some(saved) = app.ui.slot_tools.get_mut(si) { - *saved = *tool; - } - ui.close(); - } - } - }); - } + tool_slot(app, ui, p, si, slot, r, held_id); x += 28.0; if matches!(si, 2 | 5 | 7 | 10 | 13) { x += 4.0; @@ -126,17 +74,96 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { x += 6.0; } } + x +} - // Tool options. - x += 10.0; - // Selected shape layers: the options show their paint and edit it (as in After Effects). - let shape_layers: Vec = app - .session +/// One tool slot button in `r`: select on click, Ctrl+double-click Pan Behind, and the fly-out. +fn tool_slot(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, si: usize, slot: &[Tool], r: Rect, held_id: egui::Id) { + let t = app.tokens; + let cur = app.ui.slot_tools.get(si).copied().unwrap_or(slot[0]); + let active = slot.contains(&app.ui.tool); + let id = egui::Id::unique(("tool-slot", si)); + let resp = widgets::icon_button(ui, r, cur.icon(), active, &t, id); + if slot.len() > 1 { + // Flyout corner triangle. + p.add(egui::Shape::convex_polygon( + vec![pos2(r.max.x - 2.0, r.max.y - 6.0), pos2(r.max.x - 2.0, r.max.y - 2.0), pos2(r.max.x - 6.0, r.max.y - 2.0)], + if active { Color32::WHITE } else { t.text_dim }, + Stroke::NONE, + )); + } + app.auto.add_response(&format!("tools.{cur:?}"), &resp, cur.label()); + let tip = match cur.shortcut() { + Some(s) => format!("{} ({})", cur.label(), crate::menus::shortcut_text(s)), + None => cur.label().to_string(), + }; + let resp = resp.on_hover_text(tip); + let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); + if resp.clicked() && !held { + app.ui.tool = cur; + } + // Ctrl+double-click Pan Behind: Center Anchor Point in Layer Content. + if cur == Tool::PanBehind + && resp.double_clicked() + && ui.input(|i| i.modifiers.command) + && let Err(e) = crate::menus::invoke(app, ui.ctx(), "layer.centerAnchor", json!({})) + { + app.ui.status = e; + } + if slot.len() > 1 { + slot_flyout(app, ui, si, slot, &resp, held_id); + } +} + +/// The fly-out menu of a multi-tool slot: right-click or press-and-hold opens it. +fn slot_flyout(app: &mut EffectcraftApp, ui: &mut egui::Ui, si: usize, slot: &[Tool], resp: &egui::Response, held_id: egui::Id) { + let id = resp.id; + let held = ui.data(|d| d.get_temp::(held_id)) == Some(id); + let held_for = resp.is_pointer_button_down_on().then(|| ui.input(|i| i.pointer.press_start_time().map(|s| i.time - s))).flatten(); + if let Some(seconds) = held_for + && seconds < 0.35 + { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64(0.35 - seconds)); + } + let long_press = held_for.is_some_and(|seconds| seconds >= 0.35) && !held; + let command = if resp.secondary_clicked() || long_press { + if long_press { + ui.data_mut(|d| d.insert_temp(held_id, id)); + } + Some(egui::SetOpenCommand::Bool(true)) + } else if resp.clicked() && !held { + Some(egui::SetOpenCommand::Bool(false)) + } else { + None + }; + let close_behavior = if held && resp.clicked() { egui::PopupCloseBehavior::IgnoreClicks } else { egui::PopupCloseBehavior::CloseOnClickOutside }; + egui::Popup::menu(resp).style(crate::widgets::menu_style).open_memory(command).close_behavior(close_behavior).show(|ui| { + for tool in slot.iter() { + let row = ui.selectable_label(app.ui.tool == *tool, tool.label()); + app.auto.add(&format!("tools.select.{tool:?}"), row.rect, tool.label()); + if row.clicked() { + app.ui.tool = *tool; + if let Some(saved) = app.ui.slot_tools.get_mut(si) { + *saved = *tool; + } + ui.close(); + } + } + }); +} + +/// The ids of the selected shape layers: the options show their paint and edit it. +fn selected_shape_layers(app: &EffectcraftApp) -> Vec { + app.session .active_comp() .map(|c| { app.session.state.selected_layers.iter().filter(|id| c.layer(**id).is_some_and(|l| matches!(l.source, LayerSource::Shape))).map(|id| id.0).collect() }) - .unwrap_or_default(); + .unwrap_or_default() +} + +/// Mirrors the first selected shape layer's paint into the shape tool (as in After Effects). +fn sync_layer_paint(app: &mut EffectcraftApp, shape_layers: &[u64]) { let layer_paint = shape_layers.first().and_then(|id| app.session.active_comp()?.layer(LayerId(*id))).map(|l| { let lt = l.layer_time(app.session.time()); (effectcraft_engine::commands::shape_stroke::first_paint(l, lt), effectcraft_engine::commands::shape_stroke::first_paint_kinds(l, lt)) @@ -154,6 +181,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } tool.stroke_width = width.unwrap_or(0.0); } +} + +/// Options for the active tool (shape creation, fill and stroke, puppet, roto); returns the x after them. +fn tool_options_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut x: f32, cy: f32) -> f32 { + // Selected shape layers: the options show their paint and edit it (as in After Effects). + let shape_layers = selected_shape_layers(app); + sync_layer_paint(app, &shape_layers); // One undo step per picker session or width drag: end the merge once both are done. let picking = ["fill", "stroke"].iter().any(|k| widgets::popup_is_open(ui, picker_id(k))); if !picking && !ui.input(|i| i.pointer.any_down()) && app.session.history.merge_key.as_deref().is_some_and(|k| k.starts_with("tool-")) { @@ -167,14 +201,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { // Fill and Stroke, hidden while the tools draw masks. let masking = drawing && !shape_layers.is_empty() && app.session.state.shape_tool.creates_mask; if (drawing || !shape_layers.is_empty()) && !masking { - x = fill_stroke_options(app, ui, &p, &shape_layers, x, cy); + x = fill_stroke_options(app, ui, p, &shape_layers, x, cy); } if app.ui.tool.puppet_kind().is_some() { - x = puppet_options(app, ui, &p, x, cy); + x = puppet_options(app, ui, p, x, cy); } if app.ui.tool.roto_kind(false).is_some() { - x = roto_options(app, ui, &p, x, cy); + x = roto_options(app, ui, p, x, cy); } + x +} + +/// The Snapping checkbox, its label and options menu; returns the x where it started. +fn snapping_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x: f32, cy: f32) -> f32 { + let t = app.tokens; let snap = Rect::from_min_size(pos2(x, cy - 10.0), vec2(20.0, 20.0)); // The engine owns snapping (View ▸ Snapping); the checkbox mirrors it. app.ui.snapping = app.session.state.snapping; @@ -185,9 +225,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add("header.snapping", snap, "Snapping"); let label = p.text(pos2(snap.max.x + 4.0, cy), Align2::LEFT_CENTER, tr("Snapping"), Tokens::ui(12.0), t.text_dim); snapping_options(app, ui, Rect::from_min_size(pos2(label.max.x + 2.0, cy - 9.0), vec2(18.0, 18.0))); + x +} - // Right side: community buttons, workspaces. - let mut rx = rect.max.x - 10.0; +/// Right side: community buttons, appearance and workspaces; `left` is where the tools end. +fn right_side(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rect: Rect, left: f32, cy: f32) { + let t = app.tokens; + let rx = community_buttons(app, ui, p, rect.max.x - 10.0, cy); + let mut rx = appearance_button(app, ui, rx, cy); + rx -= 10.0; + p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); + rx -= 10.0; + let rx = workspace_menu(app, ui, p, rx, cy); + workspace_tabs(app, ui, p, rx, left, cy); +} + +/// The Discord pill and the GitHub and website buttons, right to left from `rx`; returns the new `rx`. +fn community_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, cy: f32) -> f32 { + let t = app.tokens; // The pill fits its label (icon, gap, text) with even padding: it was a fixed 104 px with the // text at its left, which left a wide gap on the right (#581). let label = p.layout_no_wrap(tr("Discord").to_string(), Tokens::semibold(12.0), Color32::WHITE); @@ -195,7 +250,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(discord, egui::Id::unique("hdr-discord"), Sense::click()); let dc = Color32::from_rgb(0x58, 0x65, 0xf2); p.rect_filled(discord, 13.0, if dresp.hovered() { dc.gamma_multiply(1.2) } else { dc }); - icons::paint(&p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); + icons::paint(p, Rect::from_center_size(pos2(discord.min.x + 17.0, cy), vec2(14.0, 14.0)), Icon::Chat, Color32::WHITE); p.galley(pos2(discord.min.x + 30.0, cy - label.size().y / 2.0), label, Color32::WHITE); app.auto.add("header.discord", discord, "Join the ArtCraft Discord"); if dresp.on_hover_text(tr("Join the ArtCraft community on Discord")).clicked() { @@ -212,7 +267,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { app.auto.add(&format!("header.{}", &id[4..]), r, tip); rx = r.min.x - 4.0; } - // Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next. + rx +} + +/// Appearance: Sync with System, Light, Dark (a monitor, sun or moon), one click to the next; +/// returns the new `rx`. +fn appearance_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, rx: f32, cy: f32) -> f32 { + let t = app.tokens; let (icon, mode) = match app.session.prefs.appearance.appearance_mode.as_str() { "auto" => (Icon::Monitor, tr("Sync with System")), "light" => (Icon::Sun, tr("Light")), @@ -224,37 +285,41 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if response.clicked() { app.cycle_appearance(ui.ctx()); } - response.context_menu(|ui| { - let settings = ui.button(tr("Appearance Settings…")); - app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); - if settings.clicked() { - crate::panels::settings::open(app, "appearance"); - ui.close(); - } - let favorites = app.session.prefs.appearance.favorite_themes.clone(); - for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { - let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); - if !group.is_empty() { - ui.separator(); - } - for (label, id) in group { - let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); - app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); - if r.clicked() { - if let Some(kind) = crate::theme::ThemeKind::from_name(id) { - app.set_theme(ui.ctx(), kind); - } - ui.close(); - } - } - } - }); + response.context_menu(|ui| appearance_menu(app, ui)); app.auto.add("header.appearance", r, &tip); - rx = r.min.x - 4.0; - rx -= 10.0; - p.line_segment([pos2(rx, cy - 10.0), pos2(rx, cy + 10.0)], Stroke::new(1.0, t.separator)); - rx -= 10.0; - // Workspace tabs (right to left), with a » menu of all. + r.min.x - 4.0 +} + +/// The appearance button's context menu: settings and the favorite themes. +fn appearance_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui) { + let settings = ui.button(tr("Appearance Settings…")); + app.auto.add("header.appearanceSettings", settings.rect, "Appearance Settings…"); + if settings.clicked() { + crate::panels::settings::open(app, "appearance"); + ui.close(); + } + let favorites = app.session.prefs.appearance.favorite_themes.clone(); + for options in [effectcraft_engine::prefs::LIGHT_THEMES, effectcraft_engine::prefs::DARK_THEMES] { + let group: Vec<_> = options.iter().filter(|(_, id)| favorites.iter().any(|saved| saved == id)).collect(); + if !group.is_empty() { + ui.separator(); + } + for (label, id) in group { + let r = ui.selectable_label(app.ui.theme.name() == *id, tr(label)); + app.auto.add(&format!("header.favoriteTheme.{id}"), r.rect, label); + if r.clicked() { + if let Some(kind) = crate::theme::ThemeKind::from_name(id) { + app.set_theme(ui.ctx(), kind); + } + ui.close(); + } + } + } +} + +/// The » button with the menu of all workspaces; returns the new `rx`. +fn workspace_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, rx: f32, cy: f32) -> f32 { + let t = app.tokens; let more = Rect::from_min_max(pos2(rx - 20.0, cy - 11.0), pos2(rx, cy + 11.0)); let mresp = ui.interact(more, egui::Id::unique("ws-more"), Sense::click()); p.text(more.center(), Align2::CENTER_CENTER, "»", Tokens::ui(15.0), if mresp.hovered() { t.text } else { t.text_dim }); @@ -273,15 +338,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ) { app.set_workspace(crate::dock::WORKSPACES[i]); } - rx = more.min.x - 6.0; - // After Effects 2026's workspace bar order (right to left here): Default, Review, Learn, - // Small Screen, Standard. + more.min.x - 6.0 +} + +/// Workspace tabs, right to left from `rx`, stopping 120 px right of `left`. +/// After Effects 2026's order (right to left here): Default, Review, Learn, Small Screen, Standard. +fn workspace_tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, mut rx: f32, left: f32, cy: f32) { + let t = app.tokens; let shown = ["Standard", "Small Screen", "Learn", "Review", "Default"]; for name in shown { 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 { + if r.min.x < left + 120.0 { break; } let active = app.ui.workspace == name; @@ -439,7 +508,14 @@ fn fill_stroke_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Pa x = sr.max.x + 12.0; } let width = app.session.state.shape_tool.stroke_width; - let (r, v, _) = widgets::hot_number_at(ui, pos2(x - 6.0, cy - 9.0), egui::Id::unique("tool-stroke-w"), width, 0.2, (0.0, 1000.0), 0, " px", &t); + let (r, v, _) = widgets::hot_number_at( + ui, + pos2(x - 6.0, cy - 9.0), + egui::Id::unique("tool-stroke-w"), + width, + &widgets::HotSpec { speed: 0.2, range: (0.0, 1000.0), decimals: 0, suffix: " px" }, + &t, + ); if let Some(v) = v { tool_options(app, json!({"strokeWidth": v})); paint(app, "strokeWidth", json!(v)); @@ -506,7 +582,14 @@ fn paint_options_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, shape_layers } app.auto.add(&format!("header.{key}Options.blend"), blend_rect, "Blend Mode"); p.text(row(98.0), Align2::LEFT_CENTER, tr("Opacity:"), Tokens::ui(12.0), t.text_dim); - let (or, v, _) = widgets::hot_number_at(ui, row(89.0) + vec2(84.0, 0.0), id.with("opacity"), tp.opacity, 0.5, (0.0, 100.0), 0, "%", &t); + let (or, v, _) = widgets::hot_number_at( + ui, + row(89.0) + vec2(84.0, 0.0), + id.with("opacity"), + tp.opacity, + &widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "%" }, + &t, + ); app.auto.add(&format!("header.{key}Options.opacity"), or, "Opacity"); if let Some(v) = v { let k_opacity = format!("{key}Opacity"); @@ -577,7 +660,14 @@ fn roto_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, let label = tr("Diameter:"); let lr = p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); x = lr.max.x + 6.0; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("roto-opt", key)), v, 0.5, (1.0, 5000.0), 0, " px", &t); + let (r, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("roto-opt", key)), + v, + &widgets::HotSpec { speed: 0.5, range: (1.0, 5000.0), decimals: 0, suffix: " px" }, + &t, + ); app.auto.add("header.roto.diameter", r, label); if let Some(nv) = nv && let Err(e) = app.session.execute("roto.options", json!({key: nv})) @@ -604,7 +694,14 @@ fn puppet_options(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter for (label, key, v, range) in [("Expansion:", "expansion", o.expansion, (-100.0, 200.0)), ("Density:", "density", o.density, (0.0, 100.0))] { p.text(pos2(x, cy), Align2::LEFT_CENTER, label, Tokens::ui(12.0), t.text_dim); x += if key == "expansion" { 64.0 } else { 52.0 }; - let (r, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("puppet-opt", key)), v, 0.2, range, 0, "", &t); + let (r, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("puppet-opt", key)), + v, + &widgets::HotSpec { speed: 0.2, range, decimals: 0, suffix: "" }, + &t, + ); app.auto.add(&format!("header.puppet.{key}"), r, label); if let Some(nv) = nv { let _ = app.session.execute("puppet.mesh", json!({key: nv, "merge": format!("puppet-opt-{key}")})); diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs index 1a7f6e2a3..e48bae5c8 100644 --- a/crates/ui-egui/src/menus/bar.rs +++ b/crates/ui-egui/src/menus/bar.rs @@ -1,7 +1,7 @@ use super::entries::{entry_checked, entry_enabled, entry_label, entry_shortcut, menu_choice}; use super::invoke::invoke; use super::shortcuts::shortcut_text; -use super::*; +use super::{EffectcraftApp, MenuEntry, MenuNode, Value, json}; /// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last /// frame (its title's open look, and the hover that switches between the titles). diff --git a/crates/ui-egui/src/menus/commands.rs b/crates/ui-egui/src/menus/commands.rs index bc695f609..c162bbd3a 100644 --- a/crates/ui-egui/src/menus/commands.rs +++ b/crates/ui-egui/src/menus/commands.rs @@ -1,4 +1,4 @@ -use super::*; +use super::UiCommand; macro_rules! uic { ($id:literal, $label:literal, [$($m:literal),*], $sc:expr) => { diff --git a/crates/ui-egui/src/menus/frontend.rs b/crates/ui-egui/src/menus/frontend.rs index d808b4620..f536086d9 100644 --- a/crates/ui-egui/src/menus/frontend.rs +++ b/crates/ui-egui/src/menus/frontend.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, PanelKind, Resolution, Value, json}; /// Run an engine command and perform the frontend events it emitted right away. /// Commands that fill the internal clipboard. diff --git a/crates/ui-egui/src/menus/shortcuts.rs b/crates/ui-egui/src/menus/shortcuts.rs index 7e3dfcec2..b9990786c 100644 --- a/crates/ui-egui/src/menus/shortcuts.rs +++ b/crates/ui-egui/src/menus/shortcuts.rs @@ -1,6 +1,6 @@ use super::frontend::run_engine; use super::invoke::invoke; -use super::*; +use super::{EffectcraftApp, PanelKind, Value, json}; /// Shortcut text in this OS's notation (`⇧⌘K` on macOS, `Ctrl+Shift+K` elsewhere). pub fn shortcut_text(s: &str) -> String { @@ -197,54 +197,40 @@ pub fn handle_tab_shortcut(app: &mut EffectcraftApp, ctx: &egui::Context) { } } -fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) { - let start = modifiers_at_start(ctx); - // Dialog cancellation owns Escape even when a text field has keyboard focus. - if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { - // A popup or colour picker takes Escape first (the picker goes back to its original colour). - if crate::panels::shortcut_editor::recording(app) - || crate::panels::shortcut_editor::confirming(app) - || egui::Popup::is_any_open(ctx) - || crate::color_picker::open_recently(ctx) +/// Escape with a dialog open: close the popup/picker first, else cancel the dialog. +fn escape_dialog(app: &mut EffectcraftApp, ctx: &egui::Context) { + // A popup or colour picker takes Escape first (the picker goes back to its original colour). + if crate::panels::shortcut_editor::recording(app) + || crate::panels::shortcut_editor::confirming(app) + || egui::Popup::is_any_open(ctx) + || crate::color_picker::open_recently(ctx) + { + return; + } + ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); + if app.dialog == Some(crate::Dialog::LayerStyles) { + crate::panels::layer_styles_dialog::finish(app, false); + } else if app.dialog == Some(crate::Dialog::Info) { + crate::panels::forms::close_info(app); + } else { + if app.dialog == Some(crate::Dialog::Settings) + && let Some(p) = app.dialog_state.prefs_snapshot.take() { - return; + app.session.prefs = p; + app.session.prefs_changed(); } - ctx.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Escape)); - if app.dialog == Some(crate::Dialog::LayerStyles) { - crate::panels::layer_styles_dialog::finish(app, false); - } else if app.dialog == Some(crate::Dialog::Info) { - crate::panels::forms::close_info(app); - } else { - if app.dialog == Some(crate::Dialog::Settings) - && let Some(p) = app.dialog_state.prefs_snapshot.take() - { - app.session.prefs = p; - app.session.prefs_changed(); - } - if app.dialog == Some(crate::Dialog::UnsavedChanges) { - app.dialog_state.unsaved.command = None; - } - app.dialog = None; + if app.dialog == Some(crate::Dialog::UnsavedChanges) { + app.dialog_state.unsaved.command = None; } - return; + app.dialog = None; } - // A mouse button down while Spacebar is held belongs to the Hand tool: its release won't - // preview. - if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { - ctx.data_mut(|d| d.remove::(space_tap_id())); - } - // While a menu or popup list is open, keys go to it, not to the app (Space must not start a - // preview behind the menu, #279). - if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) { - return; - } - // Text editing in the viewer takes the clipboard events itself. - let clipboard = app.session.state.text_edit.is_none(); - let project = app.ui.focused == PanelKind::Project; - // The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer. - let pasted = - if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None }; - let mut events: Vec<(egui::Key, egui::Modifiers, bool, Option)> = ctx.input(|i| { +} + +type KeyEvent = (egui::Key, egui::Modifiers, bool, Option); + +/// The frame's key presses, with clipboard events mapped back to Ctrl+C / X / V. +fn collect_key_events(ctx: &egui::Context, start: Option, clipboard: bool, project: bool) -> Vec { + ctx.input(|i| { // The windowing layer turns Ctrl+C / Ctrl+X / Ctrl+V into clipboard events instead of // key presses: map them back to the keys (with the modifiers held) so Edit ▸ Copy, Cut, // Paste and their variants (Ctrl+Alt+C…) run. Clipboard events carry no modifiers and @@ -283,22 +269,57 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only } } out - }); + }) +} + +/// Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it +/// comes as text. +fn numpad_star_marker(app: &mut EffectcraftApp, ctx: &egui::Context) { + if !ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { + return; + } + let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); + // As in After Effects, it marks the selected layers; only with none selected does it add + // a composition marker (#976). + let bound = + bound.map( + |(id, params)| { + if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } + }, + ); + if let Some((id, params)) = bound + && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) + { + app.ui.status = e; + } +} + +fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only: bool) { + let start = modifiers_at_start(ctx); + if !tab_only && app.dialog.is_some() && ctx.input(|i| i.key_pressed(egui::Key::Escape)) { + escape_dialog(app, ctx); + return; + } + // A mouse button down while Spacebar is held belongs to the Hand tool: its release won't + // preview. + if ctx.input(|i| i.pointer.any_down()) || ctx.egui_wants_keyboard_input() { + ctx.data_mut(|d| d.remove::(space_tap_id())); + } + // While a menu or popup list is open, keys go to it, not to the app (Space must not start a + // preview behind the menu, #279). + if ctx.egui_wants_keyboard_input() || crate::widgets::any_menu_open(ctx) { + return; + } + // Text editing in the viewer takes the clipboard events itself. + let clipboard = app.session.state.text_edit.is_none(); + let project = app.ui.focused == PanelKind::Project; + // The paste event's text: Edit ▸ Paste pastes SVG markup as a shape layer. + let pasted = + if clipboard { ctx.input(|i| i.events.iter().rev().find_map(|e| if let egui::Event::Paste(t) = e { Some(t.clone()) } else { None })) } else { None }; + let mut events = collect_key_events(ctx, start, clipboard, project); events.retain(|(key, _, _, _)| (*key == egui::Key::Tab) == tab_only); - // Numpad * (or `*` typed with Shift+8): Add Marker (#275). egui has no key for it, so it - // comes as text. - if !tab_only && clipboard && app.dialog.is_none() && ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::Text(t) if t == "*"))) { - let bound = app.session.shortcuts().bindings().into_iter().find(|(k, _)| *k == "Num*").map(|(_, b)| (b.command.clone(), b.params.clone())); - // As in After Effects, it marks the selected layers; only with none selected does it add - // a composition marker (#976). - let bound = bound.map(|(id, params)| { - if id == "comp.addMarker" && !app.session.state.selected_layers.is_empty() { ("layer.addMarker".to_owned(), params) } else { (id, params) } - }); - if let Some((id, params)) = bound - && let Err(e) = invoke(app, ctx, &id, if params.is_null() { json!({}) } else { params }) - { - app.ui.status = e; - } + if !tab_only && clipboard && app.dialog.is_none() { + numpad_star_marker(app, ctx); } if events.is_empty() { return; diff --git a/crates/ui-egui/src/panels/anim_tools.rs b/crates/ui-egui/src/panels/anim_tools.rs index fe4c67895..b07da8567 100644 --- a/crates/ui-egui/src/panels/anim_tools.rs +++ b/crates/ui-egui/src/panels/anim_tools.rs @@ -33,9 +33,9 @@ fn choice(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, auto: &str, opti /// A drag-to-change number field for an anim-tool setting: `v` clamped to `range`, `speed` points /// per pixel, `dec` decimals and a `suffix`; returns the new value, or `v` when it did not change. -fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 { +fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, spec: widgets::HotSpec) -> f64 { let t = app.tokens; - let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, speed, range, dec, suffix, &t); + let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("animtool-n", auto)), v, &spec, &t); app.auto.add(auto, r, &format!("{v}")); nv.unwrap_or(v) } @@ -105,10 +105,24 @@ pub fn wiggler(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += ROW + 4.0; label(&p, x0, y, "Frequency:", &t); - st.wiggle_frequency = number(app, ui, pos2(xv, y), "wiggler.frequency", st.wiggle_frequency, 0.1, (0.1, 100.0), 1, " per second"); + st.wiggle_frequency = number( + app, + ui, + pos2(xv, y), + "wiggler.frequency", + st.wiggle_frequency, + widgets::HotSpec { speed: 0.1, range: (0.1, 100.0), decimals: 1, suffix: " per second" }, + ); y += ROW; label(&p, x0, y, "Magnitude:", &t); - st.wiggle_magnitude = number(app, ui, pos2(xv, y), "wiggler.magnitude", st.wiggle_magnitude, 0.5, (0.0, 100_000.0), 1, ""); + st.wiggle_magnitude = number( + app, + ui, + pos2(xv, y), + "wiggler.magnitude", + st.wiggle_magnitude, + widgets::HotSpec { speed: 0.5, range: (0.0, 100_000.0), decimals: 1, suffix: "" }, + ); y += ROW + 8.0; app.ui.anim_tools = st.clone(); let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); @@ -149,7 +163,14 @@ pub fn smoother(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { p.text(pos2(xv, y + 9.0), Align2::LEFT_CENTER, txt, Tokens::ui(12.0), t.text); y += ROW; label(&p, x0, y, "Tolerance:", &t); - let tol = number(app, ui, pos2(xv, y), "smoother.tolerance", app.ui.anim_tools.smooth_tolerance, 0.05, (0.0, 10_000.0), 2, ""); + let tol = number( + app, + ui, + pos2(xv, y), + "smoother.tolerance", + app.ui.anim_tools.smooth_tolerance, + widgets::HotSpec { speed: 0.05, range: (0.0, 10_000.0), decimals: 2, suffix: "" }, + ); app.ui.anim_tools.smooth_tolerance = tol; y += ROW + 8.0; let b = Rect::from_min_size(pos2(rect.max.x - 92.0, y), vec2(80.0, 24.0)); @@ -170,10 +191,24 @@ pub fn motion_sketch(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let mut y = rect.min.y + 10.0; let mut st = app.ui.anim_tools.clone(); label(&p, x0, y, "Capture speed at:", &t); - st.sketch_speed = number(app, ui, pos2(xv, y), "motionSketch.captureSpeed", st.sketch_speed, 1.0, (1.0, 10_000.0), 0, " %"); + st.sketch_speed = number( + app, + ui, + pos2(xv, y), + "motionSketch.captureSpeed", + st.sketch_speed, + widgets::HotSpec { speed: 1.0, range: (1.0, 10_000.0), decimals: 0, suffix: " %" }, + ); y += ROW; label(&p, x0, y, "Smoothing:", &t); - st.sketch_smoothing = number(app, ui, pos2(xv, y), "motionSketch.smoothing", st.sketch_smoothing, 0.1, (0.0, 100.0), 1, ""); + st.sketch_smoothing = number( + app, + ui, + pos2(xv, y), + "motionSketch.smoothing", + st.sketch_smoothing, + widgets::HotSpec { speed: 0.1, range: (0.0, 100.0), decimals: 1, suffix: "" }, + ); y += ROW + 2.0; p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, tr("Show:"), Tokens::ui(12.0), t.text_dim); y += ROW - 6.0; @@ -328,7 +363,14 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec changes.insert("keyframeRate".into(), if i == 0 { json!("auto") } else { json!(fps) }); } if let Some(r) = o.keyframe_rate { - let nr = number(app, ui, pos2(xv + 78.0, y), "maskInterp.rate", r, 0.5, (0.1, 1000.0), 1, " per second"); + let nr = number( + app, + ui, + pos2(xv + 78.0, y), + "maskInterp.rate", + r, + widgets::HotSpec { speed: 0.5, range: (0.1, 1000.0), decimals: 1, suffix: " per second" }, + ); if (nr - r).abs() > 1e-9 { changes.insert("keyframeRate".into(), json!(nr)); } @@ -346,13 +388,20 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec check(app, y, "linearVertexPaths", "Use \"Linear\" Vertex Paths", o.linear_vertex_paths); y += ROW; label(&p, x0, y, "Bending Resistance:", &t); - let br = number(app, ui, pos2(xv, y), "maskInterp.bendingResistance", o.bending_resistance, 0.5, (0.0, 100.0), 0, ""); + let br = number( + app, + ui, + pos2(xv, y), + "maskInterp.bendingResistance", + o.bending_resistance, + widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" }, + ); if (br - o.bending_resistance).abs() > 1e-9 { changes.insert("bendingResistance".into(), json!(br)); } y += ROW; label(&p, x0, y, "Quality:", &t); - let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, 0.5, (0.0, 100.0), 0, ""); + let q = number(app, ui, pos2(xv, y), "maskInterp.quality", o.quality, widgets::HotSpec { speed: 0.5, range: (0.0, 100.0), decimals: 0, suffix: "" }); if (q - o.quality).abs() > 1e-9 { changes.insert("quality".into(), json!(q)); } @@ -363,7 +412,8 @@ pub fn mask_interpolation(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rec } y += ROW; if let Some(v) = o.add_vertices { - let nv = number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, 0.2, (0.1, 2000.0), 1, ""); + let nv = + number(app, ui, pos2(x0 + 22.0, y), "maskInterp.addVertices", v, widgets::HotSpec { speed: 0.2, range: (0.1, 2000.0), decimals: 1, suffix: "" }); if (nv - v).abs() > 1e-9 { changes.insert("addVertices".into(), json!(nv)); } diff --git a/crates/ui-egui/src/panels/effect_controls/common.rs b/crates/ui-egui/src/panels/effect_controls/common.rs index b96ed2180..ab46a07ac 100644 --- a/crates/ui-egui/src/panels/effect_controls/common.rs +++ b/crates/ui-egui/src/panels/effect_controls/common.rs @@ -1,4 +1,4 @@ -use super::*; +use super::{EffectcraftApp, Layer, Node, ParamUi, PropGroup, Value, json}; pub(super) type Actions = Vec<(String, serde_json::Value)>; diff --git a/crates/ui-egui/src/panels/effect_controls/editors.rs b/crates/ui-egui/src/panels/effect_controls/editors.rs index 3e79c7bdd..c95c90c07 100644 --- a/crates/ui-egui/src/panels/effect_controls/editors.rs +++ b/crates/ui-egui/src/panels/effect_controls/editors.rs @@ -1,5 +1,5 @@ use super::common::{Actions, gesture_key}; -use super::*; +use super::{Align2, Color32, EffectcraftApp, EvalCtx, Layer, PropGroup, Rect, Sense, Tokens, Value, fw, json, pos2, vec2, widgets}; /// Warp Stabilizer's Analyze / Cancel buttons and analysis status. pub(super) fn warp_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, layer: &Layer, g: &PropGroup, r: Rect, actions: &mut Actions) { diff --git a/crates/ui-egui/src/panels/effect_controls/panel.rs b/crates/ui-egui/src/panels/effect_controls/panel.rs index 30bb5804f..39cdee76c 100644 --- a/crates/ui-egui/src/panels/effect_controls/panel.rs +++ b/crates/ui-egui/src/panels/effect_controls/panel.rs @@ -1,7 +1,7 @@ use super::common::{Actions, PARAM_NAME_X, ROW, effect_active, selected_layer}; use super::editors::{curves_editor, editor_height, histogram_only, levels_editor, object_matte_editor, roto_editor, warp_editor}; use super::rows::{FxScope, group_rows, toggle_closed}; -use super::*; +use super::{Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Icon, Layer, PropGroup, Rect, Sense, Stroke, Tokens, json, pos2, vec2, widgets}; // --------------------------------------------------------------------------------------------- @@ -13,6 +13,140 @@ pub fn start_rename(ctx: &egui::Context, uid: u64, name: &str) { ctx.memory_mut(|m| m.request_focus(id.with("field"))); } +/// Where an effect header's parts sit, for its rename field. +struct EffectHdr<'a> { + g: &'a PropGroup, + shown: &'a str, + r: Rect, + tw: Rect, + reset: Rect, + rename_id: egui::Id, + layer_id: u64, +} + +/// The effect header's name field while renaming: Enter or blur commits, Escape cancels. +fn rename_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, h: &EffectHdr, mut value: String, actions: &mut Actions) { + let EffectHdr { g, shown, r, tw, reset, rename_id, layer_id } = *h; + let ctx = ui.ctx().clone(); + let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); + let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); + // Focus the field the frame it first exists: focusing an id not yet on screen leaves + // the accessibility tree with a focus it doesn't contain. + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { + crate::widgets::select_all(&ctx, response.id, &value); + response.request_focus(); + } + app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); + if ctx.data_mut(|d| d.remove_temp::(rename_id.with("select"))).unwrap_or(false) { + widgets::select_all(&ctx, response.id, &value); + } + // Escape cancels. egui drops the field's focus on Escape in the same frame, so the + // key is checked on the focus being lost as well as held. + let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); + let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); + if escape || commit { + ctx.data_mut(|d| d.remove::(rename_id)); + // The field opens with the drawn name, so committing it unchanged is not a rename. + if !escape && !value.trim().is_empty() && value != shown { + actions.push(("prop.renameGroup".into(), json!({"layer": layer_id, "prop": g.uid, "name": value}))); + } + } else { + ctx.data_mut(|d| d.insert_temp(rename_id, value)); + } +} + +struct EditorCx<'a> { + bp: &'a egui::Painter, + layer: &'a Layer, + ectx: &'a EvalCtx<'a>, +} + +/// An effect's custom editor (curves, levels, roto...), or the histogram-only fallback. +fn effect_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &EditorCx, g: &PropGroup, er: Rect, actions: &mut Actions) { + let GroupKind::Effect { effect } = &g.kind else { return }; + match effect.as_str() { + "ec.color.curves" => curves_editor(app, ui, cx.bp, cx.layer, g, cx.ectx, er, actions), + effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::roto::ID => roto_editor(app, ui, cx.bp, cx.layer, g, er, actions), + effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, cx.bp, cx.layer, g, er, actions), + "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions), + crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => { + crate::panels::fx_editors::header_editor(app, ui, cx.bp, cx.layer, g, effect, cx.ectx, er, actions) + } + _ => histogram_only(app, ui, cx.bp, g, er), + } +} + +struct ReorderDrag<'a> { + tops: &'a [(u64, f32)], + y: f32, + du: u64, + drag_id: egui::Id, + layer_id: u64, +} + +/// Drag an effect header to reorder: the insertion line between headers, and the drop. +fn reorder_drag(ctx: &egui::Context, bp: &egui::Painter, body: Rect, t: &Tokens, st: &ReorderDrag, actions: &mut Actions) { + let ReorderDrag { tops, y, du, drag_id, layer_id } = *st; + if let Some(pos) = ctx.pointer_interact_pos() { + let slot = tops.iter().position(|(_, ty)| pos.y < *ty + (ROW + 2.0) / 2.0).unwrap_or(tops.len()); + let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0); + bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent)); + if ctx.input(|i| i.pointer.any_released()) { + if let Some(from) = tops.iter().position(|(u, _)| *u == du) { + let to = if slot > from { slot - 1 } else { slot }; + if to != from { + actions.push(("effect.reorder".into(), json!({"layer": layer_id, "effect": du, "index": to + 1}))); + } + } + ctx.data_mut(|d| d.remove::(drag_id)); + } + } + if !ctx.input(|i| i.pointer.any_down()) { + ctx.data_mut(|d| d.remove::(drag_id)); + } +} + +/// Drop effects and animation presets here. +fn drop_payload(ui: &egui::Ui, rect: Rect, p: &egui::Painter, t: &Tokens, layer_id: u64, actions: &mut Actions) { + let ctx = ui.ctx().clone(); + if let Some(payload) = egui::DragAndDrop::payload::(&ctx) + && ui.rect_contains_pointer(rect) + && let Some((id, params)) = match payload.as_ref() { + crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer_id]}))), + crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer_id)), + _ => None, + } + { + p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); + if ctx.input(|i| i.pointer.any_released()) { + actions.push((id.into(), params)); + egui::DragAndDrop::clear_payload(&ctx); + } + } +} + +fn about_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, bp: &egui::Painter, t: &Tokens, effect: &str, g: &PropGroup, r: Rect) { + // About…: the effect's name and category (AE's About dialog). + let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0)); + let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click()); + bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); + app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About"); + let apop = egui::Id::unique(("ec-about-pop", g.uid)); + if aresp.clicked() { + widgets::open_popup(ui, apop); + } + if let Some(spec) = effectcraft_engine::effects::find(effect) { + let lines = vec![ + crate::i18n::effects::name(spec.id, spec.name), + format!("Category: {}", crate::i18n::effects::category(spec.category)), + "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(), + ]; + let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); + } +} + /// The Effect Controls panel for the selected layer: a header with the comp and layer names, then /// one header per effect (Fx toggle, twirl, rename, Reset, About, label bar, reorder drag and its /// right-click menu) followed by that effect's custom editor and its property rows. Dragging @@ -99,32 +233,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let rename_id = egui::Id::unique(("ec-rename", g.uid)); let editing: Option = ctx.data(|d| d.get_temp(rename_id)); let reset = Rect::from_min_size(pos2(r.max.x - 96.0, r.center().y - 9.0), vec2(40.0, 18.0)); - if let Some(mut value) = editing.as_ref().cloned() { - let name_rect = Rect::from_min_max(pos2(tw.max.x + 6.0, r.min.y + 2.0), pos2(reset.min.x - 6.0, r.max.y - 2.0)); - let response = ui.put(name_rect, egui::TextEdit::singleline(&mut value).id(rename_id.with("field")).font(Tokens::semibold(12.0))); - // Focus the field the frame it first exists: focusing an id not yet on screen leaves - // the accessibility tree with a focus it doesn't contain. - if ctx.data_mut(|d| d.remove_temp::(rename_id.with("start"))).is_some() { - crate::widgets::select_all(&ctx, response.id, &value); - response.request_focus(); - } - app.auto.add(&format!("effectControls.effect.{}.nameEdit", g.uid), name_rect, "Effect name"); - if ctx.data_mut(|d| d.remove_temp::(rename_id.with("select"))).unwrap_or(false) { - widgets::select_all(&ctx, response.id, &value); - } - // Escape cancels. egui drops the field's focus on Escape in the same frame, so the - // key is checked on the focus being lost as well as held. - let escape = (response.has_focus() || response.lost_focus()) && ui.input(|i| i.key_pressed(egui::Key::Escape)); - let commit = response.lost_focus() || (response.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter))); - if escape || commit { - ctx.data_mut(|d| d.remove::(rename_id)); - // The field opens with the drawn name, so committing it unchanged is not a rename. - if !escape && !value.trim().is_empty() && value != shown { - actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": g.uid, "name": value}))); - } - } else { - ctx.data_mut(|d| d.insert_temp(rename_id, value)); - } + let hdr_cx = EffectHdr { g, shown: &shown, r, tw, reset, rename_id, layer_id: layer.id.0 }; + if let Some(value) = editing.as_ref().cloned() { + rename_field(app, ui, &hdr_cx, value, &mut actions); } else { let name = bp.text(pos2(tw.max.x + 6.0, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::semibold(12.0), t.text); app.auto.add(&format!("effectControls.effect.{}.name", g.uid), name, &g.name); @@ -135,23 +246,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if rresp.clicked() { actions.push(("effect.reset".into(), json!({"layer": layer.id.0, "effect": g.uid}))); } - // About…: the effect's name and category (AE's About dialog). - let about = Rect::from_min_size(pos2(r.max.x - 50.0, r.center().y - 9.0), vec2(44.0, 18.0)); - let aresp = ui.interact(about, egui::Id::unique(("ec-about", g.uid)), Sense::click()); - bp.text(about.center(), Align2::CENTER_CENTER, crate::i18n::tr("About..."), Tokens::ui(11.5), if aresp.hovered() { t.hot_text } else { t.text_dim }); - app.auto.add(&format!("effectControls.effect.{}.about", g.uid), about, "About"); - let apop = egui::Id::unique(("ec-about-pop", g.uid)); - if aresp.clicked() { - widgets::open_popup(ui, apop); - } - if let Some(spec) = effectcraft_engine::effects::find(effect) { - let lines = vec![ - crate::i18n::effects::name(spec.id, spec.name), - format!("Category: {}", crate::i18n::effects::category(spec.category)), - "EffectCraft built-in effect (MIT OR Apache-2.0)".to_string(), - ]; - let _ = widgets::popup_menu(ui, apop, about.left_bottom(), &lines, None); - } + about_button(app, ui, &bp, &t, effect, g, r); let hresp = ui.interact( Rect::from_min_max(pos2(tw.max.x, r.min.y), pos2(reset.min.x - 4.0, r.max.y)), egui::Id::unique(("ec-hdr", g.uid)), @@ -234,18 +329,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { if ui.input(|i| i.pointer.primary_pressed()) && ui.rect_contains_pointer(er) { actions.push(("prop.select".into(), json!({"layer":layer.id.0,"prop":g.uid}))); } - match effect.as_str() { - "ec.color.curves" => curves_editor(app, ui, &bp, &layer, g, &ectx, er, &mut actions), - effectcraft_engine::effects::warp_stab::ID => warp_editor(app, ui, &bp, &layer, g, er, &mut actions), - effectcraft_engine::effects::camera_tracker::ID => crate::panels::camera_tracker_ui::editor(app, ui, &bp, &layer, g, er, &mut actions), - effectcraft_engine::effects::roto::ID => roto_editor(app, ui, &bp, &layer, g, er, &mut actions), - effectcraft_engine::effects::object_matte::ID => object_matte_editor(app, ui, &bp, &layer, g, er, &mut actions), - "ec.color.levels" | "ec.color.levelsic" => levels_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions), - crate::panels::fx_editors::GLOW | crate::panels::fx_editors::RESHAPE | crate::panels::fx_editors::EXTRACTOR => { - crate::panels::fx_editors::header_editor(app, ui, &bp, &layer, g, effect, &ectx, er, &mut actions) - } - _ => histogram_only(app, ui, &bp, g, er), - } + effect_editor(app, ui, &EditorCx { bp: &bp, layer: &layer, ectx: &ectx }, g, er, &mut actions); } } let scope = FxScope { effect: effect.as_str(), root: g, path: String::new() }; @@ -264,25 +348,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { } y += 4.0; } - // Drag an effect header to reorder: insertion line between headers. if let Some(du) = dragging { - if let Some(pos) = ctx.pointer_interact_pos() { - let slot = tops.iter().position(|(_, ty)| pos.y < *ty + (ROW + 2.0) / 2.0).unwrap_or(tops.len()); - let ly = tops.get(slot).map(|(_, ty)| *ty).unwrap_or(y - 4.0); - bp.line_segment([pos2(body.min.x + 4.0, ly), pos2(body.max.x - 4.0, ly)], Stroke::new(2.0, t.accent)); - if ctx.input(|i| i.pointer.any_released()) { - if let Some(from) = tops.iter().position(|(u, _)| *u == du) { - let to = if slot > from { slot - 1 } else { slot }; - if to != from { - actions.push(("effect.reorder".into(), json!({"layer": layer.id.0, "effect": du, "index": to + 1}))); - } - } - ctx.data_mut(|d| d.remove::(drag_id)); - } - } - if !ctx.input(|i| i.pointer.any_down()) { - ctx.data_mut(|d| d.remove::(drag_id)); - } + reorder_drag(&ctx, &bp, body, &t, &ReorderDrag { tops: &tops, y, du, drag_id, layer_id: layer.id.0 }, &mut actions); } // Right-click anywhere a control has no menu of its own: the Effect menu, which applies to // the selected layers (After Effects). Drawn after the rows, whose menus open first. @@ -297,21 +364,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { ui.set_clip_rect(panel_clip); let content_h = y + scroll.offset - body.min.y; scroll.end(ui, &mut app.auto, "effectControls.scroll", content_h, &t); - // Drop effects and animation presets here. - if let Some(payload) = egui::DragAndDrop::payload::(&ctx) - && ui.rect_contains_pointer(rect) - && let Some((id, params)) = match payload.as_ref() { - crate::panels::DragPayload::Effect(e) => Some(("effect.apply", json!({"effect": e, "layers": [layer.id.0]}))), - crate::panels::DragPayload::Preset { preset, .. } => Some(preset.apply_to(layer.id.0)), - _ => None, - } - { - p.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), egui::StrokeKind::Inside); - if ctx.input(|i| i.pointer.any_released()) { - actions.push((id.into(), params)); - egui::DragAndDrop::clear_payload(&ctx); - } - } + drop_payload(ui, rect, &p, &t, layer.id.0, &mut actions); if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") { app.ui.viewer.property_interacting = true; ctx.request_repaint(); diff --git a/crates/ui-egui/src/panels/effect_controls/rows.rs b/crates/ui-egui/src/panels/effect_controls/rows.rs index 2ea1f6e0b..05fea44c9 100644 --- a/crates/ui-egui/src/panels/effect_controls/rows.rs +++ b/crates/ui-egui/src/panels/effect_controls/rows.rs @@ -1,27 +1,27 @@ use super::common::{Actions, DIAL_ROW, PARAM_NAME_X, ROW, TREE_INDENT, gesture_key}; -use super::*; +use super::{ + Align2, Color32, EffectcraftApp, EvalCtx, FxPick, Icon, Layer, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, Value, fw, icons, json, + pos2, vec2, widgets, +}; fn triangle(p: &egui::Painter, c: egui::Pos2, left: bool, col: Color32) { let (dx, h) = (if left { -3.5 } else { 3.5 }, 4.0); p.add(egui::Shape::convex_polygon(vec![pos2(c.x + dx, c.y), pos2(c.x - dx, c.y - h), pos2(c.x - dx, c.y + h)], col, Stroke::NONE)); } +/// The layer and whether the time is on a key, for the key navigator. +#[derive(Clone, Copy)] +struct KeyNav { + layer_id: u64, + on_key: bool, +} + /// ◀ ◆ ▶ at the right end of an animated parameter's row. -fn key_navigator( - app: &mut EffectcraftApp, - ui: &mut egui::Ui, - p: &egui::Painter, - layer: &Layer, - prop: &Property, - ectx: &EvalCtx, - r: Rect, - actions: &mut Actions, -) { +fn key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, nav: KeyNav, prop: &Property, r: Rect, actions: &mut Actions) { + let KeyNav { layer_id, on_key } = nav; let t = app.tokens; let cy = r.center().y; let uid = prop.uid; - let half = ectx.comp.frame_duration().0 / 2; - let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half); for (what, dx) in [("prev", -44.0), ("key", -32.0), ("next", -20.0)] { let cr = Rect::from_center_size(pos2(r.max.x + dx, cy), vec2(12.0, 16.0)); let resp = ui.interact(cr, egui::Id::unique(("ec-nav", uid, what)), Sense::click()); @@ -48,7 +48,7 @@ fn key_navigator( match what { "prev" => actions.push(("time.go".into(), json!({"to": "prevKey", "prop": uid}))), "next" => actions.push(("time.go".into(), json!({"to": "nextKey", "prop": uid}))), - _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid, "selected": true}))), + _ => actions.push(("prop.toggleKey".into(), json!({"layer": layer_id, "prop": uid, "selected": true}))), } } } @@ -71,6 +71,44 @@ pub(crate) fn toggle_pick(app: &mut EffectcraftApp, kind: &str, layer: &Layer, p } } +/// What the value controls of one property row share. +#[derive(Clone, Copy)] +struct PropCx<'a> { + p: &'a egui::Painter, + layer: &'a Layer, + group: &'a PropGroup, + prop: &'a Property, + r: Rect, + vx: f32, + cy: f32, + swr: Rect, + t: Tokens, + uid: u64, + scrub: bool, +} + +impl PropCx<'_> { + fn set(&self, actions: &mut Actions, v: serde_json::Value) { + let (layer, uid, merge, scrub) = (self.layer, self.uid, format!("ec-{}", self.uid), self.scrub); + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) + } + + fn twirl_open(&self, app: &mut EffectcraftApp, ui: &mut egui::Ui) { + let PropCx { swr, cy, uid, prop, t, .. } = self; + let (cy, uid) = (*cy, *uid); + let open = app.ui.fx_slider_open.contains(&uid); + let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); + if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), t).clicked() { + if open { + app.ui.fx_slider_open.remove(&uid); + } else { + app.ui.fx_slider_open.insert(uid); + } + } + app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); + } +} + /// One property row: stopwatch, name, value control (and the keyframe navigator). Returns the /// value fields of a vector, one per dimension, for the pick whip. #[allow(clippy::too_many_arguments)] @@ -115,7 +153,9 @@ fn prop_row( } } if prop.is_animated() { - key_navigator(app, ui, p, layer, prop, ectx, r, actions); + let half = ectx.comp.frame_duration().0 / 2; + let on_key = prop.keys.iter().any(|k| (layer.comp_time(k.time).0 - ectx.time.0).abs() <= half); + key_navigator(app, ui, p, KeyNav { layer_id: layer.id.0, on_key }, prop, r, actions); } let value = ectx.value(layer, prop); // Keep a shared value column while names and their icons follow the group hierarchy. @@ -150,260 +190,320 @@ fn prop_row( app.show_panel(crate::dock::PanelKind::Timeline); } app.auto.add(&format!("effectControls.prop.{uid}.name"), name_clip, &prop.name); - let merge = format!("ec-{uid}"); // With several layers selected the edit applies to the same effect property of each (#491). let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging()); - let set = |actions: &mut Actions, v: serde_json::Value| { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub}))) - }; - let twirl_open = |app: &mut EffectcraftApp, ui: &mut egui::Ui| { - let open = app.ui.fx_slider_open.contains(&uid); - let tw = Rect::from_center_size(pos2(swr.min.x - 9.0, cy), vec2(10.0, 10.0)); - if widgets::twirl(ui, tw, open, egui::Id::unique(("ec-stw", uid)), &t).clicked() { - if open { - app.ui.fx_slider_open.remove(&uid); - } else { - app.ui.fx_slider_open.insert(uid); - } - } - app.auto.add(&format!("effectControls.prop.{uid}.twirl"), tw, &prop.name); - }; + let cx = PropCx { p, layer, group, prop, r, vx, cy, swr, t, uid, scrub }; match &value { - Value::Scalar(v) if matches!(prop.ui, ParamUi::Mask) => { - // A mask of this layer ("Path" popup). - let mut opts = vec!["None".to_string()]; - if let Some(m) = layer.masks() { - opts.extend(m.groups().map(|g| g.name.clone())); - } - let i = v.round().max(0.0) as usize; - let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}")); - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); - let pop = egui::Id::unique(("ec-mpop", uid)); - if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) { - set(actions, json!(ni as f64)); - } - } - Value::Scalar(v) if matches!(prop.ui, ParamUi::Angle) => { - let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); - app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); - app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); - if let Some(nv) = nv { - set(actions, json!(nv)); - } - twirl_open(app, ui); - } - Value::Scalar(v) => { - let (min, max, dec) = match &prop.ui { - ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), - _ => (-1e9, 1e9, 1), - }; - let suffix = match prop.ui { - ParamUi::Percent => "%", - _ => "", - }; - let speed = widgets::slider_scrub_speed(&prop.ui); - let (vr, nv, _) = widgets::hot_number_at( - ui, - pos2(vx, cy - 9.0), - egui::Id::unique(("ec-v", uid)), - *v, - speed, - (min, max), - dec.max(if dec == 0 { 0 } else { 1 }), - suffix, - &t, - ); - app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); - if let Some(nv) = nv { - set(actions, json!(nv)); - } - // AE hides a slider param's slider until its twirl is opened (see `slider_row`). - if matches!(prop.ui, ParamUi::Slider { .. }) { - twirl_open(app, ui); - } - } - Value::Vec2(_) | Value::Vec3(_) => { - let c = value.components(); - let n = shown_dims(layer, prop, &value); - let mut x = vx; - if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { - let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); - let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point"); - if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t) - .on_hover_text(crate::i18n::tr("Click, then click in the Composition panel")) - .clicked() - { - toggle_pick(app, "point", layer, prop); - } - app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name); - x += 22.0; - } - for d in 0..n.min(c.len()) { - let (vr, nv, _) = widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("ec-v", uid, d)), c[d], 1.0, (-1e9, 1e9), 1, "", &t); - app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); - dims.push(vr); - if let Some(nv) = nv { - let mut nc = c.clone(); - nc[d] = nv; - set(actions, json!(nc)); - // Typed into this component only: other selected layers keep the rest of - // their own value (#1434). - if let Some((_, a)) = actions.last_mut() { - a["dims"] = json!([d]); - } - } - x = vr.max.x + 8.0; - } - } - Value::Color(c) => { - let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0)); - let pop = egui::Id::unique(("ec-cpop", uid)); - if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); - let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0)); - let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); - if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) - .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) - .clicked() - { - toggle_pick(app, "color", layer, prop); - } - app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); - let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; - if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { - set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); - } - } - Value::Bool(b) => { - let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); - if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() { - set(actions, json!(!b)); - } - p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); - app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name); - } - Value::Enum(i) => { - if let ParamUi::Popup { options } = &prop.ui { - let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max); - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0)); - let pop = egui::Id::unique(("ec-epop", uid)); - if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() { - widgets::open_popup(ui, pop); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) { - set(actions, json!(ni)); - } - } - } - Value::Layer(l) => { - let comp = app.session.active_comp_arc(); - if let Some(comp) = comp { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); - if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) { - set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); - // Source / Masks / Effects & Masks (the hidden companion parameter). - if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { - let si = src.value.as_enum() as usize; - let opts: Vec = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect(); - let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0)); - let spop = egui::Id::unique(("ec-lspop", uid)); - if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() { - widgets::open_popup(ui, spop); - } - app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name); - if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni}))); - } - } - } - } + Value::Scalar(_) if matches!(prop.ui, ParamUi::Mask) => value_mask(app, ui, &cx, &value, actions), + Value::Scalar(_) if matches!(prop.ui, ParamUi::Angle) => value_angle(app, ui, &cx, &value, actions), + Value::Scalar(_) => value_scalar(app, ui, &cx, &value, actions), + Value::Vec2(_) | Value::Vec3(_) => dims = value_vector(app, ui, &cx, &value, actions), + Value::Color(_) => value_color(app, ui, &cx, &value, actions), + Value::Bool(_) => value_toggle(app, ui, &cx, &value, actions), + Value::Enum(_) => value_popup(app, ui, &cx, &value, actions), + Value::Layer(_) => value_layer_ref(app, ui, &cx, &value, actions), // EXtractoR's red / green / blue / alpha: a popup of the footage's OpenEXR channels (#295). - Value::Str(s) if crate::panels::fx_editors::is_extractor_channel(group, prop) => { - let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); - crate::panels::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); - } + Value::Str(_) if crate::panels::fx_editors::is_extractor_channel(group, prop) => value_extractor(app, ui, &cx, &value, actions), // A file parameter (Apply Color LUT's LUT, an OCIO config): its file name and Choose…, // as in After Effects. - Value::Str(s) if matches!(prop.ui, ParamUi::File { .. }) => { - let bw = 70.0; - let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); - if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() { - let filters: Vec<&str> = match &prop.ui { - ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), - _ => vec![], - }; - match app.hooks.pick_files.as_ref() { - Some(pick) => { - if let Some(path) = pick(&filters).into_iter().next() { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); - } - } - None => app.ui.status = "no file dialog available: set the path with prop.set".into(), - } - } - app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); - let shown = if s.trim().is_empty() { - crate::i18n::tr("None").to_string() - } else if s.contains('\n') { - prop.name.clone() - } else { - effectcraft_engine::sequence::file_name(s) - }; - let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); - let np = p.with_clip_rect(nr.intersect(p.clip_rect())); - np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); - let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover()); - if !s.trim().is_empty() && !s.contains('\n') { - hover.on_hover_text(s.as_str()); - } - app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); - } + Value::Str(_) if matches!(prop.ui, ParamUi::File { .. }) => value_file(app, ui, &cx, &value, actions), // A text parameter (Cryptomatte's Selection, Colorama's Output Cycle, config names): // typed in a field, set when it loses the focus (Esc keeps the old text). - Value::Str(s) if matches!(prop.ui, ParamUi::Text) => { - let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); - let bid = egui::Id::unique(("ec-text", uid)); - let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.clone()); - let resp = widgets::text_field(ui, fr, &mut buf, "", &t); - // The field has no visible hint: give the screen reader the parameter's name. - resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); - app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); - if resp.lost_focus() { - if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { - actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); - } - ui.data_mut(|d| d.remove::(bid)); - } else if resp.has_focus() { - ui.data_mut(|d| d.insert_temp(bid, buf)); - } - } - Value::Gradient(g) => { - let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); - let n = 48; - for k in 0..n { - let f = k as f64 / (n - 1) as f64; - let c = gradient_at(g, f); - let x0 = gr.min.x + gr.width() * k as f32 / n as f32; - let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32; - p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c); - } - p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name); - } + Value::Str(_) if matches!(prop.ui, ParamUi::Text) => value_text(app, ui, &cx, &value, actions), + Value::Gradient(_) => value_gradient(app, &cx, &value), _ => {} } dims } +fn value_mask(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; + // A mask of this layer ("Path" popup). + let mut opts = vec!["None".to_string()]; + if let Some(m) = layer.masks() { + opts.extend(m.groups().map(|g| g.name.clone())); + } + let i = v.round().max(0.0) as usize; + let label = opts.get(i).cloned().unwrap_or_else(|| format!("Mask {i}")); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + let pop = egui::Id::unique(("ec-mpop", uid)); + if widgets::dropdown(ui, dr, &label, &t, egui::Id::unique(("ec-m", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), &opts, Some(i)) { + cx.set(actions, json!(ni as f64)); + } +} + +fn value_angle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let (rr, dr, nv) = fw::angle_field(ui, pos2(vx, cy - 9.0), egui::Id::unique(("ec-v", uid)), *v, 1, &t); + app.auto.add(&format!("effectControls.prop.{uid}.value"), rr.union(dr), &fw::format_angle(*v, 1)); + app.auto.add(&format!("effectControls.prop.{uid}.revolutions"), rr, &prop.name); + if let Some(nv) = nv { + cx.set(actions, json!(nv)); + } + cx.twirl_open(app, ui); +} + +fn value_scalar(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Scalar(v) = value else { + return; + }; + let PropCx { t, uid, cy, vx, prop, .. } = *cx; + let (min, max, dec) = match &prop.ui { + ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize), + _ => (-1e9, 1e9, 1), + }; + let suffix = match prop.ui { + ParamUi::Percent => "%", + _ => "", + }; + let speed = widgets::slider_scrub_speed(&prop.ui); + let (vr, nv, _) = widgets::hot_number_at( + ui, + pos2(vx, cy - 9.0), + egui::Id::unique(("ec-v", uid)), + *v, + &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(if dec == 0 { 0 } else { 1 }), suffix }, + &t, + ); + app.auto.add(&format!("effectControls.prop.{uid}.value"), vr, &prop.name); + if let Some(nv) = nv { + cx.set(actions, json!(nv)); + } + // AE hides a slider param's slider until its twirl is opened (see `slider_row`). + if matches!(prop.ui, ParamUi::Slider { .. }) { + cx.twirl_open(app, ui); + } +} + +fn value_vector(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) -> Vec { + let mut dims = vec![]; + let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; + let c = value.components(); + let n = shown_dims(layer, prop, value); + let mut x = vx; + if matches!(prop.ui, ParamUi::Point | ParamUi::Point3) { + let cr = Rect::from_center_size(pos2(x + 8.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "point"); + if fw::crosshair_button(ui, cr, active, egui::Id::unique(("ec-xh", uid)), &t) + .on_hover_text(crate::i18n::tr("Click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, "point", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.crosshair"), cr, &prop.name); + x += 22.0; + } + for d in 0..n.min(c.len()) { + let (vr, nv, _) = widgets::hot_number_at( + ui, + pos2(x, cy - 9.0), + egui::Id::unique(("ec-v", uid, d)), + c[d], + &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" }, + &t, + ); + app.auto.add(&format!("effectControls.prop.{uid}.value.{d}"), vr, &prop.name); + dims.push(vr); + if let Some(nv) = nv { + let mut nc = c.clone(); + nc[d] = nv; + cx.set(actions, json!(nc)); + // Typed into this component only: other selected layers keep the rest of + // their own value (#1434). + if let Some((_, a)) = actions.last_mut() { + a["dims"] = json!([d]); + } + } + x = vr.max.x + 8.0; + } + dims +} + +fn value_color(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Color(c) = value else { + return; + }; + let PropCx { t, uid, cy, vx, layer, prop, .. } = *cx; + let sr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(30.0, 16.0)); + let pop = egui::Id::unique(("ec-cpop", uid)); + if widgets::swatch(ui, sr, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("ec-c", uid)), &t).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), sr, &prop.name); + let er = Rect::from_center_size(pos2(sr.max.x + 14.0, cy), vec2(18.0, 18.0)); + let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color"); + if fw::eyedropper_button(ui, er, active, egui::Id::unique(("ec-eye", uid)), &t) + .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel")) + .clicked() + { + toggle_pick(app, "color", layer, prop); + } + app.auto.add(&format!("effectControls.prop.{uid}.eyedropper"), er, &prop.name); + let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32]; + if crate::widgets::color_popup(ui, pop, sr.left_bottom(), &mut rgb) { + cx.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0])); + } +} + +fn value_toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Bool(b) = value else { + return; + }; + let PropCx { t, uid, cy, vx, p, prop, .. } = *cx; + let cr = Rect::from_min_size(pos2(vx, cy - 8.0), vec2(16.0, 16.0)); + if widgets::checkbox(ui, cr, *b, &t, egui::Id::unique(("ec-b", uid))).clicked() { + cx.set(actions, json!(!b)); + } + p.text(pos2(cr.max.x + 6.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(12.0), t.text_dim); + app.auto.add(&format!("effectControls.prop.{uid}.value"), cr, &prop.name); +} + +fn value_popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Enum(i) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx; + if let ParamUi::Popup { options } = &prop.ui { + let widest = options.iter().map(|o| p.layout_no_wrap(o.clone(), Tokens::ui(11.5), t.text).size().x).fold(0.0, f32::max); + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((widest + 30.0).clamp(80.0, (r.max.x - vx - 56.0).max(80.0)), 18.0)); + let pop = egui::Id::unique(("ec-epop", uid)); + if widgets::dropdown(ui, dr, options.get(*i as usize).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-e", uid))).clicked() { + widgets::open_popup(ui, pop); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + if let Some(ni) = widgets::popup_menu(ui, pop, dr.left_bottom(), options, Some(*i as usize)) { + cx.set(actions, json!(ni)); + } + } +} + +fn value_layer_ref(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Layer(l) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, group, .. } = *cx; + let comp = app.session.active_comp_arc(); + if let Some(comp) = comp { + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(((r.max.x - vx) * 0.55).clamp(90.0, 170.0), 18.0)); + if let Some(chosen) = widgets::layer_picker(ui, dr, &comp.layers, *l, &t, egui::Id::unique(("ec-l", uid))) { + cx.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null)); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), dr, &prop.name); + // Source / Masks / Effects & Masks (the hidden companion parameter). + if let Some(src) = group.get(&effectcraft_engine::effects::layer_source_id(&prop.match_id)) { + let si = src.value.as_enum() as usize; + let opts: Vec = effectcraft_engine::effects::LAYER_SOURCE_OPTIONS.iter().map(|s| s.to_string()).collect(); + let sr = Rect::from_min_size(pos2(dr.max.x + 6.0, cy - 9.0), vec2(((r.max.x - dr.max.x) - 64.0).clamp(70.0, 120.0), 18.0)); + let spop = egui::Id::unique(("ec-lspop", uid)); + if widgets::dropdown(ui, sr, opts.get(si).map(String::as_str).unwrap_or(""), &t, egui::Id::unique(("ec-ls", uid))).clicked() { + widgets::open_popup(ui, spop); + } + app.auto.add(&format!("effectControls.prop.{uid}.source"), sr, &src.name); + if let Some(ni) = widgets::popup_menu(ui, spop, sr.left_bottom(), &opts, Some(si)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": src.uid, "value": ni}))); + } + } + } +} + +fn value_extractor(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { cy, vx, r, layer, prop, .. } = *cx; + let dr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 56.0).clamp(80.0, 200.0), 18.0)); + crate::panels::fx_editors::channel_popup(app, ui, layer, prop, s, dr, actions); +} + +fn value_file(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, layer, prop, .. } = *cx; + let bw = 70.0; + let br = Rect::from_min_size(pos2(vx, cy - 9.0), vec2(bw, 18.0)); + if widgets::text_button(ui, br, crate::i18n::tr("Choose..."), false, &t, egui::Id::unique(("ec-file", uid))).clicked() { + let filters: Vec<&str> = match &prop.ui { + ParamUi::File { filters } => filters.iter().map(String::as_str).collect(), + _ => vec![], + }; + match app.hooks.pick_files.as_ref() { + Some(pick) => { + if let Some(path) = pick(&filters).into_iter().next() { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": path}))); + } + } + None => app.ui.status = "no file dialog available: set the path with prop.set".into(), + } + } + app.auto.add(&format!("effectControls.prop.{uid}.choose"), br, &prop.name); + let shown = if s.trim().is_empty() { + crate::i18n::tr("None").to_string() + } else if s.contains('\n') { + prop.name.clone() + } else { + effectcraft_engine::sequence::file_name(s) + }; + let nr = Rect::from_min_max(pos2(br.max.x + 8.0, r.min.y), pos2(r.max.x - 8.0, r.max.y)); + let np = p.with_clip_rect(nr.intersect(p.clip_rect())); + np.text(pos2(nr.min.x, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), if s.trim().is_empty() { t.text_dim } else { t.text }); + let hover = ui.interact(nr, egui::Id::unique(("ec-file-name", uid)), Sense::hover()); + if !s.trim().is_empty() && !s.contains('\n') { + hover.on_hover_text(s); + } + app.auto.add(&format!("effectControls.prop.{uid}.value"), nr, &shown); +} + +fn value_text(app: &mut EffectcraftApp, ui: &mut egui::Ui, cx: &PropCx, value: &Value, actions: &mut Actions) { + let Value::Str(s) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, layer, prop, .. } = *cx; + let fr = Rect::from_min_size(pos2(vx, cy - 9.0), vec2((r.max.x - vx - 12.0).clamp(80.0, 320.0), 18.0)); + let bid = egui::Id::unique(("ec-text", uid)); + let mut buf: String = ui.data(|d| d.get_temp(bid)).unwrap_or_else(|| s.to_owned()); + let resp = widgets::text_field(ui, fr, &mut buf, "", &t); + // The field has no visible hint: give the screen reader the parameter's name. + resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TextInput, ui.is_enabled(), &prop.name)); + app.auto.add(&format!("effectControls.prop.{uid}.value"), fr, &prop.name); + if resp.lost_focus() { + if buf != *s && !ui.input(|i| i.key_pressed(egui::Key::Escape)) { + actions.push(("prop.set".into(), json!({"layer": layer.id.0, "prop": uid, "value": buf}))); + } + ui.data_mut(|d| d.remove::(bid)); + } else if resp.has_focus() { + ui.data_mut(|d| d.insert_temp(bid, buf)); + } +} + +fn value_gradient(app: &mut EffectcraftApp, cx: &PropCx, value: &Value) { + let Value::Gradient(g) = value else { + return; + }; + let PropCx { t, uid, cy, vx, r, p, prop, .. } = *cx; + let gr = Rect::from_min_size(pos2(vx, cy - 7.0), vec2((r.max.x - vx - 56.0).clamp(60.0, 180.0), 14.0)); + let n = 48; + for k in 0..n { + let f = k as f64 / (n - 1) as f64; + let c = gradient_at(g, f); + let x0 = gr.min.x + gr.width() * k as f32 / n as f32; + let x1 = gr.min.x + gr.width() * (k + 1) as f32 / n as f32; + p.rect_filled(Rect::from_min_max(pos2(x0, gr.min.y), pos2(x1, gr.max.y)), 0.0, c); + } + p.rect_stroke(gr, 2.0, Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); + app.auto.add(&format!("effectControls.prop.{uid}.value"), gr, &prop.name); +} + /// How many of a vector parameter's values show (a 3D point's Z only on a 3D layer). fn shown_dims(layer: &Layer, prop: &Property, value: &Value) -> usize { let n = value.components().len(); diff --git a/crates/ui-egui/src/panels/effect_controls/viewer.rs b/crates/ui-egui/src/panels/effect_controls/viewer.rs index e71632180..b11b63a35 100644 --- a/crates/ui-egui/src/panels/effect_controls/viewer.rs +++ b/crates/ui-egui/src/panels/effect_controls/viewer.rs @@ -1,6 +1,10 @@ +use std::ops::ControlFlow; + use super::common::{Actions, effect_active, gesture_key}; use super::rows::FxScope; -use super::*; +use super::{ + Align2, Color32, EffectcraftApp, EvalCtx, GroupKind, Layer, Mat3, Node, ParamUi, PropGroup, Property, Rect, Sense, Stroke, Tokens, fw, json, vec2, +}; // --------------------------------------------------------------------------------------------- // Viewer hook @@ -22,6 +26,156 @@ fn visible_points<'a>(g: &'a PropGroup, scope: &FxScope<'_>, layer: &Layer, ectx } } +/// An armed crosshair / eyedropper pick: the hint chip, the cursor preview, and the click that +/// sets or samples. `Break` when the picked layer is gone; `Continue` carries the point preview. +fn fx_pick_session( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &crate::panels::viewer::ViewerMap, + ectx: &EvalCtx, + l2c: &dyn Fn(&EvalCtx, &Layer) -> Mat3, + actions: &mut Actions, +) -> ControlFlow<(), Option> { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let mut point_preview = None; + let Some(pick) = app.ui.fx_pick.clone() else { + return ControlFlow::Continue(None); + }; + let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { + app.ui.fx_pick = None; + return ControlFlow::Break(()); + }; + // Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`. + let keyer = (pick.kind == "color") + .then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some())) + .flatten() + .filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying")) + .map(|g| g.uid); + if pick.kind == "color" && keyer.is_none() { + // GPU frames: read the shown frame back for sampling. + app.viewer_pixels(); + } + let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click()); + app.auto.add("viewer.fxPick", map.area, &pick.name); + let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) }; + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text); + if let Some(hp) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(hp); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() { + point_preview = Some(hp); + } + if pick.kind == "color" + && keyer.is_none() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + // No frame to sample yet (it is still rendering, or its readback is pending): the click + // leaves the eyedropper armed instead of using it up without a colour. + let unsampled = pick.kind == "color" && keyer.is_none() && app.viewer_image.is_none(); + if resp.clicked() + && !unsampled + && let Some(pos) = resp.interact_pointer_pos() + { + let c = map.to_comp(pos); + // Effect points and the keyer's input are in effect space. + let m = crate::panels::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer)); + if pick.kind == "point" { + if let Some(lp) = fw::comp_to_layer(&m, c) { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); + } + } else if let Some(fx) = keyer { + // Ctrl/Cmd+click averages the 5 × 5 pixels around the point. + if let Some(lp) = fw::comp_to_layer(&m, c) { + let average = ui.input(|i| i.modifiers.command); + actions.push(( + "effect.pickColor".into(), + json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}), + )); + } + } else if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]}))); + } + app.ui.fx_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.fx_pick = None; + app.ui.status.clear(); + } + + ControlFlow::Continue(point_preview) +} + +/// An armed text-colour pick: sample the shown frame on click, Esc cancels. +fn text_color_pick( + app: &mut EffectcraftApp, + ui: &mut egui::Ui, + painter: &egui::Painter, + map: &crate::panels::viewer::ViewerMap, + ectx: &EvalCtx, + actions: &mut Actions, +) { + let t = app.tokens; + let ctx = ui.ctx().clone(); + let Some(pick) = app.ui.text_color_pick.clone() else { + return; + }; + if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } else { + app.viewer_pixels(); + let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click()); + app.auto.add("viewer.textColorPick", map.area, &pick.field); + let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); + painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); + painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); + if let Some(pos) = resp.hover_pos() { + ctx.set_cursor_icon(egui::CursorIcon::Crosshair); + let c = map.to_comp(pos); + app.pointer_comp = Some([c[0] as f32, c[1] as f32]); + if let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) + { + let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); + painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); + painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); + } + } + if resp.clicked() + && let Some(pos) = resp.interact_pointer_pos() + && let Some(img) = &app.viewer_image + && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) + { + let mut params = json!({"layer": pick.layer}); + params[&pick.field] = json!([s[0], s[1], s[2]]); + if let Some(range) = pick.range { + params["range"] = json!(range); + } + actions.push(("layer.setText".into(), params)); + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + app.ui.text_color_pick = None; + app.ui.status.clear(); + } + } +} + /// Effect Controls' part of the Composition viewer, called by the viewer after its own /// interaction (so these controls sit on top): /// @@ -42,124 +196,11 @@ pub fn viewer_hook( let t = app.tokens; let ctx = ui.ctx().clone(); let mut actions: Actions = vec![]; - let mut point_preview = None; - if let Some(pick) = app.ui.fx_pick.clone() { - let Some(layer) = ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).cloned() else { - app.ui.fx_pick = None; - return; - }; - // Keyers pick from their input (the shown frame is already keyed): `effect.pickColor`. - let keyer = (pick.kind == "color") - .then(|| layer.effects()?.groups().find(|g| g.find(pick.prop).is_some())) - .flatten() - .filter(|g| effectcraft_engine::effects::find(&g.match_id).is_some_and(|s| s.category == "Keying")) - .map(|g| g.uid); - if pick.kind == "color" && keyer.is_none() { - // GPU frames: read the shown frame back for sampling. - app.viewer_pixels(); - } - let resp = ui.interact(map.area, egui::Id::unique("viewer-fx-pick"), Sense::click()); - app.auto.add("viewer.fxPick", map.area, &pick.name); - let hint = if pick.kind == "point" { format!("Click to set {}", pick.name) } else { format!("Click to sample {}", pick.name) }; - let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); - painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); - painter.text(chip.center(), Align2::CENTER_CENTER, hint, Tokens::ui(11.5), t.text); - if let Some(hp) = resp.hover_pos() { - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - let c = map.to_comp(hp); - app.pointer_comp = Some([c[0] as f32, c[1] as f32]); - if pick.kind == "point" && fw::comp_to_layer(&l2c(ectx, &layer), c).is_some() { - point_preview = Some(hp); - } - if pick.kind == "color" - && keyer.is_none() - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - let sw = Rect::from_min_size(hp + vec2(14.0, 14.0), vec2(26.0, 18.0)); - painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); - painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); - } - } - // No frame to sample yet (it is still rendering, or its readback is pending): the click - // leaves the eyedropper armed instead of using it up without a colour. - let unsampled = pick.kind == "color" && keyer.is_none() && app.viewer_image.is_none(); - if resp.clicked() - && !unsampled - && let Some(pos) = resp.interact_pointer_pos() - { - let c = map.to_comp(pos); - // Effect points and the keyer's input are in effect space. - let m = crate::panels::viewer::from_effect_space(ectx, &layer, l2c(ectx, &layer)); - if pick.kind == "point" { - if let Some(lp) = fw::comp_to_layer(&m, c) { - actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [lp[0], lp[1]]}))); - } - } else if let Some(fx) = keyer { - // Ctrl/Cmd+click averages the 5 × 5 pixels around the point. - if let Some(lp) = fw::comp_to_layer(&m, c) { - let average = ui.input(|i| i.modifiers.command); - actions.push(( - "effect.pickColor".into(), - json!({"layer": pick.layer, "effect": fx, "prop": pick.prop, "x": lp[0], "y": lp[1], "average": average}), - )); - } - } else if let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - actions.push(("prop.set".into(), json!({"layer": pick.layer, "prop": pick.prop, "value": [s[0], s[1], s[2], 1.0]}))); - } - app.ui.fx_pick = None; - app.ui.status.clear(); - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.fx_pick = None; - app.ui.status.clear(); - } - } - if let Some(pick) = app.ui.text_color_pick.clone() { - if ectx.comp.layer(effectcraft_engine::project::LayerId(pick.layer)).is_none() { - app.ui.text_color_pick = None; - app.ui.status.clear(); - } else { - app.viewer_pixels(); - let resp = ui.interact(map.area, egui::Id::unique("viewer-text-color-pick"), Sense::click()); - app.auto.add("viewer.textColorPick", map.area, &pick.field); - let chip = Rect::from_min_size(map.area.min + vec2(10.0, 10.0), vec2(220.0, 22.0)); - painter.rect_filled(chip, 11.0, Color32::from_black_alpha(180)); - painter.text(chip.center(), Align2::CENTER_CENTER, crate::i18n::tr_args("Click to sample text {}", &[&pick.field]), Tokens::ui(11.5), t.text); - if let Some(pos) = resp.hover_pos() { - ctx.set_cursor_icon(egui::CursorIcon::Crosshair); - let c = map.to_comp(pos); - app.pointer_comp = Some([c[0] as f32, c[1] as f32]); - if let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), c) - { - let sw = Rect::from_min_size(pos + vec2(14.0, 14.0), vec2(26.0, 18.0)); - painter.rect_filled(sw, 2.0, Color32::from_rgb((s[0] * 255.0) as u8, (s[1] * 255.0) as u8, (s[2] * 255.0) as u8)); - painter.rect_stroke(sw, 2.0, Stroke::new(1.0, Color32::WHITE), egui::StrokeKind::Outside); - } - } - if resp.clicked() - && let Some(pos) = resp.interact_pointer_pos() - && let Some(img) = &app.viewer_image - && let Some(s) = fw::sample_frame(img, crate::panels::viewer_tools::shown_region(&ctx, map.comp), map.to_comp(pos)) - { - let mut params = json!({"layer": pick.layer}); - params[&pick.field] = json!([s[0], s[1], s[2]]); - if let Some(range) = pick.range { - params["range"] = json!(range); - } - actions.push(("layer.setText".into(), params)); - app.ui.text_color_pick = None; - app.ui.status.clear(); - } - if ui.input(|i| i.key_pressed(egui::Key::Escape)) { - app.ui.text_color_pick = None; - app.ui.status.clear(); - } - } - } + let point_preview = match fx_pick_session(app, ui, painter, map, ectx, l2c, &mut actions) { + ControlFlow::Break(()) => return, + ControlFlow::Continue(p) => p, + }; + text_color_pick(app, ui, painter, map, ectx, &mut actions); let picking = app.ui.fx_pick.is_some() || app.ui.text_color_pick.is_some(); let point_pick = app.ui.fx_pick.clone().filter(|p| p.kind == "point"); if app.ui.viewer.show_layer_controls || point_pick.is_some() { diff --git a/crates/ui-egui/src/panels/effects_presets.rs b/crates/ui-egui/src/panels/effects_presets.rs index fa7d2ac40..380d1d869 100644 --- a/crates/ui-egui/src/panels/effects_presets.rs +++ b/crates/ui-egui/src/panels/effects_presets.rs @@ -264,32 +264,13 @@ fn user_presets(ctx: &egui::Context, force: bool, scan: impl FnOnce() -> Vec
 bool {
     // Contents menu.
     let mr = Rect::from_center_size(pos2(rect.max.x - 18.0, sr.center().y), vec2(22.0, 22.0));
     let mpop = egui::Id::unique("fx-contents-menu");
-    if widgets::icon_button(ui, mr, Icon::Hamburger, false, &t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
+    if widgets::icon_button(ui, mr, Icon::Hamburger, false, t, egui::Id::unique("fx-menu")).on_hover_text(tr("Effects & Presets options")).clicked() {
         widgets::open_popup(ui, mpop);
     }
     app.auto.add("effects.menu", mr, "Effects & Presets options");
@@ -339,7 +320,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let v = &mut app.ui.effects_view;
         match i {
             i if menu.get(i) == Some(©_label) => {
-                if let Err(e) = crate::menus::invoke(app, &ctx, "effects.copyPresetsFolderPath", json!({})) {
+                if let Err(e) = crate::menus::invoke(app, ctx, "effects.copyPresetsFolderPath", json!({})) {
                     app.ui.status = e;
                 }
             }
@@ -354,6 +335,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             _ => {}
         }
     }
+    force_scan
+}
+
+/// The Effects & Presets panel: search, a contents menu (colour-depth and GPU filters, whether
+/// presets show, categories or one alphabetical list, refresh and clear of the recently used
+/// list), then Favourites, Recently Used, Animation Presets and the effects by category or
+/// alphabetically, filtered by the query. Picking an effect or preset applies it to the selection
+/// (`effect.apply` or the preset's own command); applying an effect also records it as recently
+/// used and opens Effect Controls. Without a selected layer it only reports that in
+/// `app.ui.status`.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let p = ui.painter().clone();
+    // Track the last applied effect (menus, drag and drop, the control channel) as recently used.
+    if let Some(last) = app.session.state.last_effect.clone()
+        && app.ui.effects_recent.first() != Some(&last)
+    {
+        push_recent(&mut app.ui.effects_recent, &last);
+    }
+    let sr = Rect::from_min_size(rect.min + vec2(8.0, 8.0), vec2(rect.width() - 44.0, 24.0));
+    let mut q = app.ui.effects_search.clone();
+    widgets::search_field(ui, sr, &mut q, "Search effects and presets", &t);
+    app.ui.effects_search = q.clone();
+    app.auto.add("effects.search", sr, "Search");
+    let force_scan = contents_menu(app, ui, &ctx, rect, sr, &t);
     let view = app.ui.effects_view.clone();
     let list = Rect::from_min_max(pos2(rect.min.x, sr.max.y + 6.0), rect.max);
     let lp = p.with_clip_rect(list);
diff --git a/crates/ui-egui/src/panels/expr_editor.rs b/crates/ui-egui/src/panels/expr_editor.rs
index f342d5636..157288b94 100644
--- a/crates/ui-egui/src/panels/expr_editor.rs
+++ b/crates/ui-egui/src/panels/expr_editor.rs
@@ -331,8 +331,16 @@ fn layout(text: &str, font: FontId, base: Color32, sx: &Syntax, p: &Scripting, b
     job
 }
 
+/// The scripting preferences, text colour and row count an [`editor`] is drawn with.
+pub struct EditorOpts<'a> {
+    pub prefs: &'a Scripting,
+    pub color: Color32,
+    pub rows: usize,
+}
+
 /// The editor: returns the response; `buf` is edited in place.
-pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, p: &Scripting, color: Color32, rows: usize, t: &Tokens) -> egui::Response {
+pub fn editor(ui: &mut egui::Ui, id: egui::Id, buf: &mut String, rect: Rect, opts: EditorOpts, t: &Tokens) -> egui::Response {
+    let EditorOpts { prefs: p, color, rows } = opts;
     let size = (p.editor_font_size.clamp(8, 40) as f32) * 0.9;
     let font = Tokens::mono(size);
     // Line numbers in a gutter on the left.
diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs
index 67128d04d..e121d5d02 100644
--- a/crates/ui-egui/src/panels/forms.rs
+++ b/crates/ui-egui/src/panels/forms.rs
@@ -206,6 +206,280 @@ fn value_fields(name: &str, v: &effectcraft_engine::keyframe::Value) -> Option (String, Vec) {
+    let names: Vec<(String, Value)> =
+        s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
+    let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
+    let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
+    (
+        "New Composition from Selection".into(),
+        vec![
+            Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
+            Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
+            Field::num("duration", "Still Duration (seconds)", still),
+            Field::bool("addToRenderQueue", "Add to Render Queue", false),
+            Field::bool("sequence", "Sequence Layers", false),
+            Field::bool("overlap", "Overlap", false),
+            Field::num("overlapDuration", "Duration (seconds)", 1.0),
+            Field::choice(
+                "transition",
+                "Transition",
+                &[("Off", json!("off")), ("Dissolve Front Layer", json!("dissolveFront")), ("Cross Dissolve Front and Back Layers", json!("crossDissolve"))],
+                0,
+            ),
+        ],
+    )
+}
+
+/// The Interpret Footage dialog for the selected footage (or its proxy), if there is one.
+fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> {
+    let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
+        (_, Some(px)) if proxy => Some(px.footage.clone()),
+        (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
+        _ => None,
+    });
+    let f = f?;
+    let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
+    let mut fields = vec![
+        // Main Options ▸ Alpha.
+        Field::choice(
+            "alpha",
+            "Alpha",
+            &[
+                ("Interpret Straight - Unmatted", json!("straight")),
+                ("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
+                ("Ignore", json!("ignore")),
+                ("Guess", json!("guess")),
+            ],
+            f.alpha as usize,
+        ),
+        Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
+        Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
+        // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
+        Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
+        Field::choice(
+            "fields",
+            "Separate Fields",
+            &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
+            f.fields as usize,
+        ),
+        Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
+        Field::num("loop", "Loop (times)", f.loop_count as f64),
+        // Color.
+        Field::choice(
+            "colorProfile",
+            "Assign Profile",
+            &[
+                ("Embedded / sRGB", json!("auto")),
+                ("sRGB IEC61966-2.1", json!("srgb")),
+                ("HDTV (Rec. 709)", json!("rec709")),
+                ("Rec. 2020", json!("rec2020")),
+                ("Display P3", json!("p3")),
+                ("ACEScg", json!("acescg")),
+                ("ACES2065-1", json!("aces2065")),
+                ("Rec. 2100 PQ", json!("rec2100pq")),
+                ("Rec. 2100 HLG", json!("rec2100hlg")),
+            ],
+            f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
+        ),
+        Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
+        Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
+    ];
+    // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
+    // After Effects), then Start Timecode.
+    let mut timing = Vec::new();
+    if f.kind == effectcraft_engine::project::FootageKind::Sequence {
+        let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
+            _ if f.alphabetical => "files in alphabetical order".into(),
+            Some(gaps) => format!("{gaps} shown as color bars"),
+            None => "no missing frames".into(),
+        };
+        timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
+    }
+    if f.kind != effectcraft_engine::project::FootageKind::Still {
+        timing.push(Field::choice(
+            "overrideStart",
+            "Start Timecode",
+            &[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
+            usize::from(f.start_timecode.is_some()),
+        ));
+        timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
+    }
+    let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
+    fields.splice(at..at, timing);
+    Some((if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields))
+}
+
+/// The Project Settings dialog's title and fields.
+fn project_settings_form(s: &effectcraft_engine::Session) -> (String, Vec) {
+    let st = &s.project.settings;
+    // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
+    // tr_args: wrapping the assembled string in tr() would look up the backend's name.
+    let gpu_label = match &s.accel {
+        Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
+        None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
+    };
+    let depth = match st.bit_depth.label() {
+        l if l.starts_with("16") => 1,
+        l if l.starts_with("32") => 2,
+        _ => 0,
+    };
+    (
+        "Project Settings".into(),
+        vec![
+            Field::choice(
+                "bitDepth",
+                "Depth",
+                &[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
+                depth,
+            ),
+            Field::choice(
+                "timeDisplay",
+                "Time display style",
+                &[
+                    ("Timecode", json!("timecode")),
+                    ("Frames", json!("frames")),
+                    ("Feet + Frames (35mm)", json!("feet35")),
+                    ("Feet + Frames (16mm)", json!("feet16")),
+                ],
+                {
+                    use effectcraft_engine::project::TimeDisplayStyle as T;
+                    match st.time_display {
+                        T::Timecode => 0,
+                        T::Frames => 1,
+                        T::Feet35 => 2,
+                        T::Feet16 => 3,
+                    }
+                },
+            ),
+            // Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
+            Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
+            Field::choice(
+                "colorEngine",
+                "Color engine",
+                &[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
+                usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
+            ),
+            Field::choice(
+                "workingSpace",
+                "Working space",
+                &[
+                    ("None", json!("none")),
+                    ("sRGB IEC61966-2.1", json!("srgb")),
+                    ("HDTV (Rec. 709)", json!("rec709")),
+                    ("Rec. 2020", json!("rec2020")),
+                    ("Display P3", json!("p3")),
+                    ("ACEScg", json!("acescg")),
+                    ("ACES2065-1", json!("aces2065")),
+                ],
+                st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
+            ),
+            // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
+            Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
+            Field::bool("linearize", "Linearize working space", st.linearize),
+            Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
+            Field::choice(
+                "hdr",
+                "HDR on SDR displays and outputs",
+                &[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
+                st.hdr as usize,
+            ),
+            Field::choice(
+                "outputSpace",
+                "Output color space",
+                &[
+                    ("sRGB IEC61966-2.1 (default)", json!("default")),
+                    ("HDTV (Rec. 709)", json!("rec709")),
+                    ("Rec. 2020", json!("rec2020")),
+                    ("Display P3", json!("p3")),
+                    ("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
+                    ("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
+                ],
+                {
+                    use effectcraft_engine::project::ColorSpace as C;
+                    match st.output_space {
+                        Some(C::Rec709) => 1,
+                        Some(C::Rec2020) => 2,
+                        Some(C::DisplayP3) => 3,
+                        Some(C::Rec2100Pq) => 4,
+                        Some(C::Rec2100Hlg) => 5,
+                        _ => 0,
+                    }
+                },
+            ),
+            // Video Rendering and Effects ▸ Use.
+            Field::choice(
+                "renderer",
+                "Video rendering and effects",
+                &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
+                usize::from(!st.gpu_acceleration),
+            ),
+        ],
+    )
+}
+
+/// The Export Adobe Premiere Pro Project dialog's title and fields.
+fn export_timeline_form(s: &effectcraft_engine::Session) -> (String, Vec) {
+    let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
+    let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
+    let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
+    (
+        "Export Adobe Premiere Pro Project".into(),
+        vec![
+            Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
+            Field::save_path("path", "File", &path),
+            Field::choice(
+                "format",
+                "Format",
+                &[
+                    ("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
+                    ("FCPXML (.fcpxml)", json!("fcpxml")),
+                    ("OpenTimelineIO (.otio)", json!("otio")),
+                    ("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
+                    ("AAF (.aaf)", json!("aaf")),
+                    ("OMF (.omf, audio)", json!("omf")),
+                ],
+                0,
+            ),
+            Field::choice(
+                "prerender",
+                "Pre-render (ProRes 4444)",
+                &[
+                    ("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
+                    ("All layers", json!("all")),
+                    ("None (leave them out)", json!("none")),
+                ],
+                0,
+            ),
+            Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
+        ],
+    )
+}
+
+/// The Layer ▸ Transform value dialog for the property named by `p` (default Position).
+fn transform_form(p: &Value, layer: Option<&effectcraft_engine::project::Layer>, lt: effectcraft_engine::time::Tick) -> (String, Vec) {
+    let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
+    let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
+    let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
+    let three = layer.is_some_and(|l| l.is_3d());
+    let title = match prop {
+        "anchor" => "Anchor Point",
+        "position" => "Position",
+        "scale" => "Scale",
+        "orientation" => "Orientation",
+        "rotation" => "Rotation",
+        _ => "Opacity",
+    };
+    let fields = match prop {
+        "rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
+        "orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
+        _ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
+        _ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
+    };
+    (title.into(), fields)
+}
+
 /// If `id` is a dialog command invoked without its parameters, open its form and return true.
 pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
     let s = &app.session;
@@ -281,27 +555,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             let Some(fields) = value_fields(&pr.name, &pr.value_at(l.layer_time(t))) else { return false };
             (pr.name.clone(), fields)
         }
-        "layer.setTransform" if !has(p, &["value"]) => {
-            let prop = p.get("prop").and_then(Value::as_str).unwrap_or("position");
-            let cur = layer.and_then(|l| l.transform()).and_then(|tr| tr.get(if prop == "anchorPoint" { "anchor" } else { prop })).map(|pr| pr.value_at(lt));
-            let v = cur.map(|c| c.as_vec3()).unwrap_or([0.0; 3]);
-            let three = layer.is_some_and(|l| l.is_3d());
-            let title = match prop {
-                "anchor" => "Anchor Point",
-                "position" => "Position",
-                "scale" => "Scale",
-                "orientation" => "Orientation",
-                "rotation" => "Rotation",
-                _ => "Opacity",
-            };
-            let fields = match prop {
-                "rotation" | "opacity" => vec![Field::num("value", if prop == "rotation" { "Degrees" } else { "Opacity (%)" }, v[0])],
-                "orientation" => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
-                _ if three => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1]), Field::num("value[2]", "Z", v[2])],
-                _ => vec![Field::num("value[0]", "X", v[0]), Field::num("value[1]", "Y", v[1])],
-            };
-            (title.into(), fields)
-        }
+        "layer.setTransform" if !has(p, &["value"]) => transform_form(p, layer, lt),
         "path.freeTransform" if !has(p, &["scale", "rotation", "offset"]) => (
             "Free Transform Points".into(),
             vec![
@@ -377,32 +631,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
         "file.newCompFromSelection"
             if s.state.project_selection.len() > 1 && !has(p, &["single", "dimensionsFrom", "duration", "sequence", "addToRenderQueue"]) =>
         {
-            let names: Vec<(String, Value)> =
-                s.state.project_selection.iter().enumerate().filter_map(|(i, id)| s.project.item(*id).map(|it| (it.name.clone(), json!(i)))).collect();
-            let names: Vec<(&str, Value)> = names.iter().map(|(n, v)| (n.as_str(), v.clone())).collect();
-            let still = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 };
-            (
-                "New Composition from Selection".into(),
-                vec![
-                    Field::choice("single", "Create", &[("Single Composition", json!(true)), ("Multiple Compositions", json!(false))], 0),
-                    Field::choice("dimensionsFrom", "Use Dimensions From", &names, 0),
-                    Field::num("duration", "Still Duration (seconds)", still),
-                    Field::bool("addToRenderQueue", "Add to Render Queue", false),
-                    Field::bool("sequence", "Sequence Layers", false),
-                    Field::bool("overlap", "Overlap", false),
-                    Field::num("overlapDuration", "Duration (seconds)", 1.0),
-                    Field::choice(
-                        "transition",
-                        "Transition",
-                        &[
-                            ("Off", json!("off")),
-                            ("Dissolve Front Layer", json!("dissolveFront")),
-                            ("Cross Dissolve Front and Back Layers", json!("crossDissolve")),
-                        ],
-                        0,
-                    ),
-                ],
-            )
+            new_comp_from_selection_form(s)
         }
         "layer.sequence" if !has(p, &["overlap"]) => (
             "Sequence Layers".into(),
@@ -425,188 +654,10 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) =>
         {
             let proxy = id == "file.interpretProxy";
-            let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
-                (_, Some(px)) if proxy => Some(px.footage.clone()),
-                (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
-                _ => None,
-            });
-            let Some(f) = f else { return false };
-            let hex = |c: [f32; 3]| format!("#{:02x}{:02x}{:02x}", (c[0] * 255.0).round() as u8, (c[1] * 255.0).round() as u8, (c[2] * 255.0).round() as u8);
-            let mut fields = vec![
-                // Main Options ▸ Alpha.
-                Field::choice(
-                    "alpha",
-                    "Alpha",
-                    &[
-                        ("Interpret Straight - Unmatted", json!("straight")),
-                        ("Interpret Premultiplied - Matted With Color", json!("premultiplied")),
-                        ("Ignore", json!("ignore")),
-                        ("Guess", json!("guess")),
-                    ],
-                    f.alpha as usize,
-                ),
-                Field::text("matteColor", "Matte color (premultiplied)", &hex(f.premul_color)),
-                Field::bool("invertAlpha", "Invert Alpha", f.invert_alpha),
-                // Main Options ▸ Frame Rate, Fields and Pulldown, Other Options.
-                Field::num("frameRate", "Assume this frame rate", f.frame_rate.as_f64()),
-                Field::choice(
-                    "fields",
-                    "Separate Fields",
-                    &[("Off", json!("off")), ("Upper Field First", json!("upper")), ("Lower Field First", json!("lower"))],
-                    f.fields as usize,
-                ),
-                Field::num("pixelAspect", "Pixel Aspect Ratio", f.pixel_aspect),
-                Field::num("loop", "Loop (times)", f.loop_count as f64),
-                // Color.
-                Field::choice(
-                    "colorProfile",
-                    "Assign Profile",
-                    &[
-                        ("Embedded / sRGB", json!("auto")),
-                        ("sRGB IEC61966-2.1", json!("srgb")),
-                        ("HDTV (Rec. 709)", json!("rec709")),
-                        ("Rec. 2020", json!("rec2020")),
-                        ("Display P3", json!("p3")),
-                        ("ACEScg", json!("acescg")),
-                        ("ACES2065-1", json!("aces2065")),
-                        ("Rec. 2100 PQ", json!("rec2100pq")),
-                        ("Rec. 2100 HLG", json!("rec2100hlg")),
-                    ],
-                    f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
-                ),
-                Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
-                Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
-            ];
-            // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
-            // After Effects), then Start Timecode.
-            let mut timing = Vec::new();
-            if f.kind == effectcraft_engine::project::FootageKind::Sequence {
-                let gaps = match effectcraft_engine::sequence::missing_report(&f.sequence) {
-                    _ if f.alphabetical => "files in alphabetical order".into(),
-                    Some(gaps) => format!("{gaps} shown as color bars"),
-                    None => "no missing frames".into(),
-                };
-                timing.push(Field::note("sequenceInfo", &format!("Image sequence: {} files, {} frames; {gaps}.", f.sequence.len(), f.sequence_frames())));
-            }
-            if f.kind != effectcraft_engine::project::FootageKind::Still {
-                timing.push(Field::choice(
-                    "overrideStart",
-                    "Start Timecode",
-                    &[("Use Source File Timecode", json!(false)), ("Override Start", json!(true))],
-                    usize::from(f.start_timecode.is_some()),
-                ));
-                timing.push(Field::text("startTimecode", "Override Start", &f.timecode(0)));
-            }
-            let at = fields.iter().position(|x| x.key == "frameRate").map_or(fields.len(), |i| i + 1);
-            fields.splice(at..at, timing);
-            (if proxy { "Interpret Footage: Proxy".into() } else { "Interpret Footage".into() }, fields)
-        }
-        "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => {
-            let st = &s.project.settings;
-            // The catalogue row is `Mercury GPU Acceleration ({})`, so the value goes through
-            // tr_args: wrapping the assembled string in tr() would look up the backend's name.
-            let gpu_label = match &s.accel {
-                Some(a) => tr_args("Mercury GPU Acceleration ({})", &[&a.name()]),
-                None => tr("Mercury GPU Acceleration (no GPU: software)").to_string(),
-            };
-            let depth = match st.bit_depth.label() {
-                l if l.starts_with("16") => 1,
-                l if l.starts_with("32") => 2,
-                _ => 0,
-            };
-            (
-                "Project Settings".into(),
-                vec![
-                    Field::choice(
-                        "bitDepth",
-                        "Depth",
-                        &[("8 bits per channel", json!(8)), ("16 bits per channel", json!(16)), ("32 bits per channel (float)", json!(32))],
-                        depth,
-                    ),
-                    Field::choice(
-                        "timeDisplay",
-                        "Time display style",
-                        &[
-                            ("Timecode", json!("timecode")),
-                            ("Frames", json!("frames")),
-                            ("Feet + Frames (35mm)", json!("feet35")),
-                            ("Feet + Frames (16mm)", json!("feet16")),
-                        ],
-                        {
-                            use effectcraft_engine::project::TimeDisplayStyle as T;
-                            match st.time_display {
-                                T::Timecode => 0,
-                                T::Frames => 1,
-                                T::Feet35 => 2,
-                                T::Feet16 => 3,
-                            }
-                        },
-                    ),
-                    // Time Display Style ▸ Frames ▸ Frame Count (also numbers Feet + Frames).
-                    Field::choice("frameStart", "Frame count", &[("Start at 0", json!(0)), ("Start at 1", json!(1))], usize::from(st.frame_start == 1)),
-                    Field::choice(
-                        "colorEngine",
-                        "Color engine",
-                        &[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))],
-                        usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio),
-                    ),
-                    Field::choice(
-                        "workingSpace",
-                        "Working space",
-                        &[
-                            ("None", json!("none")),
-                            ("sRGB IEC61966-2.1", json!("srgb")),
-                            ("HDTV (Rec. 709)", json!("rec709")),
-                            ("Rec. 2020", json!("rec2020")),
-                            ("Display P3", json!("p3")),
-                            ("ACEScg", json!("acescg")),
-                            ("ACES2065-1", json!("aces2065")),
-                        ],
-                        st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.iter().position(|x| *x == c).unwrap_or(0)),
-                    ),
-                    // With Working Space None (After Effects' Working Gamma, 2.2 or 2.4).
-                    Field::choice("workingGamma", "Working gamma", &[("2.2", json!(2.2)), ("2.4", json!(2.4))], usize::from(st.working_gamma_24())),
-                    Field::bool("linearize", "Linearize working space", st.linearize),
-                    Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
-                    Field::choice(
-                        "hdr",
-                        "HDR on SDR displays and outputs",
-                        &[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))],
-                        st.hdr as usize,
-                    ),
-                    Field::choice(
-                        "outputSpace",
-                        "Output color space",
-                        &[
-                            ("sRGB IEC61966-2.1 (default)", json!("default")),
-                            ("HDTV (Rec. 709)", json!("rec709")),
-                            ("Rec. 2020", json!("rec2020")),
-                            ("Display P3", json!("p3")),
-                            ("Rec. 2100 PQ (HDR)", json!("rec2100pq")),
-                            ("Rec. 2100 HLG (HDR)", json!("rec2100hlg")),
-                        ],
-                        {
-                            use effectcraft_engine::project::ColorSpace as C;
-                            match st.output_space {
-                                Some(C::Rec709) => 1,
-                                Some(C::Rec2020) => 2,
-                                Some(C::DisplayP3) => 3,
-                                Some(C::Rec2100Pq) => 4,
-                                Some(C::Rec2100Hlg) => 5,
-                                _ => 0,
-                            }
-                        },
-                    ),
-                    // Video Rendering and Effects ▸ Use.
-                    Field::choice(
-                        "renderer",
-                        "Video rendering and effects",
-                        &[(gpu_label.as_str(), json!("gpu")), ("Mercury Software Only", json!("software"))],
-                        usize::from(!st.gpu_acceleration),
-                    ),
-                ],
-            )
+            let Some(form) = interpret_footage_form(s, proxy) else { return false };
+            form
         }
+        "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s),
         "layer.autoTrace" if !has(p, &["channel", "timeSpan", "threshold"]) => (
             "Auto-trace".into(),
             vec![
@@ -662,42 +713,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
             )
         }
         // File ▸ Export ▸ Adobe Premiere Pro Project…: Final Cut Pro XML (or FCPXML / OTIO / EDL).
-        "file.exportTimeline" if !has(p, &["path"]) => {
-            let name = s.active_comp_id().and_then(|c| s.project.item(c)).map(|i| i.name.clone()).unwrap_or_else(|| "Composition".into());
-            let dir = s.path.as_deref().and_then(|p| std::path::Path::new(p).parent()).map(|d| d.to_path_buf()).unwrap_or_default();
-            let path = dir.join(format!("{name}.xml")).to_string_lossy().to_string();
-            (
-                "Export Adobe Premiere Pro Project".into(),
-                vec![
-                    Field::note("note", "Writes Final Cut Pro XML (.xml), which Premiere Pro opens with File ▸ Import. Native .prproj files are not written."),
-                    Field::save_path("path", "File", &path),
-                    Field::choice(
-                        "format",
-                        "Format",
-                        &[
-                            ("Final Cut Pro XML (.xml) – Premiere Pro", json!("xml")),
-                            ("FCPXML (.fcpxml)", json!("fcpxml")),
-                            ("OpenTimelineIO (.otio)", json!("otio")),
-                            ("CMX 3600 EDL (.edl, V1 only)", json!("edl")),
-                            ("AAF (.aaf)", json!("aaf")),
-                            ("OMF (.omf, audio)", json!("omf")),
-                        ],
-                        0,
-                    ),
-                    Field::choice(
-                        "prerender",
-                        "Pre-render (ProRes 4444)",
-                        &[
-                            ("Layers Premiere can't show (text, shapes, effects…)", json!("unsupported")),
-                            ("All layers", json!("all")),
-                            ("None (leave them out)", json!("none")),
-                        ],
-                        0,
-                    ),
-                    Field::choice("precomps", "Precomps", &[("Nested sequences", json!("nest")), ("Pre-render", json!("prerender"))], 0),
-                ],
-            )
-        }
+        "file.exportTimeline" if !has(p, &["path"]) => export_timeline_form(s),
         "file.importPlaceholder" | "file.replaceWithPlaceholder" if p.as_object().is_none_or(|m| m.is_empty()) => (
             "New Placeholder".into(),
             vec![
diff --git a/crates/ui-egui/src/panels/fx_editors.rs b/crates/ui-egui/src/panels/fx_editors.rs
index b7b09dad4..4ed96797a 100644
--- a/crates/ui-egui/src/panels/fx_editors.rs
+++ b/crates/ui-egui/src/panels/fx_editors.rs
@@ -66,19 +66,9 @@ fn register_points(app: &mut EffectcraftApp, prefix: &str, gr: Rect, pts: &[[f32
 }
 
 /// Small coloured tabs (`names[i]`, swatch `cols[i]`); returns the clicked one.
-fn tabs(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    x0: f32,
-    cy: f32,
-    cur: usize,
-    names: &[&str],
-    cols: &[Color32],
-    id: &str,
-) -> Option {
+fn tabs(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, at: Pos2, cur: usize, (names, cols): (&[&str], &[Color32]), id: &str) -> Option {
     let t = app.tokens;
-    let mut x = x0;
+    let (mut x, cy) = (at.x, at.y);
     let mut out = None;
     for (i, name) in names.iter().enumerate() {
         let w = if cols.is_empty() { 16.0 + name.chars().count() as f32 * 6.2 } else { 20.0 };
@@ -188,7 +178,7 @@ fn rgb_curves(
     let cy = r.min.y + 15.0;
     let cols = [Color32::from_gray(0xe8), Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
     let names: Vec<&str> = RGB_CURVES.iter().map(|c| c.1).collect();
-    if let Some(n) = tabs(app, ui, p, x0, cy, ch, &names, &cols, &format!("{prefix}.channel")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, cy), ch, (&names, &cols), &format!("{prefix}.channel")) {
         app.ui.fx_curve_channel.insert(g.uid, n);
     }
     let id = RGB_CURVES[ch].0;
@@ -299,7 +289,7 @@ fn huesat_curves(
     let x0 = r.min.x + 38.0;
     let cy = r.min.y + 15.0;
     let names: Vec<&str> = HUESAT.iter().map(|c| c.1).collect();
-    if let Some(n) = tabs(app, ui, p, x0, cy, k, &names, &[], &format!("{prefix}.curve")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, cy), k, (&names, &[]), &format!("{prefix}.curve")) {
         app.ui.fx_curve_channel.insert(g.uid, n);
     }
     let (id, name, axis, periodic) = HUESAT[k];
@@ -724,7 +714,7 @@ fn glow_map(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, laye
     }
     let ch = app.ui.fx_curve_channel.get(&euid).copied().unwrap_or(0).min(2);
     let cols = [Color32::from_rgb(0xf0, 0x50, 0x50), Color32::from_rgb(0x50, 0xd0, 0x60), Color32::from_rgb(0x50, 0x80, 0xff)];
-    if let Some(n) = tabs(app, ui, p, x0, r.min.y + 15.0, ch, &["Red", "Green", "Blue"], &cols, &format!("{prefix}.channel")) {
+    if let Some(n) = tabs(app, ui, p, pos2(x0, r.min.y + 15.0), ch, (&["Red", "Green", "Blue"], &cols), &format!("{prefix}.channel")) {
         app.ui.fx_curve_channel.insert(euid, n);
     }
     if reset_link(app, ui, p, Rect::from_min_size(pos2(x0 + size - 44.0, r.min.y + 6.0), vec2(44.0, 18.0)), &format!("{prefix}.reset")) {
diff --git a/crates/ui-egui/src/panels/fx_widgets.rs b/crates/ui-egui/src/panels/fx_widgets.rs
index e917827cc..b23000d27 100644
--- a/crates/ui-egui/src/panels/fx_widgets.rs
+++ b/crates/ui-egui/src/panels/fx_widgets.rs
@@ -59,7 +59,8 @@ pub fn angle_field(ui: &mut egui::Ui, at: Pos2, id: egui::Id, v: f64, decimals:
     let (rev, deg) = split_angle(v);
     let (rr, new_rev, _) = widgets::hot_int_at(ui, at, id.with("rev"), rev, 0.1, "x", t);
     let sign = ui.painter().text(pos2(rr.max.x - 2.0, rr.center().y), Align2::LEFT_CENTER, if deg < 0.0 { "-" } else { "+" }, Tokens::ui(12.0), t.hot_text);
-    let (dr, new_deg, _) = widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), 0.5, (-1e9, 1e9), decimals, "°", t);
+    let (dr, new_deg, _) =
+        widgets::hot_number_at(ui, pos2(sign.max.x - 2.0, at.y), id, deg.abs(), &widgets::HotSpec { speed: 0.5, range: (-1e9, 1e9), decimals, suffix: "°" }, t);
     let dragging = new_deg.is_some() && ui.ctx().is_being_dragged(id);
     let out = match (new_rev, new_deg) {
         (Some(r), _) => Some(r as f64 * 360.0 + deg),
diff --git a/crates/ui-egui/src/panels/graph.rs b/crates/ui-egui/src/panels/graph.rs
index 66ba31470..24df22616 100644
--- a/crates/ui-egui/src/panels/graph.rs
+++ b/crates/ui-egui/src/panels/graph.rs
@@ -240,17 +240,16 @@ impl ButtonBar {
 /// at `y`: draws it, registers it for automation and advances the bar. Once a button does not fit
 /// (or a previous one overflowed), the button goes to the "»" menu instead and only the id the
 /// menu picked reports a click. Returns whether this button was chosen.
-fn text_button(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    bar: &mut ButtonBar,
-    y: f32,
-    label: &str,
+/// One bar button for [`text_button`]: label, lit state, automation id and tooltip.
+struct BarBtn<'a> {
+    label: &'a str,
     on: bool,
-    id: &str,
-    tip: &str,
-) -> bool {
+    id: &'a str,
+    tip: &'a str,
+}
+
+fn text_button(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: &mut ButtonBar, y: f32, btn: BarBtn) -> bool {
+    let BarBtn { label, on, id, tip } = btn;
     let t = app.tokens;
     let w = p.layout_no_wrap(label.to_string(), Tokens::ui(11.0), t.text).size().x + 14.0;
     // Once one button overflows, the rest follow it into the menu (they keep their order).
@@ -277,35 +276,44 @@ fn text_button(
     resp.clicked()
 }
 
-/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
-pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, ectx: &EvalCtx, tm: TMap, area: Rect, actions: &mut Actions) {
-    let t = app.tokens;
-    let ctx = ui.ctx().clone();
-    let footer_h = 28.0;
-    let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
-    let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
-    // Background first so keys and handles (added later) sit on top for hit-testing.
-    let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
-    p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
-    app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
-    let speed = speed_graph(app, comp);
-    let cs = curves(app, comp, ectx, tm, plot, speed);
-    // Just opened, or the graph type changed (value and speed have different units): frame the
-    // graphs (Fit Selection to View, else Fit All Graphs to View).
-    let was_speed_id = egui::Id::unique("graph-was-speed");
-    let type_changed = ctx.data(|d| d.get_temp::(was_speed_id)).is_some_and(|was| was != speed);
-    ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
-    if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed {
-        if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
-            fit_selection(app, &cs, tm, plot, speed);
-        } else {
-            fit_all(app);
+/// What the Graph Editor draws: the composition, its evaluation context and the time mapping.
+#[derive(Clone, Copy)]
+pub(crate) struct GraphView<'a> {
+    pub comp: &'a Comp,
+    pub ectx: &'a EvalCtx<'a>,
+    pub tm: TMap,
+}
+
+/// Show Reference Graph: the other graph type, faint, scaled to the plot.
+fn reference_graph(app: &EffectcraftApp, view: GraphView, plot: Rect, speed: bool, pc: &egui::Painter) {
+    let GraphView { comp, ectx, tm } = view;
+    let other = curves(app, comp, ectx, tm, plot, !speed);
+    let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
+    for c in &other {
+        for (_, v) in &c.pts {
+            rlo = rlo.min(*v);
+            rhi = rhi.max(*v);
         }
     }
+    if rlo.is_finite() {
+        let span = (rhi - rlo).max(1e-9);
+        let lines: Vec<(Color32, Vec)> = other
+            .iter()
+            .map(|c| {
+                (
+                    c.color,
+                    c.pts.iter().map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05)).collect(),
+                )
+            })
+            .collect();
+        super::graph_tools::reference_graph(pc, plot, &lines);
+    }
+}
 
-    // Vertical range: auto-zoom to the curves + handles, or the stored manual range.
+/// The value range that fits every curve point and key handle (with a margin).
+fn auto_range(cs: &[Curve], speed: bool) -> (f64, f64) {
     let (mut lo, mut hi) = (f64::INFINITY, f64::NEG_INFINITY);
-    for c in &cs {
+    for c in cs {
         for (_, v) in &c.pts {
             lo = lo.min(*v);
             hi = hi.max(*v);
@@ -327,7 +335,78 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         hi = lo + 1.0;
     }
     let pad = (hi - lo) * 0.08;
-    let auto = (lo - pad, hi + pad);
+    (lo - pad, hi + pad)
+}
+
+/// Background: a click deselects keys, a drag box-selects the keys inside it.
+fn box_select(ui: &egui::Ui, pc: &egui::Painter, bg: &egui::Response, key_screens: &[(serde_json::Value, Pos2)], accent: Color32, actions: &mut Actions) {
+    let ctx = ui.ctx().clone();
+    if bg.clicked() {
+        actions.push(("keys.select".into(), json!({"keys": []})));
+    }
+    if bg.dragged()
+        && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
+    {
+        let br = Rect::from_two_pos(o, cur);
+        pc.rect_filled(br, 0.0, accent.gamma_multiply(0.12));
+        pc.rect_stroke(br, 0.0, Stroke::new(1.0, accent), StrokeKind::Middle);
+        ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
+    }
+    if bg.drag_stopped()
+        && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("graph-box")))
+    {
+        ctx.data_mut(|d| d.remove::(egui::Id::unique("graph-box")));
+        let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
+        actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
+    }
+}
+
+/// Wheel over the graph with Ctrl zooms the value axis `(lo, hi)` (turns auto-zoom off).
+fn wheel_zoom(app: &mut EffectcraftApp, ui: &egui::Ui, plot: Rect, (lo, hi): (f64, f64)) {
+    if ui.rect_contains_pointer(plot) {
+        let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
+        if zoom && dy.abs() > 0.0 {
+            let k = (-dy as f64 / 200.0).exp();
+            let mid = (lo + hi) / 2.0;
+            app.ui.timeline.graph_auto_zoom = false;
+            app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
+        }
+    }
+}
+
+/// Just opened, or the graph type changed (value and speed have different units): frame the
+/// graphs (Fit Selection to View, else Fit All Graphs to View).
+fn fit_on_open(app: &mut EffectcraftApp, ctx: &egui::Context, cs: &[Curve], tm: TMap, plot: Rect, speed: bool) {
+    let was_speed_id = egui::Id::unique("graph-was-speed");
+    let type_changed = ctx.data(|d| d.get_temp::(was_speed_id)).is_some_and(|was| was != speed);
+    ctx.data_mut(|d| d.insert_temp(was_speed_id, speed));
+    if ctx.data_mut(|d| d.remove_temp::(fit_pending_id())).unwrap_or(false) || type_changed {
+        if cs.iter().any(|c| c.keys.iter().any(|k| k.sel)) {
+            fit_selection(app, cs, tm, plot, speed);
+        } else {
+            fit_all(app);
+        }
+    }
+}
+
+/// Draw the Graph Editor into `area` (the right side of the Timeline) and queue edits.
+pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, view: GraphView, area: Rect, actions: &mut Actions) {
+    let GraphView { comp, ectx, tm } = view;
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let footer_h = 28.0;
+    let plot = Rect::from_min_max(area.min + vec2(0.0, 6.0), pos2(area.max.x, area.max.y - footer_h));
+    let bar = Rect::from_min_max(pos2(area.min.x, area.max.y - footer_h), area.max);
+    // Background first so keys and handles (added later) sit on top for hit-testing.
+    let bg = ui.interact(plot, egui::Id::unique("graph-bg"), Sense::click_and_drag());
+    p.rect_filled(area, 0.0, Color32::from_rgb(0x1d, 0x1d, 0x1d));
+    app.auto.add("timeline.graph", plot, &app.ui.timeline.graph_mode.clone());
+    let speed = speed_graph(app, comp);
+    let cs = curves(app, comp, ectx, tm, plot, speed);
+    fit_on_open(app, &ctx, &cs, tm, plot, speed);
+
+    // Vertical range: auto-zoom to the curves + handles, or the stored manual range.
+    let auto = auto_range(&cs, speed);
     // While a key or handle is being dragged the scale stays put (no feedback loop).
     let drag_id = egui::Id::unique("graph-dragging");
     let dragging = ctx.data(|d| d.get_temp::(drag_id)).unwrap_or(false) && ctx.input(|i| i.pointer.any_down());
@@ -358,32 +437,8 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         );
     }
 
-    // Show Reference Graph: the other graph type, faint, scaled to the plot.
     if app.ui.timeline.graph_reference && !cs.is_empty() {
-        let other = curves(app, comp, ectx, tm, plot, !speed);
-        let (mut rlo, mut rhi) = (f64::INFINITY, f64::NEG_INFINITY);
-        for c in &other {
-            for (_, v) in &c.pts {
-                rlo = rlo.min(*v);
-                rhi = rhi.max(*v);
-            }
-        }
-        if rlo.is_finite() {
-            let span = (rhi - rlo).max(1e-9);
-            let lines: Vec<(Color32, Vec)> = other
-                .iter()
-                .map(|c| {
-                    (
-                        c.color,
-                        c.pts
-                            .iter()
-                            .map(|(ct, v)| pos2(tm.x(*ct), plot.max.y - ((v - rlo) / span) as f32 * plot.height() * 0.9 - plot.height() * 0.05))
-                            .collect(),
-                    )
-                })
-                .collect();
-            super::graph_tools::reference_graph(&pc, plot, &lines);
-        }
+        reference_graph(app, view, plot, speed, &pc);
     }
     // Curves.
     for c in &cs {
@@ -523,37 +578,19 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
 
     super::graph_tools::transform_box(app, ui, &pc, &sel_keys, tm, &ymap, &vmap, speed, true, actions);
 
-    // Background: click deselects keys, drag box-selects.
-    if bg.clicked() {
-        actions.push(("keys.select".into(), json!({"keys": []})));
-    }
-    if bg.dragged()
-        && let (Some(o), Some(cur)) = (ctx.input(|i| i.pointer.press_origin()), bg.interact_pointer_pos())
-    {
-        let br = Rect::from_two_pos(o, cur);
-        pc.rect_filled(br, 0.0, t.accent.gamma_multiply(0.12));
-        pc.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
-        ctx.data_mut(|d| d.insert_temp(egui::Id::unique("graph-box"), br));
-    }
-    if bg.drag_stopped()
-        && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("graph-box")))
-    {
-        ctx.data_mut(|d| d.remove::(egui::Id::unique("graph-box")));
-        let keys: Vec = key_screens.iter().filter(|(_, sp)| br.contains(*sp)).map(|(k, _)| k.clone()).collect();
-        actions.push(("keys.select".into(), json!({"keys": keys, "add": ui.input(|i| i.modifiers.shift)})));
-    }
-    // Wheel over the graph with Alt/Ctrl zooms the value axis (turns auto-zoom off).
-    if ui.rect_contains_pointer(plot) {
-        let (dy, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.ctrl || i.modifiers.command));
-        if zoom && dy.abs() > 0.0 {
-            let k = (-dy as f64 / 200.0).exp();
-            let mid = (lo + hi) / 2.0;
-            app.ui.timeline.graph_auto_zoom = false;
-            app.ui.timeline.graph_range = Some((mid - (mid - lo) * k, mid + (hi - mid) * k));
-        }
+    box_select(ui, &pc, &bg, &key_screens, t.accent, actions);
+    wheel_zoom(app, ui, plot, (lo, hi));
+    if button_bar(app, ui, p, comp, bar, speed, actions) {
+        fit_selection(app, &cs, tm, plot, speed);
     }
+}
 
-    // Button bar.
+/// The footer button bar: graph type, display toggles, fit, snap and keyframe interpolation.
+/// Returns whether Fit Selection was pressed.
+fn button_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, comp: &Comp, bar: Rect, speed: bool, actions: &mut Actions) -> bool {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let mut fit_sel = false;
     p.rect_filled(bar, 0.0, t.panel_bg);
     p.line_segment([bar.left_top(), bar.right_top()], Stroke::new(1.0, t.separator));
     let y = bar.center().y;
@@ -566,48 +603,71 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         p,
         &mut buttons,
         y,
-        "Auto",
-        auto_type,
-        "autoSelectGraphType",
-        "Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
+        BarBtn {
+            label: "Auto",
+            on: auto_type,
+            id: "autoSelectGraphType",
+            tip: "Auto-Select Graph Type (speed graph for spatial properties, else value graph)",
+        },
     ) {
         app.ui.timeline.graph_mode = if auto_type { if speed { "speed" } else { "value" } } else { "auto" }.into();
     }
     // Choosing a graph type turns Auto-Select off, as in After Effects.
-    if text_button(app, ui, p, &mut buttons, y, "Value", !speed, "valueGraph", "Edit Value Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Value", on: !speed, id: "valueGraph", tip: "Edit Value Graph" }) {
         app.ui.timeline.graph_mode = "value".into();
     }
-    if text_button(app, ui, p, &mut buttons, y, "Speed", speed, "speedGraph", "Edit Speed Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Speed", on: speed, id: "speedGraph", tip: "Edit Speed Graph" }) {
         app.ui.timeline.graph_mode = "speed".into();
     }
     buttons.x += 6.0;
-    if text_button(app, ui, p, &mut buttons, y, "Selected", show_sel, "showSelected", "Show Selected Properties (else all animated properties)") {
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Selected", on: show_sel, id: "showSelected", tip: "Show Selected Properties (else all animated properties)" },
+    ) {
         app.ui.timeline.graph_show_selected = !show_sel;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Auto-zoom", auto_on, "autoZoom", "Auto-zoom graph height") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Auto-zoom", on: auto_on, id: "autoZoom", tip: "Auto-zoom graph height" }) {
         app.ui.timeline.graph_auto_zoom = !auto_on;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Fit", false, "fit", "Fit all graphs to view") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit", on: false, id: "fit", tip: "Fit all graphs to view" }) {
         fit_all(app);
     }
-    if text_button(app, ui, p, &mut buttons, y, "Fit Selection", false, "fitSelection", "Fit selection to view") {
-        fit_selection(app, &cs, tm, plot, speed);
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Fit Selection", on: false, id: "fitSelection", tip: "Fit selection to view" }) {
+        fit_sel = true;
     }
     buttons.x += 6.0;
     let (snap, reference, tbox) = (app.ui.timeline.graph_snap, app.ui.timeline.graph_reference, app.ui.timeline.graph_transform_box);
-    if text_button(app, ui, p, &mut buttons, y, "Snap", snap, "snap", "Snap (key drags snap to the current time and other keys)") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Snap", on: snap, id: "snap", tip: "Snap (key drags snap to the current time and other keys)" })
+    {
         app.ui.timeline.graph_snap = !snap;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Reference", reference, "reference", "Show Reference Graph") {
+    if text_button(app, ui, p, &mut buttons, y, BarBtn { label: "Reference", on: reference, id: "reference", tip: "Show Reference Graph" }) {
         app.ui.timeline.graph_reference = !reference;
     }
-    if text_button(app, ui, p, &mut buttons, y, "Transform Box", tbox, "transformBox", "Show Transform Box when multiple keys are selected") {
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Transform Box", on: tbox, id: "transformBox", tip: "Show Transform Box when multiple keys are selected" },
+    ) {
         app.ui.timeline.graph_transform_box = !tbox;
     }
     buttons.x += 6.0;
     let pos_sel = app.session.state.selected_props.iter().find_map(|(l, u)| super::timeline::is_position(comp.layer(*l)?, *u).then_some(l.0));
-    if text_button(app, ui, p, &mut buttons, y, "Separate Dimensions", false, "separateDimensions", "Separate Dimensions (Position)")
-        && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
+    if text_button(
+        app,
+        ui,
+        p,
+        &mut buttons,
+        y,
+        BarBtn { label: "Separate Dimensions", on: false, id: "separateDimensions", tip: "Separate Dimensions (Position)" },
+    ) && let Some(l) = pos_sel.or(app.session.state.selected_layers.first().map(|l| l.0))
     {
         actions.push(("prop.separateDimensions".into(), json!({"layer": l})));
     }
@@ -621,11 +681,12 @@ pub(crate) fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painte
         ("Ease Out", "keys.easyEaseOut", json!({}), "Easy Ease Out (Ctrl+Shift+F9)"),
     ] {
         let id = cmd.trim_start_matches("keys.").to_string() + label.split_whitespace().next().unwrap_or("");
-        if text_button(app, ui, p, &mut buttons, y, label, false, &id, tip) {
+        if text_button(app, ui, p, &mut buttons, y, BarBtn { label, on: false, id: &id, tip }) {
             actions.push((cmd.into(), params));
         }
     }
     buttons.more(app, ui, p, bar);
+    fit_sel
 }
 
 /// Fit All Graphs to View: the whole comp in time, auto-zoomed values.
diff --git a/crates/ui-egui/src/panels/key_dialogs.rs b/crates/ui-egui/src/panels/key_dialogs.rs
index ab26d700f..22e31c468 100644
--- a/crates/ui-egui/src/panels/key_dialogs.rs
+++ b/crates/ui-egui/src/panels/key_dialogs.rs
@@ -125,8 +125,16 @@ fn buttons(ui: &mut egui::Ui, app: &mut EffectcraftApp, t: &Tokens, prefix: &str
     (ok, cancel)
 }
 
-fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, speed: f64, range: std::ops::RangeInclusive, suffix: &str, enabled: bool) {
-    let r = ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(speed).range(range).max_decimals(2).suffix(suffix)));
+/// Drag speed, clamp range and suffix of a [`drag`] field.
+struct DragSpec<'a> {
+    speed: f64,
+    range: std::ops::RangeInclusive,
+    suffix: &'a str,
+}
+
+fn drag(ui: &mut egui::Ui, app: &mut EffectcraftApp, id: &str, v: &mut f64, spec: DragSpec, enabled: bool) {
+    let r =
+        ui.add_enabled(enabled, crate::widgets::drag_value(egui::DragValue::new(v).speed(spec.speed).range(spec.range).max_decimals(2).suffix(spec.suffix)));
     app.auto.add(id, r.rect, id);
 }
 
@@ -147,13 +155,13 @@ pub fn velocity(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
                     ui.horizontal(|ui| {
                         ui.label(tr("Speed:"));
                         let v = if out { &mut d.out_speed[dim] } else { &mut d.in_speed[dim] };
-                        drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, 1.0, -1e7..=1e7, "", enabled);
+                        drag(ui, app, &format!("dialog.velocity.{side}Speed.{dim}"), v, DragSpec { speed: 1.0, range: -1e7..=1e7, suffix: "" }, enabled);
                         ui.label(egui::RichText::new(&d.units).color(t.text_dim));
                     });
                     ui.horizontal(|ui| {
                         ui.label(tr("Influence:"));
                         let v = if out { &mut d.out_inf[dim] } else { &mut d.in_inf[dim] };
-                        drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, 0.2, 0.1..=100.0, " %", enabled);
+                        drag(ui, app, &format!("dialog.velocity.{side}Influence.{dim}"), v, DragSpec { speed: 0.2, range: 0.1..=100.0, suffix: " %" }, enabled);
                     });
                     ui.end_row();
                 }
@@ -254,14 +262,14 @@ pub fn time_stretch(app: &mut EffectcraftApp, ctx: &egui::Context, t: &Tokens) {
             ui.end_row();
             ui.label(tr("Stretch Factor:"));
             let before = d.percent;
-            drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, 0.5, -10000.0..=10000.0, " %", true);
+            drag(ui, app, "dialog.timeStretch.percent", &mut d.percent, DragSpec { speed: 0.5, range: -10000.0..=10000.0, suffix: " %" }, true);
             if (before - d.percent).abs() > 1e-9 && d.orig_percent.abs() > 1e-9 {
                 d.duration = d.orig_duration * d.percent.abs() / d.orig_percent.abs();
             }
             ui.end_row();
             ui.label(tr("New Duration:"));
             let before = d.duration;
-            drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, 0.05, 0.01..=100000.0, " s", true);
+            drag(ui, app, "dialog.timeStretch.duration", &mut d.duration, DragSpec { speed: 0.05, range: 0.01..=100000.0, suffix: " s" }, true);
             if (before - d.duration).abs() > 1e-9 && d.orig_duration > 1e-9 {
                 d.percent = d.orig_percent.signum() * d.orig_percent.abs() * d.duration / d.orig_duration;
             }
diff --git a/crates/ui-egui/src/panels/layer_panel.rs b/crates/ui-egui/src/panels/layer_panel.rs
index f6157da47..5fb4d703c 100644
--- a/crates/ui-egui/src/panels/layer_panel.rs
+++ b/crates/ui-egui/src/panels/layer_panel.rs
@@ -87,6 +87,141 @@ fn default_view(layer: &Layer, tool: Tool) -> usize {
     }
 }
 
+/// The layer's rendered view `view` at `scale`, cached in egui's temp data per revision, layer,
+/// time, view and scale (the layer cache makes repeats cheap).
+fn render_tex(
+    app: &EffectcraftApp,
+    ctx: &egui::Context,
+    cid: effectcraft_engine::project::ItemId,
+    comp: &effectcraft_engine::project::Comp,
+    layer: &Layer,
+    view: usize,
+    scale: f64,
+) -> Option {
+    let time = app.session.time();
+    let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
+    let cached: Option = ctx.data(|d| d.get_temp(tex_id()));
+    match cached {
+        Some(c) if c.key == key => Some(c),
+        _ => {
+            let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp, time, expr: app.session.expr.as_deref(), footage: None };
+            let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
+            r.expr = app.session.expr.as_deref();
+            r.cache = Some(&app.session.layer_cache);
+            r.layer_input(&ectx, layer, view).map(|buf| {
+                let img = crate::frames::to_color_image(&buf.img);
+                let x0 = -buf.offset[0] / buf.scale;
+                let y0 = -buf.offset[1] / buf.scale;
+                let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
+                let tex = crate::frames::load_fitted(ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
+                let c = Tex { key, tex, rect };
+                ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
+                c
+            })
+        }
+    }
+}
+
+/// What [`paint_stroke`] needs about the brush and the layer <-> screen mapping.
+#[derive(Clone, Copy)]
+struct StrokeCtx<'a> {
+    layer: u64,
+    kind: &'a str,
+    zoom: f64,
+    did: egui::Id,
+    diameter: f64,
+    color: [f64; 4],
+    to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
+    to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
+}
+
+/// Records a paint stroke's layer-space points while dragging, previews it, and runs
+/// `paint.stroke` on release (or on a click).
+fn paint_stroke(app: &mut EffectcraftApp, ui: &egui::Ui, painter: &egui::Painter, resp: &egui::Response, sc: &StrokeCtx) {
+    let StrokeCtx { layer, kind, zoom, did, diameter, color, to_layer, to_screen } = *sc;
+    let ctx = ui.ctx().clone();
+    let now = ui.input(|i| i.time);
+    if resp.drag_started()
+        && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
+    {
+        ctx.data_mut(|d| d.insert_temp(did, Drag { layer, points: vec![to_layer(pos)], started: now }));
+    }
+    if let Some(mut d) = ctx.data(|d| d.get_temp::(did)) {
+        if let Some(pos) = resp.interact_pointer_pos() {
+            let lp = to_layer(pos);
+            let last = d.points.last().copied().unwrap_or(lp);
+            if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.0 {
+                d.points.push(lp);
+                ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
+            }
+        }
+        // Live preview of the stroke.
+        let col = if kind == "eraser" {
+            Color32::from_white_alpha(140)
+        } else {
+            Color32::from_rgba_unmultiplied((color[0] * 255.0) as u8, (color[1] * 255.0) as u8, (color[2] * 255.0) as u8, 200)
+        };
+        let width = ((diameter * zoom) as f32).max(1.0);
+        let pts: Vec = d.points.iter().map(|q| to_screen(*q)).collect();
+        if pts.len() == 1 {
+            painter.circle_filled(pts[0], width / 2.0, col);
+        } else {
+            painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
+        }
+        if resp.drag_stopped() {
+            ctx.data_mut(|dd| dd.remove::(did));
+            let pts: Vec<[f64; 2]> = d.points.clone();
+            let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
+            if let Err(e) = r {
+                app.ui.status = e.to_string();
+            }
+        }
+    }
+    if resp.clicked()
+        && let Some(pos) = resp.interact_pointer_pos()
+    {
+        let r = app.session.execute("paint.stroke", json!({"layer": layer, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
+        if let Err(e) = r {
+            app.ui.status = e.to_string();
+        }
+    }
+}
+
+/// The bottom bar: the magnification menu and the View menu over `names` (Masks, then the
+/// effects). Returns where the View dropdown ends.
+fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, bar: Rect, names: &[String], view: usize, (zoom, fit): (f64, f32)) -> f32 {
+    let t = app.tokens;
+    p.rect_filled(bar, 0.0, t.panel_bg);
+    let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
+    if let Some((z, up)) = super::viewer_tools::magnification_menu(
+        app,
+        ui,
+        mr,
+        "layer-mag",
+        "layerPanel.magnification",
+        "layerMagnification",
+        zoom as f32,
+        app.ui.layer_zoom,
+        false,
+    ) {
+        // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %.
+        app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z };
+        app.ui.layer_pan = [0.0, 0.0];
+    }
+    let vx = mr.max.x + 10.0;
+    p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
+    let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
+    let pop = egui::Id::unique("layer-view-pop");
+    if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    app.auto.add("layerPanel.view", vr, "View");
+    if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), names, Some(view)) {
+        app.ui.layer_view = Some(i);
+    }
+    vr.max.x
+}
+
 /// The Layer panel: one layer in its own space with its masks and effects up to the View menu's
 /// choice, rendered synchronously at a zoom/pan the panel keeps (the last Fit magnification is
 /// published for the View menu). Wheel or pinch zooms about the pointer, the Hand tool or
@@ -130,44 +265,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     };
     let origin = area.center() + pan - vec2((lw * zoom / 2.0) as f32, (lh * zoom / 2.0) as f32);
 
-    // Bottom bar: magnification and View menus.
-    p.rect_filled(bar, 0.0, t.panel_bg);
-    let mr = Rect::from_min_size(pos2(bar.min.x + 8.0, bar.center().y - 10.0), vec2(80.0, 20.0));
-    if let Some((z, up)) = super::viewer_tools::magnification_menu(
-        app,
-        ui,
-        mr,
-        "layer-mag",
-        "layerPanel.magnification",
-        "layerMagnification",
-        zoom as f32,
-        app.ui.layer_zoom,
-        false,
-    ) {
-        // The Layer panel keeps no fit mode: Fit up to 100% is the fit, at most 100 %.
-        app.ui.layer_zoom = if up { Some(fit.min(1.0 / ui.ctx().pixels_per_point())) } else { z };
-        app.ui.layer_pan = [0.0, 0.0];
-    }
-    let vx = mr.max.x + 10.0;
-    p.text(pos2(vx, bar.center().y), Align2::LEFT_CENTER, tr("View:"), Tokens::ui(12.0), t.text_dim);
     let mut names = vec!["Masks".to_string()];
     if let Some(fx) = layer.effects() {
         names.extend(fx.groups().map(|g| g.name.clone()));
     }
-    let vr = Rect::from_min_size(pos2(vx + 36.0, bar.center().y - 10.0), vec2(150.0, 20.0));
-    let pop = egui::Id::unique("layer-view-pop");
-    if widgets::dropdown(ui, vr, &names[view.min(names.len() - 1)], &t, egui::Id::unique("layer-view")).clicked() {
-        widgets::open_popup(ui, pop);
-    }
-    app.auto.add("layerPanel.view", vr, "View");
-    if let Some(i) = widgets::popup_menu(ui, pop, vr.left_bottom(), &names, Some(view)) {
-        app.ui.layer_view = Some(i);
-    }
+    let vr_right = bottom_bar(app, ui, &p, bar, &names, view, (zoom, fit));
+
     let tc = super::timecode(&app.session, &comp, time);
     p.text(pos2(bar.max.x - 10.0, bar.center().y), Align2::RIGHT_CENTER, tc, Tokens::mono(12.0), t.timecode);
     // Roto Brush: view / Freeze buttons and the segmentation span bar.
     if roto.is_some() {
-        super::roto_tool::bar_buttons(app, ui, &p, bar, vr.max.x + 12.0, &layer);
+        super::roto_tool::bar_buttons(app, ui, &p, bar, vr_right + 12.0, &layer);
         let sb = Rect::from_min_max(pos2(rect.min.x, canvas_bottom), pos2(rect.max.x, bar.min.y));
         super::roto_tool::span_bar(app, ui, &p, sb, &comp, &layer);
     }
@@ -190,27 +298,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     } else {
         0.25
     };
-    let key = (app.session.revision, layer.id.0, time.0, view, (scale * 1000.0) as u32);
-    let cached: Option = ctx.data(|d| d.get_temp(tex_id()));
-    let tex = match cached {
-        Some(c) if c.key == key => Some(c),
-        _ => {
-            let ectx = EvalCtx { project: &app.session.project, comp_id: cid, comp: &comp, time, expr: app.session.expr.as_deref(), footage: None };
-            let mut r = Renderer::new(&app.session.project, &*app.session.footage, RenderOpts { scale, ..Default::default() });
-            r.expr = app.session.expr.as_deref();
-            r.cache = Some(&app.session.layer_cache);
-            r.layer_input(&ectx, &layer, view).map(|buf| {
-                let img = crate::frames::to_color_image(&buf.img);
-                let x0 = -buf.offset[0] / buf.scale;
-                let y0 = -buf.offset[1] / buf.scale;
-                let rect = [x0, y0, x0 + buf.img.width as f64 / buf.scale, y0 + buf.img.height as f64 / buf.scale];
-                let tex = crate::frames::load_fitted(&ctx, "layer-panel", img, egui::TextureOptions::LINEAR);
-                let c = Tex { key, tex, rect };
-                ctx.data_mut(|d| d.insert_temp(tex_id(), c.clone()));
-                c
-            })
-        }
-    };
+    let tex = render_tex(app, &ctx, cid, &comp, &layer, view, scale);
     if let Some(tx) = &tex {
         let r = Rect::from_min_max(to_screen([tx.rect[0], tx.rect[1]]), to_screen([tx.rect[2], tx.rect[3]]));
         painter.image(tx.tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
@@ -271,7 +359,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     // Interaction: Roto Brush / Refine Edge.
     if app.ui.tool.roto_kind(false).is_some() {
-        super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, &to_screen, &to_layer);
+        super::roto_tool::interact(app, ui, &painter, area, &layer, zoom, super::roto_tool::LayerXform { to_screen: &to_screen, to_layer: &to_layer });
         return;
     }
     // Interaction: paint tools.
@@ -314,7 +402,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         painter.line_segment([s - vec2(0.0, 6.0), s + vec2(0.0, 6.0)], Stroke::new(1.0, Color32::WHITE));
     }
     let mods = ui.input(|i| i.modifiers);
-    let now = ui.input(|i| i.time);
     // Alt/Option-click with the Clone Stamp sets the source.
     if app.ui.tool == Tool::Clone
         && mods.alt
@@ -328,49 +415,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     if let Some(kind) = kind
         && !mods.alt
     {
-        if resp.drag_started()
-            && let Some(pos) = ui.input(|i| i.pointer.press_origin()).or(resp.interact_pointer_pos())
-        {
-            ctx.data_mut(|d| d.insert_temp(did, Drag { layer: layer.id.0, points: vec![to_layer(pos)], started: now }));
-        }
-        if let Some(mut d) = ctx.data(|d| d.get_temp::(did)) {
-            if let Some(pos) = resp.interact_pointer_pos() {
-                let lp = to_layer(pos);
-                let last = d.points.last().copied().unwrap_or(lp);
-                if (lp[0] - last[0]).hypot(lp[1] - last[1]) * zoom >= 1.0 {
-                    d.points.push(lp);
-                    ctx.data_mut(|dd| dd.insert_temp(did, d.clone()));
-                }
-            }
-            // Live preview of the stroke.
-            let col = if kind == "eraser" {
-                Color32::from_white_alpha(140)
-            } else {
-                Color32::from_rgba_unmultiplied((o.color[0] * 255.0) as u8, (o.color[1] * 255.0) as u8, (o.color[2] * 255.0) as u8, 200)
-            };
-            let width = ((o.diameter * zoom) as f32).max(1.0);
-            let pts: Vec = d.points.iter().map(|q| to_screen(*q)).collect();
-            if pts.len() == 1 {
-                painter.circle_filled(pts[0], width / 2.0, col);
-            } else {
-                painter.add(egui::Shape::line(pts, Stroke::new(width, col)));
-            }
-            if resp.drag_stopped() {
-                ctx.data_mut(|dd| dd.remove::(did));
-                let pts: Vec<[f64; 2]> = d.points.clone();
-                let r = app.session.execute("paint.stroke", json!({"layer": d.layer, "kind": kind, "points": pts, "duration": (now - d.started).max(0.0)}));
-                if let Err(e) = r {
-                    app.ui.status = e.to_string();
-                }
-            }
-        }
-        if resp.clicked()
-            && let Some(pos) = resp.interact_pointer_pos()
-        {
-            let r = app.session.execute("paint.stroke", json!({"layer": layer.id.0, "kind": kind, "points": [to_layer(pos)], "duration": 0.0}));
-            if let Err(e) = r {
-                app.ui.status = e.to_string();
-            }
-        }
+        let stroke = StrokeCtx { layer: layer.id.0, kind, zoom, did, diameter: o.diameter, color: o.color, to_layer: &to_layer, to_screen: &to_screen };
+        paint_stroke(app, ui, &painter, &resp, &stroke);
     }
 }
diff --git a/crates/ui-egui/src/panels/media_panels.rs b/crates/ui-egui/src/panels/media_panels.rs
index e07b25766..ffe2cc1f7 100644
--- a/crates/ui-egui/src/panels/media_panels.rs
+++ b/crates/ui-egui/src/panels/media_panels.rs
@@ -63,6 +63,27 @@ fn thumbnail(app: &EffectcraftApp, ctx: &egui::Context, path: &str, budget: &mut
     thumb
 }
 
+/// The Favorites column: one clickable row per place, the current folder `dir` highlighted.
+fn places_column(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, side: Rect, places: &[(String, String)], dir: &str) {
+    let t = app.tokens;
+    let mut fy = side.min.y + 22.0;
+    for (i, (name, path)) in places.iter().enumerate() {
+        let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
+        let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
+        if path == dir {
+            p.rect_filled(r, 0.0, t.row_selected);
+        } else if resp.hovered() {
+            p.rect_filled(r, 0.0, t.hover);
+        }
+        p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
+        app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
+        if resp.clicked() {
+            kit::exec(app, "mediaBrowser.go", json!({"path": path}));
+        }
+        fy += 20.0;
+    }
+}
+
 /// The Media Browser panel: a toolbar (up, home, favourite, "Media only" filter and the browser's
 /// own actions), the current path, the favourite places and a tile grid of the folder's entries
 /// with thumbnails for images and videos. Double-click opens a folder or imports a file; files
@@ -136,26 +157,11 @@ pub fn media_browser(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let side = Rect::from_min_max(pos2(rect.min.x, rect.min.y + 54.0), pos2(rect.min.x + 160.0_f32.min(rect.width() * 0.3), rect.max.y));
     p.line_segment([side.right_top(), side.right_bottom()], Stroke::new(1.0, t.separator));
     p.text(pos2(side.min.x + 10.0, side.min.y + 10.0), Align2::LEFT_CENTER, tr("Favorites"), Tokens::semibold(11.5), t.text_dim);
-    let mut fy = side.min.y + 22.0;
     let mut places: Vec<(String, String)> = b.places();
     places.extend(
         st.favorites.iter().map(|f| (std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| f.clone()), f.clone())),
     );
-    for (i, (name, path)) in places.iter().enumerate() {
-        let r = Rect::from_min_size(pos2(side.min.x, fy), vec2(side.width(), 20.0));
-        let resp = ui.interact(r, egui::Id::unique(("mb-fav", i)), Sense::click());
-        if *path == dir {
-            p.rect_filled(r, 0.0, t.row_selected);
-        } else if resp.hovered() {
-            p.rect_filled(r, 0.0, t.hover);
-        }
-        p.text(pos2(r.min.x + 14.0, r.center().y), Align2::LEFT_CENTER, name, Tokens::ui(12.0), t.text);
-        app.auto.add(&format!("mediaBrowser.place.{i}"), r, name);
-        if resp.clicked() {
-            kit::exec(app, "mediaBrowser.go", json!({"path": path}));
-        }
-        fy += 20.0;
-    }
+    places_column(app, ui, &p, side, &places, &dir);
     // Files.
     let list = Rect::from_min_max(pos2(side.max.x + 1.0, side.min.y), rect.max);
     let entries = match entries.as_ref() {
diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs
index bd0e716b4..dfa9a42e0 100644
--- a/crates/ui-egui/src/panels/misc.rs
+++ b/crates/ui-egui/src/panels/misc.rs
@@ -117,7 +117,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for (k, (key, text, v)) in [("preRoll", "Pre-roll", o.pre_roll), ("postRoll", "Post-roll", o.post_roll)].into_iter().enumerate() {
             let lx = xv + k as f32 * 100.0;
             p.text(pos2(lx, yy), Align2::LEFT_CENTER, text, Tokens::ui(11.5), t.text_dim);
-            let (r, nv, _) = widgets::hot_number_at(ui, pos2(lx + 52.0, yy - 9.0), egui::Id::unique(("pv-roll", key)), v, 0.05, (0.0, 600.0), 1, "s", &t);
+            let (r, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(lx + 52.0, yy - 9.0),
+                egui::Id::unique(("pv-roll", key)),
+                v,
+                &widgets::HotSpec { speed: 0.05, range: (0.0, 600.0), decimals: 1, suffix: "s" },
+                &t,
+            );
             app.auto.add(&format!("preview.{key}"), r, text);
             if let Some(nv) = nv {
                 changes.push(json!({"shortcut": current.id(), key: nv}));
@@ -160,7 +167,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     let sx = xv + 106.0;
     p.text(pos2(sx, yy), Align2::LEFT_CENTER, tr("Skip"), Tokens::ui(12.0), t.text_dim);
-    let (r, nv, _) = widgets::hot_number_at(ui, pos2(sx + 30.0, yy - 9.0), egui::Id::unique("pv-skip"), o.skip as f64, 0.1, (0.0, 99.0), 0, "", &t);
+    let (r, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(sx + 30.0, yy - 9.0),
+        egui::Id::unique("pv-skip"),
+        o.skip as f64,
+        &widgets::HotSpec { speed: 0.1, range: (0.0, 99.0), decimals: 0, suffix: "" },
+        &t,
+    );
     app.auto.add("preview.skip", r, "Skip");
     if let Some(nv) = nv {
         changes.push(json!({"shortcut": current.id(), "skip": nv.round() as u32}));
@@ -181,8 +195,14 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         changes.push(json!({"shortcut": current.id(), "resolution": res_ids[i]}));
     }
     if o.resolution == PreviewResolution::Custom {
-        let (r, nv, _) =
-            widgets::hot_number_at(ui, pos2(xv + 106.0, yy - 9.0), egui::Id::unique("pv-custom-res"), o.custom_resolution as f64, 0.1, (1.0, 40.0), 0, "", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(xv + 106.0, yy - 9.0),
+            egui::Id::unique("pv-custom-res"),
+            o.custom_resolution as f64,
+            &widgets::HotSpec { speed: 0.1, range: (1.0, 40.0), decimals: 0, suffix: "" },
+            &t,
+        );
         app.auto.add("preview.customResolution", r, "Custom Resolution");
         if let Some(nv) = nv {
             changes.push(json!({"shortcut": current.id(), "customResolution": nv.round() as u32}));
diff --git a/crates/ui-egui/src/panels/paint_panels.rs b/crates/ui-egui/src/panels/paint_panels.rs
index 43e4d438c..c867eb63a 100644
--- a/crates/ui-egui/src/panels/paint_panels.rs
+++ b/crates/ui-egui/src/panels/paint_panels.rs
@@ -36,7 +36,14 @@ fn number(
     let t = app.tokens;
     p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, label, Tokens::ui(11.5), if enabled { t.text_dim } else { t.text_faint });
     let lw = p.layout_no_wrap(label.to_string(), Tokens::ui(11.5), t.text).size().x;
-    let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + lw + 6.0, y), egui::Id::unique(("paint-num", key)), v, 0.5, range, 0, suffix, &t);
+    let (r, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(x + lw + 6.0, y),
+        egui::Id::unique(("paint-num", key)),
+        v,
+        &widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
+        &t,
+    );
     app.auto.add(key, r, label);
     nv.filter(|_| enabled)
 }
@@ -83,6 +90,48 @@ fn paint_swap_icon(p: &egui::Painter, rect: Rect, color: Color32) {
     p.arrow(pos2(right, bottom), vec2(left - right, 0.0), stroke);
 }
 
+/// The foreground-over-background swatches (each opens a colour popup) with Swap and Reset.
+fn colour_swatches(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, y: f32, color: [f64; 4], background: [f64; 4]) {
+    let t = app.tokens;
+    let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
+    let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
+    widgets::swatch(ui, bg, [background[0] as f32, background[1] as f32, background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
+    let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
+    if bresp.clicked() {
+        widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
+    }
+    if widgets::swatch(ui, fg, [color[0] as f32, color[1] as f32, color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
+        widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
+    }
+    app.auto.add("paint.color", fg, "Foreground color");
+    app.auto.add("paint.background", bg, "Background color");
+    let mut fc = rgb(color);
+    if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
+        set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
+    }
+    let mut bc = rgb(background);
+    if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
+        set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
+    }
+    let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
+    let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
+    // Two arrows (foreground over background): drawn, so the icon does not depend on a font
+    // carrying U+21C4 (the bundled fonts do not).
+    paint_swap_icon(p, swap, if sresp.hovered() { t.text } else { t.text_dim });
+    app.auto.add("paint.swapColors", swap, "Swap colors");
+    if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
+        set(app, json!({"color": background, "background": color}));
+    }
+    let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
+    let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
+    p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
+    p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
+    app.auto.add("paint.resetColors", reset, "Default colors");
+    if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
+        set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
+    }
+}
+
 /// The Paint panel: opacity and flow, foreground/background colours with swap and reset, mode,
 /// channels, duration, erase mode and the Clone Stamp options. Every change is sent as a
 /// `paint.options` command. Mode and Channels are disabled for the Eraser; Erase is enabled
@@ -106,43 +155,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     y += 28.0;
     // Colour swatches (foreground over background) with swap / reset.
-    let fg = Rect::from_min_size(pos2(x0, y), vec2(26.0, 20.0));
-    let bg = Rect::from_min_size(pos2(x0 + 14.0, y + 10.0), vec2(26.0, 20.0));
-    widgets::swatch(ui, bg, [o.background[0] as f32, o.background[1] as f32, o.background[2] as f32, 1.0], egui::Id::unique("paint-bg"), &t);
-    let bresp = ui.interact(Rect::from_min_max(pos2(fg.max.x, bg.min.y), bg.max), egui::Id::unique("paint-bg-hit"), Sense::click());
-    if bresp.clicked() {
-        widgets::open_popup(ui, egui::Id::unique("paint-bg-pop"));
-    }
-    if widgets::swatch(ui, fg, [o.color[0] as f32, o.color[1] as f32, o.color[2] as f32, 1.0], egui::Id::unique("paint-fg"), &t).clicked() {
-        widgets::open_popup(ui, egui::Id::unique("paint-fg-pop"));
-    }
-    app.auto.add("paint.color", fg, "Foreground color");
-    app.auto.add("paint.background", bg, "Background color");
-    let mut fc = rgb(o.color);
-    if crate::widgets::color_popup(ui, egui::Id::unique("paint-fg-pop"), fg.left_bottom(), &mut fc) {
-        set(app, json!({"color": [fc[0], fc[1], fc[2], 1.0]}));
-    }
-    let mut bc = rgb(o.background);
-    if crate::widgets::color_popup(ui, egui::Id::unique("paint-bg-pop"), bg.left_bottom(), &mut bc) {
-        set(app, json!({"background": [bc[0], bc[1], bc[2], 1.0]}));
-    }
-    let swap = Rect::from_min_size(pos2(x0 + 46.0, y), vec2(18.0, 14.0));
-    let sresp = ui.interact(swap, egui::Id::unique("paint-swap"), Sense::click());
-    // Two arrows (foreground over background): drawn, so the icon does not depend on a font
-    // carrying U+21C4 (the bundled fonts do not).
-    paint_swap_icon(&p, swap, if sresp.hovered() { t.text } else { t.text_dim });
-    app.auto.add("paint.swapColors", swap, "Swap colors");
-    if sresp.on_hover_text(tr("Swap Paint Colors (X)")).clicked() {
-        set(app, json!({"color": o.background, "background": o.color}));
-    }
-    let reset = Rect::from_min_size(pos2(x0 + 46.0, y + 18.0), vec2(18.0, 14.0));
-    let rresp = ui.interact(reset, egui::Id::unique("paint-reset-colors"), Sense::click());
-    p.rect_filled(Rect::from_min_size(reset.min + vec2(4.0, 3.0), vec2(7.0, 6.0)), 0.0, Color32::WHITE);
-    p.rect_stroke(Rect::from_min_size(reset.min + vec2(8.0, 6.0), vec2(7.0, 6.0)), 0.0, Stroke::new(1.0, t.text_dim), StrokeKind::Inside);
-    app.auto.add("paint.resetColors", reset, "Default colors");
-    if rresp.on_hover_text(tr("Set Paint Colors to Black and White (D)")).clicked() {
-        set(app, json!({"color": [1.0, 1.0, 1.0, 1.0], "background": [0.0, 0.0, 0.0, 1.0]}));
-    }
+    colour_swatches(app, ui, &p, x0, y, o.color, o.background);
     y += 40.0;
 
     // Mode / Channels / Duration.
@@ -167,10 +180,7 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             pos2(x0 + w - 52.0, y + 1.0),
             egui::Id::unique("paint-custom-frames"),
             o.custom_frames as f64,
-            0.2,
-            (1.0, 10000.0),
-            0,
-            " f",
+            &widgets::HotSpec { speed: 0.2, range: (1.0, 10000.0), decimals: 0, suffix: " f" },
             &t,
         );
         app.auto.add("paint.customFrames", r, "Custom duration (frames)");
@@ -239,7 +249,14 @@ pub fn paint(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let off = o.clone_offset.unwrap_or([0.0, 0.0]);
     p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Offset:"), Tokens::ui(11.5), t.text_dim);
     for (d, v) in off.iter().enumerate() {
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 56.0 + d as f32 * 64.0, y), egui::Id::unique(("clone-off", d)), *v, 1.0, (-1e5, 1e5), 0, "", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x0 + 56.0 + d as f32 * 64.0, y),
+            egui::Id::unique(("clone-off", d)),
+            *v,
+            &widgets::HotSpec { speed: 1.0, range: (-1e5, 1e5), decimals: 0, suffix: "" },
+            &t,
+        );
         app.auto.add(&format!("paint.cloneOffset.{d}"), r, "Clone offset");
         if let Some(nv) = nv {
             let mut no = off;
@@ -354,7 +371,14 @@ pub fn brushes(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     // Spacing (checkbox in AE turns spacing on; we always space dabs, so it shows the value).
     p.text(pos2(x0, y + 9.0), Align2::LEFT_CENTER, tr("Spacing:"), Tokens::ui(11.5), t.text_dim);
     let _ = r;
-    let (sr, nv, _) = widgets::hot_number_at(ui, pos2(x0 + 58.0, y), egui::Id::unique("brushes-spacing"), o.spacing, 0.5, (1.0, 1000.0), 0, " %", &t);
+    let (sr, nv, _) = widgets::hot_number_at(
+        ui,
+        pos2(x0 + 58.0, y),
+        egui::Id::unique("brushes-spacing"),
+        o.spacing,
+        &widgets::HotSpec { speed: 0.5, range: (1.0, 1000.0), decimals: 0, suffix: " %" },
+        &t,
+    );
     app.auto.add("brushes.spacing", sr, "Spacing");
     if let Some(nv) = nv {
         set(app, json!({"spacing": nv}));
diff --git a/crates/ui-egui/src/panels/panel_kit.rs b/crates/ui-egui/src/panels/panel_kit.rs
index 99554ec57..501ddf64f 100644
--- a/crates/ui-egui/src/panels/panel_kit.rs
+++ b/crates/ui-egui/src/panels/panel_kit.rs
@@ -47,7 +47,7 @@ pub fn label(ui: &egui::Ui, at: egui::Pos2, text: &str, t: &Tokens) {
 #[allow(clippy::too_many_arguments)]
 pub fn number(app: &mut EffectcraftApp, ui: &mut egui::Ui, at: egui::Pos2, auto: &str, v: f64, speed: f64, range: (f64, f64), dec: usize, suffix: &str) -> f64 {
     let t = app.tokens;
-    let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, speed, range, dec, suffix, &t);
+    let (r, nv, _) = widgets::hot_number_at(ui, at, egui::Id::unique(("kit-n", auto)), v, &widgets::HotSpec { speed, range, decimals: dec, suffix }, &t);
     app.auto.add(auto, r, &format!("{v}"));
     nv.unwrap_or(v)
 }
diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs
index 0d2769541..1944f333f 100644
--- a/crates/ui-egui/src/panels/project.rs
+++ b/crates/ui-egui/src/panels/project.rs
@@ -381,17 +381,9 @@ pub(crate) fn reveal(app: &mut EffectcraftApp, ctx: &egui::Context, items: &[Ite
     app.show_panel(crate::dock::PanelKind::Project);
 }
 
-/// The Project panel: the selected item's thumbnail and details (with the comps that use it), the
-/// search field, sortable column headers, and the item rows (folders, comps, footage and solids)
-/// with selection, Shift/Cmd ranges, keyboard navigation, inline rename, drag-and-drop move,
-/// marquee selection and context menus. Only the rows in view are drawn. File commands are queued
-/// and run through [`crate::menus::invoke`] at the end, with failures shown in `app.ui.status`.
-pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+/// The selected item's thumbnail and details (with the comps that use it), or a hint.
+fn details_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, ctx: &egui::Context, head: Rect) {
     let t = app.tokens;
-    let p = ui.painter().clone();
-    let ctx = ui.ctx().clone();
-    // Preview header.
-    let head = Rect::from_min_size(rect.min, vec2(rect.width(), 74.0));
     let sel = app.session.state.project_selection.first().copied();
     if let Some(it) = sel.and_then(|i| app.session.project.item(i)).cloned() {
         let thumb = Rect::from_min_size(head.min + vec2(10.0, 10.0), vec2(96.0, 54.0));
@@ -400,12 +392,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ItemKind::Solid(s) => {
                 p.rect_filled(thumb.shrink(4.0), 2.0, Color32::from_rgb((s.color[0] * 255.0) as u8, (s.color[1] * 255.0) as u8, (s.color[2] * 255.0) as u8));
             }
-            _ => match thumbnail(app, &ctx, &it) {
+            _ => match thumbnail(app, ctx, &it) {
                 Some(tex) => {
                     let [w, h] = tex.size();
                     p.image(tex.id(), fit(thumb, w as f32, h as f32), Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
                 }
-                None => icons::paint(&p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
+                None => icons::paint(p, Rect::from_center_size(thumb.center(), vec2(24.0, 24.0)), item_icon(&it), t.text_dim),
             },
         }
         app.auto.add("project.thumbnail", thumb, &it.name);
@@ -418,7 +410,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let count = users.iter().fold(0usize, |n, (_, k)| n.saturating_add(*k));
         let name_y = thumb.min.y + 6.0;
         if count == 0 {
-            let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text);
+            let r = widgets::text_fit(p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), max_w, t.text);
             app.auto.add("project.details.name", r, &it.name);
         } else {
             let used = if count == 1 { crate::i18n::tr("used once").to_owned() } else { crate::i18n::tr_args("used {} times", &[&count]) };
@@ -427,17 +419,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let arrow_w = 14.0;
             // The name gives way first, but keeps at least half the room; the count is cut after it.
             let name_max = (max_w - arrow_w - used_w).max(max_w * 0.5);
-            let r = widgets::text_fit(&p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text);
+            let r = widgets::text_fit(p, pos2(tx, name_y), Align2::LEFT_CENTER, &it.name, Tokens::semibold(12.0), name_max, t.text);
             app.auto.add("project.details.name", r, &it.name);
             let ar = Rect::from_min_size(pos2(r.max.x + 1.0, name_y - 7.0), vec2(arrow_w, 14.0));
             let tip = crate::i18n::tr("Compositions that use this item");
             let aresp = ui.interact(ar, egui::Id::unique("proj-used-in"), Sense::click()).on_hover_text(tip);
-            icons::paint(&p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
+            icons::paint(p, Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), Icon::ChevronDown, if aresp.hovered() { t.text } else { t.text_dim });
             app.auto.add("project.details.usedIn", ar, tip);
             // The comma sits right after the arrow (its glyph ends short of the hit area).
             let ux = ar.max.x - 3.0;
             let room = (head.max.x - 10.0 - ux).max(0.0);
-            let ur = widgets::text_fit(&p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim);
+            let ur = widgets::text_fit(p, pos2(ux, name_y), Align2::LEFT_CENTER, &used, Tokens::ui(11.0), room, t.text_dim);
             app.auto.add("project.details.used", ur, &used);
             let names: Vec<(u64, String, usize)> =
                 users.iter().map(|(cid, n)| (cid.0, app.session.project.item(*cid).map(|c| c.name.clone()).unwrap_or_default(), *n)).collect();
@@ -454,7 +446,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 }
             });
             if let Some(cid) = open
-                && let Err(e) = crate::menus::invoke(app, &ctx, "comp.open", json!({"comp": cid}))
+                && let Err(e) = crate::menus::invoke(app, ctx, "comp.open", json!({"comp": cid}))
             {
                 app.ui.status = e;
             }
@@ -471,23 +463,37 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             lines.push(f.codec.clone());
         }
         for (i, l) in lines.iter().enumerate() {
-            let r = widgets::text_fit(&p, pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), max_w, t.text_dim);
+            let r = widgets::text_fit(p, pos2(tx, thumb.min.y + 22.0 + 13.0 * i as f32), Align2::LEFT_CENTER, l, Tokens::ui(11.0), max_w, t.text_dim);
             app.auto.add(&format!("project.details.{i}"), r, l);
         }
     } else {
         let hint = "Select an item to see its details";
-        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);
+        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, 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.
-    let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
-    p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+}
+
+/// Column positions: Name and Label, then the visible optional columns (scrolled by `hscroll`).
+struct Columns {
+    cols: Vec<(&'static str, &'static str, f32, f32)>,
+    name_w: f32,
+    label_x: f32,
+    opt_x0: f32,
+    /// How far the optional columns overflow the panel.
+    overflow: f32,
+    hscroll: f32,
+    /// Where the last column ends.
+    end_x: f32,
+}
+
+impl Columns {
+    /// `r` cut off under the frozen Name and Label columns.
+    fn opt_clip(&self, r: Rect) -> Rect {
+        Rect::from_min_max(pos2(r.min.x.max(self.opt_x0 - 4.0), r.min.y), r.max)
+    }
+}
+
+fn layout_columns(app: &mut EffectcraftApp, rect: Rect) -> Columns {
     let name_w = (rect.width() * 0.40).clamp(120.0, 260.0);
     let label_x = rect.min.x + name_w + 14.0;
     let mut cols: Vec<(&'static str, &'static str, f32, f32)> = vec![("name", "Name", rect.min.x + 26.0, name_w - 26.0), ("label", "", label_x - 8.0, 18.0)];
@@ -505,11 +511,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             x += w + 4.0;
         }
     }
-    let opt_clip = |r: Rect| Rect::from_min_max(pos2(r.min.x.max(opt_x0 - 4.0), r.min.y), r.max);
+    Columns { cols, name_w, label_x, opt_x0, overflow, hscroll, end_x: x }
+}
+
+/// The column header row: sort labels with the sort arrow, and the Columns menu.
+fn draw_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, hdr: Rect, cl: &Columns) {
+    let t = app.tokens;
+    let Columns { cols, label_x, end_x, .. } = cl;
+    let (label_x, end_x) = (*label_x, *end_x);
     let hp = p.with_clip_rect(hdr);
-    for (key, label, x, w) in &cols {
+    for (key, label, x, w) in cols {
         let hr = Rect::from_min_size(pos2(x - 4.0, hdr.min.y), vec2(*w, hdr.height()));
-        let hr = if matches!(*key, "name" | "label") { hr } else { opt_clip(hr) };
+        let hr = if matches!(*key, "name" | "label") { hr } else { cl.opt_clip(hr) };
         if hr.width() <= 0.0 {
             continue;
         }
@@ -553,446 +566,202 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         column_menu(app, &resp);
     }
-    let hresp = ui.interact(Rect::from_min_max(pos2(x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
+    let hresp = ui.interact(Rect::from_min_max(pos2(end_x, hdr.min.y), hdr.max), egui::Id::unique("proj-hdr-rest"), Sense::CLICK);
     column_menu(app, &hresp);
     app.auto.add("project.columns", hdr, "Columns (right-click to show or hide)");
-    // Rows.
-    let footer_h = 28.0;
-    let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
-    let lp = p.with_clip_rect(list);
-    // The list's empty area (under the rows and the scroll bar, which take their own clicks):
-    // a click deselects, a drag draws a selection box, a double-click imports, as in After
-    // Effects (File ▸ Import ▸ File...).
-    // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
-    let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
-    app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
-    if overflow > 0.0 && ui.rect_contains_pointer(list) {
-        let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
-        let d = if dx.abs() > 0.0 {
-            dx
-        } else if shift {
-            dy
-        } else {
-            0.0
-        };
-        app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
+}
+
+/// An item row's context menu: Composition Settings, Delete, Rename, Duplicate, labels and the
+/// footage commands.
+fn item_menu(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    ctx: &egui::Context,
+    it: &effectcraft_engine::project::Item,
+    id: ItemId,
+    selected: bool,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let footage = matches!(it.kind, ItemKind::Footage(_));
+    // First on a comp, as in After Effects; for this comp, not the viewer's (#1058).
+    if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() {
+        actions.push(("comp.settings".into(), json!({"comp": id.0})));
+        ui.close();
     }
-    let moved = keyboard_nav(app, &ctx);
-    let rows = visible_rows(app);
-    // Enter renames the selected item (Project panel focused, not typing).
-    if app.ui.focused == crate::dock::PanelKind::Project
-        && app.dialog.is_none()
-        && keys_reach_rows(app, &ctx)
-        && ctx.data(|d| d.get_temp::(edit_id())).is_none()
-        && ctx.input(|i| i.key_pressed(egui::Key::Enter))
-    {
-        begin_rename(app, &ctx);
+    if ui.button(crate::i18n::tr("Delete")).clicked() {
+        actions.push(("project.delete".into(), json!({"items": targets(app, id, selected)})));
+        ui.close();
     }
-    let editing: Option = ctx.data(|d| d.get_temp(edit_id()));
-    let dragging = egui::DragAndDrop::payload::(&ctx).and_then(|p| match *p {
-        DragPayload::Item(i) => Some(i),
-        _ => None,
+    if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Open Composition")).clicked() {
+        actions.push(("comp.open".into(), json!({"comp": id.0})));
+        ui.close();
+    }
+    if ui.button(crate::i18n::tr("Rename")).clicked() {
+        ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone())));
+        ui.close();
+    }
+    let duplicate_items = targets(app, id, selected);
+    // Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
+    let can_duplicate = !duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
+    let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
+    if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
+        duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
+    }
+    let response = ui.add_enabled(can_duplicate, duplicate);
+    app.auto.add("project.context.duplicate", response.rect, "Duplicate");
+    if response.clicked() {
+        actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
+        ui.close();
+    }
+    if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
+        actions.push(("project.move".into(), json!({"items": targets(app, id, selected), "folder": null})));
+        ui.close();
+    }
+    if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() {
+        actions.push(("layer.addItem".into(), json!({"item": id.0})));
+        ui.close();
+    }
+    // File ▸ New Comp from Selection on the selection (the right-clicked row joins it
+    // above): several items ask how in its dialog, as in After Effects (#360).
+    if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() {
+        actions.push(("file.newCompFromSelection".into(), json!({})));
+        ui.close();
+    }
+    // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a
+    // new composition holding it instead. The label says how many layers that changes.
+    let uses: usize = app.session.project.item_users(id).iter().map(|(_, n)| n).sum();
+    let label = match uses {
+        0 => crate::i18n::tr("Replace with Precomposition").to_owned(),
+        1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")),
+        n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])),
+    };
+    if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() {
+        actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0})));
+        ui.close();
+    }
+    ui.separator();
+    ui.menu_button(crate::i18n::tr("Label"), |ui| {
+        for l in Label::ALL {
+            if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
+                actions.push(("project.setLabel".into(), json!({"items": targets(app, id, selected), "label": label_index(l)})));
+                ui.close();
+            }
+        }
     });
-    let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
-    let mut drop_row: Option = None;
-    let mut actions: Vec<(String, serde_json::Value)> = vec![];
-    // Vertical scrolling: only the rows in view are drawn (and registered), so a project with
-    // thousands of items costs what one screenful does.
-    let content_h = rows.len() as f32 * ROW_H;
-    let max_scroll = (content_h - list.height() + ROW_H).max(0.0);
-    if ui.rect_contains_pointer(list) {
-        let (dy, shift) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.shift));
-        if !shift && dy != 0.0 {
-            app.ui.project_scroll -= dy;
-        }
+    // Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
+    if footage {
+        let mut chosen = None;
+        crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
+        crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
+        crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
+        crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
+        actions.extend(chosen);
     }
-    // A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
-    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::>(sel_key)) != Some(sel_now) {
-        ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
-        if let Some(row) = sel_now.and_then(|s| rows.iter().position(|(id, _)| id.0 == s)) {
-            let top = row as f32 * ROW_H;
-            if top < app.ui.project_scroll {
-                app.ui.project_scroll = top;
-            } else if top + ROW_H > app.ui.project_scroll + list.height() {
-                app.ui.project_scroll = top + ROW_H - list.height();
-            }
-        }
+}
+
+/// The inline rename / comment field over `cell` when it is item `id`'s `field` being edited;
+/// returns whether it took the cell.
+fn cell_edit(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    actions: &mut Vec<(String, serde_json::Value)>,
+    editing: &Option,
+    id: u64,
+    field: &str,
+    cell: Rect,
+) -> bool {
+    let ctx = ui.ctx().clone();
+    let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id && f == field) else { return false };
+    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell));
+    // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no
+    // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel.
+    let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() };
+    let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter));
+    app.auto.add(&format!("project.item.{}.{field}Edit", id), cell, field);
+    let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab));
+    if tab {
+        resp.surrender_focus();
     }
-    // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
-    if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
-        let top = row as f32 * ROW_H;
-        // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
-        app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
-    }
-    app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
-    let scroll = app.ui.project_scroll;
-    let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
-    let mut y = list.min.y - (scroll - first as f32 * ROW_H);
-    let project = app.session.project.clone();
-    for (i, (id, depth)) in rows.iter().enumerate().skip(first) {
-        let Some(it) = project.item(*id) else { continue };
-        let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
-        y += ROW_H;
-        if r.min.y > list.max.y {
-            break;
-        }
-        if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) {
-            drop_row = Some(*id);
-        }
-        let selected = app.session.state.project_selection.contains(id);
-        lp.rect_filled(
-            r,
-            0.0,
-            // Flat rows like After Effects' Project panel (no stripes).
-            if selected { t.row_selected } else { t.row },
-        );
-        // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
-        // on top of it take their own clicks.
-        let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
-        app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
-        // For screen readers: a tree item named by the item and its type, with its selection,
-        // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
-        let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
-        resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
-        ctx.accesskit_node_builder(resp.id, |n| {
-            n.set_selected(selected);
-            n.set_level(depth.saturating_add(1));
-            if let Some(open) = open {
-                n.set_expanded(open);
-            }
-        });
-        if resp.has_focus() {
-            ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
-        }
-        if resp.gained_focus() {
-            app.ui.focused = crate::dock::PanelKind::Project;
-        }
-        let _ = i;
-        let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
-        if it.is_folder() {
-            let open = app.ui.project_open_folders.contains(&id.0);
-            let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
-            let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", id.0)), &t);
-            tresp.widget_info(|| {
-                let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
-                egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-            });
-            if tresp.clicked() {
-                // Alt/Cmd-click twirls every folder inside it too.
-                let mut ids = vec![*id];
-                if ui.input(|i| i.modifiers.alt || i.modifiers.command) {
-                    let mut at = 0;
-                    while let Some(f) = ids.get(at).copied() {
-                        ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id));
-                        at += 1;
-                    }
-                }
-                for f in ids {
-                    if open {
-                        app.ui.project_open_folders.remove(&f.0);
-                    } else {
-                        app.ui.project_open_folders.insert(f.0);
-                    }
-                }
-            }
-            app.auto.add(&format!("project.item.{}.twirl", id.0), tw, &it.name);
-        }
-        icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim);
-        // Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
-        if let Some(px) = it.proxy.as_ref() {
-            let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
-            if px.enabled {
-                lp.rect_filled(pr, 1.0, t.text);
+    if resp.lost_focus() || tab {
+        ctx.data_mut(|d| d.remove::(edit_id()));
+        if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
+            let (cmd, params) = if ef == "name" {
+                ("project.rename", json!({"item": eid, "name": buf}))
             } else {
-                lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
-            }
-            let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", id.0)), Sense::click()).on_hover_text(format!(
-                "Proxy: {} ({})",
-                px.footage.path,
-                if px.enabled { "in use" } else { "off" }
-            ));
-            app.auto.add(&format!("project.item.{}.proxy", id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
-            presp.widget_info(|| {
-                let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
-                egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-            });
-            if presp.clicked() {
-                actions.push(("file.useProxy".into(), json!({"item": id.0})));
-            }
-        }
-        // Footage that can't be decoded (no decoder for its codec / profile): a warning marker
-        // at the end of the name cell, the reason on hover.
-        let problem = match &it.kind {
-            ItemKind::Footage(f) => app.session.footage.problem(f),
-            _ => None,
-        };
-        let name_clip =
-            Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list);
-        if let Some(why) = &problem {
-            let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
-            lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
-            lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
-            ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why.as_str());
-            app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
-        }
-        let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
-        // Inline edits (rename / comment).
-        let cell_edit = |app: &mut EffectcraftApp, ui: &mut egui::Ui, actions: &mut Vec<(String, serde_json::Value)>, field: &str, cell: Rect| -> bool {
-            let Some((eid, ef, mut buf)) = editing.clone().filter(|(e, f, _)| *e == id.0 && f == field) else { return false };
-            let mut child = ui.new_child(egui::UiBuilder::new().max_rect(cell));
-            // The field keeps Tab (egui would move the keyboard to the next widget, where Enter no
-            // longer renames, #881): Tab commits like Enter and gives the keyboard back to the panel.
-            let filter = egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, ..Default::default() };
-            let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(cell.width()).font(Tokens::ui(12.0)).event_filter(filter));
-            app.auto.add(&format!("project.item.{}.{field}Edit", id.0), cell, field);
-            let tab = resp.has_focus() && ctx.input(|i| i.key_pressed(egui::Key::Tab));
-            if tab {
-                resp.surrender_focus();
-            }
-            if resp.lost_focus() || tab {
-                ctx.data_mut(|d| d.remove::(edit_id()));
-                if !ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
-                    let (cmd, params) = if ef == "name" {
-                        ("project.rename", json!({"item": eid, "name": buf}))
-                    } else {
-                        ("project.setComment", json!({"items": [eid], "comment": buf}))
-                    };
-                    actions.push((cmd.into(), params));
-                }
-            } else {
-                widgets::keep_focus(&ctx, &resp, &buf);
-                ctx.data_mut(|d| d.insert_temp::(edit_id(), (eid, ef, buf)));
-            }
-            true
-        };
-        if !cell_edit(app, ui, &mut actions, "name", name_rect) {
-            // The selected item's name sits in a light cell with dark text.
-            if selected {
-                let g = lp.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), Color32::BLACK);
-                let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0));
-                lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6));
-            }
-            // Long names end in "…" before the label column instead of being cut mid-glyph.
-            widgets::text_fit(
-                &lp.with_clip_rect(name_clip),
-                pos2(x0 + 30.0, r.center().y),
-                Align2::LEFT_CENTER,
-                &it.name,
-                Tokens::ui(12.0),
-                name_clip.max.x - (x0 + 30.0) - 2.0,
-                if selected { Color32::from_gray(0x16) } else { t.text },
-            );
-        }
-        // Label swatch: click for the label colour menu.
-        let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
-        lp.rect_filled(sw, 2.0, t.label(it.label));
-        let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", id.0)), Sense::click());
-        app.auto.add(&format!("project.item.{}.label", id.0), sw, it.label.name());
-        sresp.widget_info(|| {
-            let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
-            egui::WidgetInfo::labeled(egui::Role::Button, true, name)
-        });
-        let pop = egui::Id::unique(("plabel-pop", id.0));
-        if sresp.clicked() || sresp.secondary_clicked() {
-            widgets::open_popup(ui, pop);
-        }
-        if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
-            actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
-        }
-        // Optional columns.
-        for (key, _, cx, w) in cols.iter().skip(2) {
-            let cell = opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
-            if cell.width() <= 0.0 {
-                continue;
-            }
-            let cp = lp.with_clip_rect(cell);
-            let txt = match *key {
-                "type" => it.type_name().to_string(),
-                "size" => file_size(it).map(fmt_size).unwrap_or_default(),
-                "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
-                "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
-                "path" => file_path(it).to_string(),
-                "comment" => {
-                    let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0));
-                    if cell_edit(app, ui, &mut actions, "comment", cr) {
-                        continue;
-                    }
-                    let cresp = ui.interact(cell, egui::Id::unique(("pcomment", id.0)), Sense::CLICK);
-                    app.auto.add(&format!("project.item.{}.comment", id.0), cell, &it.comment);
-                    cresp.widget_info(|| {
-                        egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment]))
-                    });
-                    if cresp.double_clicked() {
-                        ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "comment".into(), it.comment.clone())));
-                    } else {
-                        // Other clicks select the row and open its context menu.
-                        resp = resp.union(cresp);
-                    }
-                    it.comment.clone()
-                }
-                _ => String::new(),
+                ("project.setComment", json!({"items": [eid], "comment": buf}))
             };
-            let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER };
-            let tx = if *key == "fps" { cx + w - 6.0 } else { *cx };
-            cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim);
+            actions.push((cmd.into(), params));
         }
-        if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
-            lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
-        }
-        // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
-        if ctx.memory(|m| m.has_focus(row_id(*id))) {
-            lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
-        }
-        // Part of the row: not a second Tab stop.
-        let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
-        app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
-        let resp = resp.union(nresp);
-        if resp.clicked() {
-            let m = ui.input(|i| i.modifiers);
-            let anchor_key = anchor_id();
-            ctx.data_mut(|d| d.insert_temp(cursor_id(), *id));
-            let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
-            if m.shift
-                && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
-            {
-                let b = rows.iter().position(|(i, _)| i == id).unwrap_or(a);
-                app.session.state.project_selection = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
-            } else if m.command {
-                if app.session.state.project_selection.contains(id) {
-                    app.session.state.project_selection.retain(|i| i != id);
-                } else {
-                    app.session.state.project_selection.push(*id);
-                }
-                ctx.data_mut(|d| d.insert_temp(anchor_key, *id));
-            } else {
-                app.session.state.project_selection = vec![*id];
-                ctx.data_mut(|d| d.insert_temp(anchor_key, *id));
-            }
-        }
-        // Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and
-        // the viewer); Enter or the context menu renames.
-        if resp.double_clicked() {
-            match &it.kind {
-                ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
-                ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
-                    app.ui.project_open_folders.insert(id.0);
-                }
-                // Footage (not data) and solids open in the Footage panel.
-                ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => {
-                    actions.push(("footage.open".into(), json!({"item": id.0})))
-                }
-                ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))),
-                _ => {}
-            }
-        }
-        if resp.drag_started() {
-            egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
-        }
-        // Right-clicking an item selects it (After Effects): the menu's File commands act on the
-        // selection.
-        if resp.secondary_clicked() && !selected {
-            app.session.state.project_selection = vec![*id];
-        }
-        let footage = matches!(it.kind, ItemKind::Footage(_));
-        crate::widgets::context_menu(&resp, |ui| {
-            // First on a comp, as in After Effects; for this comp, not the viewer's (#1058).
-            if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Composition Settings…")).clicked() {
-                actions.push(("comp.settings".into(), json!({"comp": id.0})));
-                ui.close();
-            }
-            if ui.button(crate::i18n::tr("Delete")).clicked() {
-                actions.push(("project.delete".into(), json!({"items": targets(app, *id, selected)})));
-                ui.close();
-            }
-            if matches!(it.kind, ItemKind::Comp(_)) && ui.button(crate::i18n::tr("Open Composition")).clicked() {
-                actions.push(("comp.open".into(), json!({"comp": id.0})));
-                ui.close();
-            }
-            if ui.button(crate::i18n::tr("Rename")).clicked() {
-                ctx.data_mut(|d| d.insert_temp::(edit_id(), (id.0, "name".into(), it.name.clone())));
-                ui.close();
-            }
-            let duplicate_items = targets(app, *id, selected);
-            // Like Edit > Duplicate: folders disable the whole operation, including mixed selections.
-            let can_duplicate =
-                !duplicate_items.is_empty() && duplicate_items.iter().all(|id| app.session.project.item(ItemId(*id)).is_some_and(|i| !i.is_folder()));
-            let mut duplicate = egui::Button::new(crate::i18n::tr("Duplicate"));
-            if let Some(shortcut) = app.session.shortcuts().shortcut_of("edit.duplicate", &serde_json::Value::Null) {
-                duplicate = duplicate.shortcut_text(crate::menus::shortcut_text(shortcut));
-            }
-            let response = ui.add_enabled(can_duplicate, duplicate);
-            app.auto.add("project.context.duplicate", response.rect, "Duplicate");
-            if response.clicked() {
-                actions.push(("project.duplicate".into(), json!({"items": duplicate_items})));
-                ui.close();
-            }
-            if it.parent.is_some() && ui.button(crate::i18n::tr("Move to Project Root")).clicked() {
-                actions.push(("project.move".into(), json!({"items": targets(app, *id, selected), "folder": null})));
-                ui.close();
-            }
-            if !it.is_folder() && ui.button(crate::i18n::tr("Add to Composition")).clicked() {
-                actions.push(("layer.addItem".into(), json!({"item": id.0})));
-                ui.close();
-            }
-            // File ▸ New Comp from Selection on the selection (the right-clicked row joins it
-            // above): several items ask how in its dialog, as in After Effects (#360).
-            if ui.button(crate::i18n::tr("New Comp from Selection")).clicked() {
-                actions.push(("file.newCompFromSelection".into(), json!({})));
-                ui.close();
-            }
-            // File ▸ Replace Footage ▸ With Precomposition: every layer using the item uses a
-            // new composition holding it instead. The label says how many layers that changes.
-            let uses: usize = app.session.project.item_users(*id).iter().map(|(_, n)| n).sum();
-            let label = match uses {
-                0 => crate::i18n::tr("Replace with Precomposition").to_owned(),
-                1 => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr("used once")),
-                n => format!("{} ({})", crate::i18n::tr("Replace with Precomposition"), crate::i18n::tr_args("used {} times", &[&n])),
-            };
-            if (footage || matches!(it.kind, ItemKind::Solid(_))) && ui.button(label).clicked() {
-                actions.push(("file.replaceWithPrecomp".into(), json!({"item": id.0})));
-                ui.close();
-            }
-            ui.separator();
-            ui.menu_button(crate::i18n::tr("Label"), |ui| {
-                for l in Label::ALL {
-                    if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), l == it.label, &t).clicked() {
-                        actions.push(("project.setLabel".into(), json!({"items": targets(app, *id, selected), "label": label_index(l)})));
-                        ui.close();
-                    }
-                }
-            });
-            // Footage: the File menu's footage commands (Interpret Footage, Replace Footage…).
-            if footage {
-                let mut chosen = None;
-                crate::menus::submenu_at(app, ui, &["File", "Interpret Footage"], &mut chosen);
-                crate::menus::submenu_at(app, ui, &["File", "Replace Footage"], &mut chosen);
-                crate::menus::entry_for(app, ui, "file.reloadFootage", &mut chosen);
-                crate::menus::entry_for(app, ui, "file.revealInFinder", &mut chosen);
-                actions.extend(chosen);
-            }
-        });
+    } else {
+        widgets::keep_focus(&ctx, &resp, &buf);
+        ctx.data_mut(|d| d.insert_temp::(edit_id(), (eid, ef, buf)));
     }
-    // The scroll bars, over the rows (which would take their presses otherwise).
-    if overflow > 0.0 {
-        let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
-        app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t);
+    true
+}
+
+/// A click on item `id`'s row: plain selects it, Shift extends from the anchor and Cmd toggles.
+fn select_click(app: &mut EffectcraftApp, ui: &egui::Ui, ctx: &egui::Context, id: ItemId, rows: &[(ItemId, usize)]) {
+    let m = ui.input(|i| i.modifiers);
+    let anchor_key = anchor_id();
+    ctx.data_mut(|d| d.insert_temp(cursor_id(), id));
+    let anchor = ctx.data(|d| d.get_temp::(anchor_key)).or_else(|| app.session.state.project_selection.first().copied());
+    if m.shift
+        && let Some(a) = anchor.and_then(|a| rows.iter().position(|(i, _)| *i == a))
+    {
+        let b = rows.iter().position(|(i, _)| *i == id).unwrap_or(a);
+        app.session.state.project_selection = rows.get(a.min(b)..=a.max(b)).unwrap_or_default().iter().map(|(i, _)| *i).collect();
+    } else if m.command {
+        if app.session.state.project_selection.contains(&id) {
+            app.session.state.project_selection.retain(|i| *i != id);
+        } else {
+            app.session.state.project_selection.push(id);
+        }
+        ctx.data_mut(|d| d.insert_temp(anchor_key, id));
+    } else {
+        app.session.state.project_selection = vec![id];
+        ctx.data_mut(|d| d.insert_temp(anchor_key, id));
     }
-    if max_scroll > 0.0 {
-        let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
-        app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t);
+}
+
+/// Double-click opens the item, as in After Effects: a comp in its Timeline and the viewer, a
+/// folder toggles, footage (not data) and solids open in the Footage panel.
+fn open_item(app: &mut EffectcraftApp, it: &effectcraft_engine::project::Item, id: ItemId, actions: &mut Vec<(String, serde_json::Value)>) {
+    match &it.kind {
+        ItemKind::Comp(_) => actions.push(("comp.open".into(), json!({"comp": id.0}))),
+        ItemKind::Folder if !app.ui.project_open_folders.remove(&id.0) => {
+            app.ui.project_open_folders.insert(id.0);
+        }
+        // Footage (not data) and solids open in the Footage panel.
+        ItemKind::Footage(f) if f.kind != effectcraft_engine::project::FootageKind::Data => actions.push(("footage.open".into(), json!({"item": id.0}))),
+        ItemKind::Solid(_) => actions.push(("footage.open".into(), json!({"item": id.0}))),
+        _ => {}
     }
+}
+
+/// What the pointer is doing over the list: the item being dragged, the row under it, whether it
+/// is over the list at all.
+struct DropState {
+    dragging: Option,
+    row: Option,
+    hovering: bool,
+}
+
+/// Dragging an item or files over the list: drop outlines, moving items into folders, importing
+/// files and the drag ghost.
+fn drop_targets(app: &mut EffectcraftApp, ctx: &egui::Context, lp: &egui::Painter, list: Rect, ds: DropState, actions: &mut Vec<(String, serde_json::Value)>) {
+    let t = app.tokens;
+    let DropState { dragging, row, hovering } = ds;
     // Dropping a dragged item on the list: into the folder under the pointer (or the folder of
     // the item under it), or the project root below the rows.
     if let Some(iid) = dragging
-        && hover_y.is_some()
+        && hovering
     {
-        if drop_row.is_none() {
+        if row.is_none() {
             lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
         }
-        if ctx.input(|i| i.pointer.any_released()) && drop_row != Some(ItemId(iid)) {
-            let folder = drop_folder(&app.session.project, drop_row);
+        if ctx.input(|i| i.pointer.any_released()) && row != Some(ItemId(iid)) {
+            let folder = drop_folder(&app.session.project, row);
             let cur = app.session.project.item(ItemId(iid)).and_then(|i| i.parent);
             if folder != cur {
                 let moving: Vec = if app.session.state.project_selection.contains(&ItemId(iid)) {
@@ -1005,21 +774,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     app.ui.project_open_folders.insert(f.0);
                 }
             }
-            egui::DragAndDrop::clear_payload(&ctx);
+            egui::DragAndDrop::clear_payload(ctx);
         }
     }
     // Files dragged from the Media Browser: import.
-    if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::(&ctx).as_deref()
-        && hover_y.is_some()
+    if let Some(DragPayload::Files(paths)) = egui::DragAndDrop::payload::(ctx).as_deref()
+        && hovering
     {
         lp.rect_stroke(list.shrink(1.0), 0.0, Stroke::new(1.0, t.accent.gamma_multiply(0.6)), egui::StrokeKind::Inside);
         if ctx.input(|i| i.pointer.any_released()) {
             actions.push(("mediaBrowser.import".into(), json!({"paths": paths})));
-            egui::DragAndDrop::clear_payload(&ctx);
+            egui::DragAndDrop::clear_payload(ctx);
         }
     }
     // Drag ghost.
-    if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(&ctx).as_deref()
+    if let Some(DragPayload::Item(iid)) = egui::DragAndDrop::payload::(ctx).as_deref()
         && let Some(pos) = ctx.pointer_hover_pos()
         && let Some(it) = app.session.project.item(ItemId(*iid))
     {
@@ -1029,8 +798,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         painter.rect_filled(r, 4.0, Color32::from_rgba_premultiplied(40, 40, 40, 230));
         painter.galley(r.min + vec2(8.0, 4.0), g, t.text);
     }
-    // Footer.
-    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
+}
+
+/// The footer: Interpret Footage, New Folder, New Composition, the colour depth and the trash.
+fn footer(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, foot: Rect, actions: &mut Vec<(String, serde_json::Value)>) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
     p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
     let mut x = foot.min.x + 8.0;
     for (icon, id, tip) in [
@@ -1092,23 +865,102 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(("project.delete".into(), json!({})));
     }
     app.auto.add("project.delete", tr, "Delete");
-    if empty.double_clicked() {
-        actions.push(("file.import".into(), json!({})));
-    }
-    crate::widgets::context_menu(&empty, |ui| {
-        for (key, label, command) in
-            [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")]
-        {
-            let response = ui.button(label);
-            app.auto.add(&format!("project.context.{key}"), response.rect, label);
-            if response.clicked() {
-                actions.push((command.into(), json!({})));
-                ui.close();
-            }
+}
+
+/// The optional columns of a row (type, size, duration, fps, path, comment). Returns the comment
+/// cell's response so its clicks select the row and open its context menu.
+fn optional_cells(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    cl: &Columns,
+    editing: &Option,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) -> Option {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    let RowGeom { lp, list, r, .. } = *g;
+    let mut extra = None;
+    for (key, _, cx, w) in cl.cols.iter().skip(2) {
+        let cell = cl.opt_clip(Rect::from_min_size(pos2(*cx, r.min.y), vec2(*w, r.height())).intersect(list));
+        if cell.width() <= 0.0 {
+            continue;
         }
-    });
-    // Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
-    let adding = ui.input(|i| i.modifiers.shift || i.modifiers.command);
+        let cp = lp.with_clip_rect(cell);
+        let txt = match *key {
+            "type" => it.type_name().to_string(),
+            "size" => file_size(it).map(fmt_size).unwrap_or_default(),
+            "duration" => it.duration().map(|d| fmt_dur(d.seconds(), it.frame_rate().map(|r| r.as_f64()).unwrap_or(30.0))).unwrap_or_default(),
+            "fps" => it.frame_rate().map(|f| format!("{:.2}", f.as_f64())).unwrap_or_default(),
+            "path" => file_path(it).to_string(),
+            "comment" => {
+                let cr = Rect::from_min_size(pos2(*cx - 2.0, r.min.y + 2.0), vec2(*w, r.height() - 4.0));
+                if cell_edit(app, ui, actions, editing, it.id.0, "comment", cr) {
+                    continue;
+                }
+                let cresp = ui.interact(cell, egui::Id::unique(("pcomment", it.id.0)), Sense::CLICK);
+                app.auto.add(&format!("project.item.{}.comment", it.id.0), cell, &it.comment);
+                cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Unknown, true, crate::i18n::tr_args("Comment of {}: {}", &[&it.name, &it.comment])));
+                if cresp.double_clicked() {
+                    ctx.data_mut(|d| d.insert_temp::(edit_id(), (it.id.0, "comment".into(), it.comment.clone())));
+                } else {
+                    // Other clicks select the row and open its context menu.
+                    extra = Some(cresp);
+                }
+                it.comment.clone()
+            }
+            _ => String::new(),
+        };
+        let align = if *key == "fps" { Align2::RIGHT_CENTER } else { Align2::LEFT_CENTER };
+        let tx = if *key == "fps" { cx + w - 6.0 } else { *cx };
+        cp.text(pos2(tx, r.center().y), align, txt, Tokens::ui(11.5), t.text_dim);
+    }
+    extra
+}
+
+/// Shift+wheel / trackpad over the list scrolls the optional columns sideways.
+fn hscroll_wheel(app: &mut EffectcraftApp, ui: &egui::Ui, list: Rect, overflow: f32) {
+    if overflow > 0.0 && ui.rect_contains_pointer(list) {
+        let (dx, dy, shift) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y, i.modifiers.shift));
+        let d = if dx.abs() > 0.0 {
+            dx
+        } else if shift {
+            dy
+        } else {
+            0.0
+        };
+        app.ui.project_hscroll = (app.ui.project_hscroll - d).clamp(0.0, overflow);
+    }
+}
+
+/// Enter renames the selected item (Project panel focused, not typing).
+fn enter_renames(app: &mut EffectcraftApp, ctx: &egui::Context) {
+    if app.ui.focused == crate::dock::PanelKind::Project
+        && app.dialog.is_none()
+        && keys_reach_rows(app, ctx)
+        && ctx.data(|d| d.get_temp::(edit_id())).is_none()
+        && ctx.input(|i| i.key_pressed(egui::Key::Enter))
+    {
+        begin_rename(app, ctx);
+    }
+}
+
+/// The warning marker at the end of the name cell for footage that can't be decoded.
+fn problem_marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &RowGeom, name_w: f32, id: ItemId, why: &str) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, .. } = *g;
+    let m = Rect::from_center_size(pos2(r.min.x + name_w - 8.0, r.center().y), vec2(13.0, 12.0));
+    lp.add(egui::Shape::convex_polygon(vec![m.center_top(), m.right_bottom(), m.left_bottom()], t.warning, Stroke::NONE));
+    lp.text(m.center() + vec2(0.0, 1.5), Align2::CENTER_CENTER, "!", Tokens::semibold(9.0), Color32::BLACK);
+    ui.interact(m.expand(2.0).intersect(list), egui::Id::unique(("pproblem", id.0)), Sense::hover()).on_hover_text(why);
+    app.auto.add(&format!("project.item.{}.problem", id.0), m, why);
+}
+
+/// Selection box over the rows it touches (#203); Shift or Cmd/Ctrl adds to the selection.
+fn marquee_select(app: &mut EffectcraftApp, ctx: &egui::Context, lp: &egui::Painter, list: Rect, empty: &egui::Response, rows: &[(ItemId, usize)]) {
+    let t = app.tokens;
+    let adding = ctx.input(|i| i.modifiers.shift || i.modifiers.command);
     if empty.clicked() && !adding {
         app.session.state.project_selection.clear();
     }
@@ -1142,6 +994,333 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// Where a row is drawn: its clipped painter, the list, the row rectangle and the indented x.
+#[derive(Clone, Copy)]
+struct RowGeom<'a> {
+    lp: &'a egui::Painter,
+    list: Rect,
+    r: Rect,
+    x0: f32,
+}
+
+/// Vertical scrolling: only the rows in view are drawn (and registered), so a project with
+/// thousands of items costs what one screenful does. Returns the scroll offset and its maximum.
+fn update_scroll(app: &mut EffectcraftApp, ui: &egui::Ui, ctx: &egui::Context, list: Rect, rows: &[(ItemId, usize)], moved: Option) -> (f32, f32) {
+    let content_h = rows.len() as f32 * ROW_H;
+    let max_scroll = (content_h - list.height() + ROW_H).max(0.0);
+    if ui.rect_contains_pointer(list) {
+        let (dy, shift) = ui.input(|i| (i.smooth_scroll_delta.y, i.modifiers.shift));
+        if !shift && dy != 0.0 {
+            app.ui.project_scroll -= dy;
+        }
+    }
+    // A selection made elsewhere (a command, an agent, the viewer) scrolls into view.
+    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::>(sel_key)) != Some(sel_now) {
+        ctx.data_mut(|d| d.insert_temp(sel_key, sel_now));
+        if let Some(row) = sel_now.and_then(|s| rows.iter().position(|(id, _)| id.0 == s)) {
+            let top = row as f32 * ROW_H;
+            if top < app.ui.project_scroll {
+                app.ui.project_scroll = top;
+            } else if top + ROW_H > app.ui.project_scroll + list.height() {
+                app.ui.project_scroll = top + ROW_H - list.height();
+            }
+        }
+    }
+    // The arrow keys' item scrolls into view (Shift+Up extends upward, past the selection's end).
+    if let Some(row) = moved.and_then(|m| rows.iter().position(|(id, _)| *id == m)) {
+        let top = row as f32 * ROW_H;
+        // Not `clamp`: a panel shorter than a row would make its bounds cross. The row's top wins.
+        app.ui.project_scroll = app.ui.project_scroll.max(top + ROW_H - list.height()).min(top);
+    }
+    app.ui.project_scroll = app.ui.project_scroll.clamp(0.0, max_scroll);
+    (app.ui.project_scroll, max_scroll)
+}
+
+/// The folder's open / close twirl (Alt / Cmd-click twirls every folder inside it too).
+fn folder_twirl(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &RowGeom, it: &effectcraft_engine::project::Item) {
+    let t = app.tokens;
+    let RowGeom { r, x0, .. } = *g;
+    let open = app.ui.project_open_folders.contains(&it.id.0);
+    let tw = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(12.0, 12.0));
+    let tresp = widgets::twirl(ui, tw, open, egui::Id::unique(("pf", it.id.0)), &t);
+    tresp.widget_info(|| {
+        let name = crate::i18n::tr_args(if open { "Close folder {}" } else { "Open folder {}" }, &[&it.name]);
+        egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+    });
+    if tresp.clicked() {
+        // Alt/Cmd-click twirls every folder inside it too.
+        let mut ids = vec![it.id];
+        if ui.input(|i| i.modifiers.alt || i.modifiers.command) {
+            let mut at = 0;
+            while let Some(f) = ids.get(at).copied() {
+                ids.extend(app.session.project.children(Some(f)).into_iter().filter(|c| c.is_folder()).map(|c| c.id));
+                at += 1;
+            }
+        }
+        for f in ids {
+            if open {
+                app.ui.project_open_folders.remove(&f.0);
+            } else {
+                app.ui.project_open_folders.insert(f.0);
+            }
+        }
+    }
+    app.auto.add(&format!("project.item.{}.twirl", it.id.0), tw, &it.name);
+}
+
+/// Proxy indicator: filled = the proxy is used, hollow = set but off. Click to switch.
+fn proxy_indicator(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, x0 } = *g;
+    if let Some(px) = it.proxy.as_ref() {
+        let pr = Rect::from_center_size(pos2(x0 + 4.0, r.center().y), vec2(9.0, 9.0));
+        if px.enabled {
+            lp.rect_filled(pr, 1.0, t.text);
+        } else {
+            lp.rect_stroke(pr, 1.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside);
+        }
+        let presp = ui.interact(pr.expand(2.0).intersect(list), egui::Id::unique(("pproxy", it.id.0)), Sense::click()).on_hover_text(format!(
+            "Proxy: {} ({})",
+            px.footage.path,
+            if px.enabled { "in use" } else { "off" }
+        ));
+        app.auto.add(&format!("project.item.{}.proxy", it.id.0), pr, if px.enabled { "Proxy in use" } else { "Proxy off" });
+        presp.widget_info(|| {
+            let name = crate::i18n::tr_args(if px.enabled { "Proxy of {}: in use" } else { "Proxy of {}: off" }, &[&it.name]);
+            egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+        });
+        if presp.clicked() {
+            actions.push(("file.useProxy".into(), json!({"item": it.id.0})));
+        }
+    }
+}
+
+/// The label swatch: click for the label colour menu.
+fn label_swatch(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &RowGeom,
+    it: &effectcraft_engine::project::Item,
+    label_x: f32,
+    selected: bool,
+    actions: &mut Vec<(String, serde_json::Value)>,
+) {
+    let t = app.tokens;
+    let RowGeom { lp, list, r, .. } = *g;
+    let sw = Rect::from_center_size(pos2(label_x, r.center().y), vec2(10.0, 10.0));
+    lp.rect_filled(sw, 2.0, t.label(it.label));
+    let sresp = ui.interact(sw.expand(3.0).intersect(list), egui::Id::unique(("plabel", it.id.0)), Sense::click());
+    app.auto.add(&format!("project.item.{}.label", it.id.0), sw, it.label.name());
+    sresp.widget_info(|| {
+        let name = crate::i18n::tr_args("Label of {}: {}", &[&it.name, &app.session.prefs.label_name(it.label)]);
+        egui::WidgetInfo::labeled(egui::Role::Button, true, name)
+    });
+    let pop = egui::Id::unique(("plabel-pop", it.id.0));
+    if sresp.clicked() || sresp.secondary_clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    if let Some(l) = widgets::label_popup(ui, pop, sw.left_bottom(), it.label, |l| app.session.prefs.label_name(l), &t) {
+        actions.push(("project.setLabel".into(), json!({"items": targets(app, it.id, selected), "label": label_index(l)})));
+    }
+}
+
+/// The Project panel: the selected item's thumbnail and details (with the comps that use it), the
+/// search field, sortable column headers, and the item rows (folders, comps, footage and solids)
+/// with selection, Shift/Cmd ranges, keyboard navigation, inline rename, drag-and-drop move,
+/// marquee selection and context menus. Only the rows in view are drawn. File commands are queued
+/// and run through [`crate::menus::invoke`] at the end, with failures shown in `app.ui.status`.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let p = ui.painter().clone();
+    let ctx = ui.ctx().clone();
+    // Preview header.
+    let head = Rect::from_min_size(rect.min, vec2(rect.width(), 74.0));
+    details_header(app, ui, &p, &ctx, head);
+    // 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, 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.
+    let hdr = Rect::from_min_size(pos2(rect.min.x, sr.max.y + 6.0), vec2(rect.width(), 20.0));
+    p.line_segment([hdr.left_bottom(), hdr.right_bottom()], Stroke::new(1.0, t.separator));
+    let cl = layout_columns(app, rect);
+    let (name_w, label_x, opt_x0, overflow, hscroll) = (cl.name_w, cl.label_x, cl.opt_x0, cl.overflow, cl.hscroll);
+    draw_header(app, ui, &p, hdr, &cl);
+    // Rows.
+    let footer_h = 28.0;
+    let list = Rect::from_min_max(pos2(rect.min.x, hdr.max.y), pos2(rect.max.x, rect.max.y - footer_h));
+    let lp = p.with_clip_rect(list);
+    // The list's empty area (under the rows and the scroll bar, which take their own clicks):
+    // a click deselects, a drag draws a selection box, a double-click imports, as in After
+    // Effects (File ▸ Import ▸ File...).
+    // Not a Tab stop: the keyboard reaches the rows, and nothing else here takes a key.
+    let empty = ui.interact(list, egui::Id::unique("proj-empty"), Sense::CLICK | Sense::DRAG);
+    app.auto.add("project.empty", list, "Double-click to import files; drag to select items; right-click for project actions");
+    hscroll_wheel(app, ui, list, overflow);
+    let moved = keyboard_nav(app, &ctx);
+    let rows = visible_rows(app);
+    enter_renames(app, &ctx);
+    let editing: Option = ctx.data(|d| d.get_temp(edit_id()));
+    let dragging = egui::DragAndDrop::payload::(&ctx).and_then(|p| match *p {
+        DragPayload::Item(i) => Some(i),
+        _ => None,
+    });
+    let hover_y = ctx.pointer_hover_pos().filter(|p| list.contains(*p)).map(|p| p.y);
+    let mut drop_row: Option = None;
+    let mut actions: Vec<(String, serde_json::Value)> = vec![];
+    let (scroll, max_scroll) = update_scroll(app, ui, &ctx, list, &rows, moved);
+    let first = ((scroll / ROW_H).floor() as usize).min(rows.len());
+    let mut y = list.min.y - (scroll - first as f32 * ROW_H);
+    let project = app.session.project.clone();
+    for (i, (id, depth)) in rows.iter().enumerate().skip(first) {
+        let Some(it) = project.item(*id) else { continue };
+        let r = Rect::from_min_size(pos2(list.min.x, y), vec2(list.width(), ROW_H));
+        y += ROW_H;
+        if r.min.y > list.max.y {
+            break;
+        }
+        if hover_y.is_some_and(|hy| hy >= r.min.y && hy < r.max.y) {
+            drop_row = Some(*id);
+        }
+        let selected = app.session.state.project_selection.contains(id);
+        lp.rect_filled(
+            r,
+            0.0,
+            // Flat rows like After Effects' Project panel (no stripes).
+            if selected { t.row_selected } else { t.row },
+        );
+        // The row's click / drag area goes first, so the twirl, proxy, label and comment widgets
+        // on top of it take their own clicks.
+        let mut resp = ui.interact(r.intersect(list), row_id(*id), Sense::click_and_drag());
+        app.auto.add(&format!("project.item.{}", id.0), r, &it.name);
+        // For screen readers: a tree item named by the item and its type, with its selection,
+        // depth and (a folder) open state. One Tab stop per row; the panel's keys then apply.
+        let open = it.is_folder().then(|| app.ui.project_open_folders.contains(&id.0) || !app.ui.project_search.is_empty());
+        resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::TreeItem, true, format!("{}, {}", it.name, crate::i18n::tr(it.type_name()))));
+        ctx.accesskit_node_builder(resp.id, |n| {
+            n.set_selected(selected);
+            n.set_level(depth.saturating_add(1));
+            if let Some(open) = open {
+                n.set_expanded(open);
+            }
+        });
+        if resp.has_focus() {
+            ctx.data_mut(|d| d.insert_temp(focused_row_id(), *id));
+        }
+        if resp.gained_focus() {
+            app.ui.focused = crate::dock::PanelKind::Project;
+        }
+        let _ = i;
+        let x0 = r.min.x + 8.0 + 14.0 * *depth as f32;
+        let g = RowGeom { lp: &lp, list, r, x0 };
+        if it.is_folder() {
+            folder_twirl(app, ui, &g, it);
+        }
+        icons::paint(&lp, Rect::from_center_size(pos2(x0 + 18.0, r.center().y), vec2(14.0, 14.0)), item_icon(it), t.text_dim);
+        proxy_indicator(app, ui, &g, it, &mut actions);
+        // Footage that can't be decoded (no decoder for its codec / profile): a warning marker
+        // at the end of the name cell, the reason on hover.
+        let problem = match &it.kind {
+            ItemKind::Footage(f) => app.session.footage.problem(f),
+            _ => None,
+        };
+        let name_clip =
+            Rect::from_min_max(pos2(x0 + 30.0, r.min.y), pos2(r.min.x + name_w + 4.0 - if problem.is_some() { 16.0 } else { 0.0 }, r.max.y)).intersect(list);
+        if let Some(why) = &problem {
+            problem_marker(app, ui, &g, name_w, it.id, why);
+        }
+        let name_rect = Rect::from_min_max(pos2(x0 + 28.0, r.min.y + 2.0), pos2(r.min.x + name_w + 2.0, r.max.y - 2.0));
+        if !cell_edit(app, ui, &mut actions, &editing, id.0, "name", name_rect) {
+            // The selected item's name sits in a light cell with dark text.
+            if selected {
+                let g = lp.layout_no_wrap(it.name.clone(), Tokens::ui(12.0), Color32::BLACK);
+                let cell = Rect::from_min_max(pos2(x0 + 27.0, r.min.y + 1.0), pos2((x0 + 33.0 + g.size().x).min(name_clip.max.x), r.max.y - 1.0));
+                lp.with_clip_rect(name_clip.expand2(vec2(3.0, 0.0))).rect_filled(cell, 0.0, Color32::from_rgb(0xa6, 0xa6, 0xa6));
+            }
+            // Long names end in "…" before the label column instead of being cut mid-glyph.
+            widgets::text_fit(
+                &lp.with_clip_rect(name_clip),
+                pos2(x0 + 30.0, r.center().y),
+                Align2::LEFT_CENTER,
+                &it.name,
+                Tokens::ui(12.0),
+                name_clip.max.x - (x0 + 30.0) - 2.0,
+                if selected { Color32::from_gray(0x16) } else { t.text },
+            );
+        }
+        label_swatch(app, ui, &g, it, label_x, selected, &mut actions);
+        if let Some(extra) = optional_cells(app, ui, &g, it, &cl, &editing, &mut actions) {
+            resp = resp.union(extra);
+        }
+        if dragging.is_some_and(|d| d != id.0) && drop_row == Some(*id) && it.is_folder() {
+            lp.rect_stroke(r.shrink(1.0), 2.0, Stroke::new(1.5, t.accent), egui::StrokeKind::Inside);
+        }
+        // The row with keyboard focus (Tab, or the arrow keys after it) shows a focus ring.
+        if ctx.memory(|m| m.has_focus(row_id(*id))) {
+            lp.rect_stroke(r.shrink(0.5), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
+        }
+        // Part of the row: not a second Tab stop.
+        let nresp = ui.interact(name_clip, egui::Id::unique(("pname", id.0)), Sense::CLICK | Sense::DRAG);
+        app.auto.add(&format!("project.item.{}.name", id.0), name_clip, &it.name);
+        let resp = resp.union(nresp);
+        if resp.clicked() {
+            select_click(app, ui, &ctx, *id, &rows);
+        }
+        // Double-click opens the item, as in After Effects (a comp gets its own Timeline tab and
+        // the viewer); Enter or the context menu renames.
+        if resp.double_clicked() {
+            open_item(app, it, *id, &mut actions);
+        }
+        if resp.drag_started() {
+            egui::DragAndDrop::set_payload(&ctx, DragPayload::Item(id.0));
+        }
+        // Right-clicking an item selects it (After Effects): the menu's File commands act on the
+        // selection.
+        if resp.secondary_clicked() && !selected {
+            app.session.state.project_selection = vec![*id];
+        }
+        crate::widgets::context_menu(&resp, |ui| item_menu(app, ui, &ctx, it, *id, selected, &mut actions));
+    }
+    // The scroll bars, over the rows (which would take their presses otherwise).
+    if overflow > 0.0 {
+        let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
+        app.ui.project_hscroll = widgets::scroll_bar(ui, &mut app.auto, "project.hscroll", track, hscroll, overflow, &t);
+    }
+    if max_scroll > 0.0 {
+        let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
+        app.ui.project_scroll = widgets::scroll_bar(ui, &mut app.auto, "project.vscroll", track, scroll, max_scroll, &t);
+    }
+    drop_targets(app, &ctx, &lp, list, DropState { dragging, row: drop_row, hovering: hover_y.is_some() }, &mut actions);
+    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
+    footer(app, ui, &p, foot, &mut actions);
+    if empty.double_clicked() {
+        actions.push(("file.import".into(), json!({})));
+    }
+    crate::widgets::context_menu(&empty, |ui| {
+        for (key, label, command) in
+            [("newComp", "New Composition…", "comp.new"), ("newFolder", "New Folder", "project.newFolder"), ("import", "Import File…", "file.import")]
+        {
+            let response = ui.button(label);
+            app.auto.add(&format!("project.context.{key}"), response.rect, label);
+            if response.clicked() {
+                actions.push((command.into(), json!({})));
+                ui.close();
+            }
+        }
+    });
+    marquee_select(app, &ctx, &lp, list, &empty, &rows);
     for (id, params) in actions {
         if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
             app.ui.status = e;
diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs
index c26bb5030..c35108937 100644
--- a/crates/ui-egui/src/panels/properties.rs
+++ b/crates/ui-egui/src/panels/properties.rs
@@ -91,7 +91,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
 
     // ---------------------------------------------------------------- Layer Transform
     if let Some(tr) = layer.transform().cloned() {
-        section_header(app, ui, &p, x0, w, y, "Layer Transform", "properties.transform");
+        section_header(app, &p, x0, w, y, "Layer Transform", "properties.transform");
         let rr = Rect::from_min_size(pos2(x0 + w - 40.0, y), vec2(40.0, ROW_H));
         let resp = ui.interact(rr, egui::Id::unique("props-reset"), Sense::click());
         p.text(rr.right_center(), Align2::RIGHT_CENTER, crate::i18n::tr("Reset"), Tokens::ui(12.0), if resp.hovered() { t.accent_hover } else { t.accent });
@@ -124,7 +124,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         divider(&p, x0, w, y - 4.0, &t);
         y = paragraph_section(app, ui, &p, &layer, &doc, x0, w, y + 4.0, &mut actions);
         divider(&p, x0, w, y - 4.0, &t);
-        section_header(app, ui, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
+        section_header(app, &p, x0, w, y + 4.0, "Text Animation", "properties.textAnimation");
         y += ROW_H + 10.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;
@@ -180,11 +180,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 }
 
-fn section_header(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
+fn section_header(app: &mut EffectcraftApp, p: &egui::Painter, x0: f32, w: f32, y: f32, title: &str, auto: &str) {
     let t = app.tokens;
     p.text(pos2(x0, y + ROW_H / 2.0), Align2::LEFT_CENTER, title, Tokens::semibold(12.0), t.text);
     let r = Rect::from_min_size(pos2(x0, y), vec2(w, ROW_H));
-    let _ = ui;
     app.auto.add(auto, r, title);
 }
 
@@ -286,7 +285,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
         }
         Value::Scalar(v) => {
             let range = if pr.name == "Opacity" { (0.0, 100.0) } else { (-1e9, 1e9) };
-            let (vr, nv, _) = widgets::hot_number_at(ui, pos2(vx, cy - 9.0), egui::Id::unique(("props-v", uid)), *v, 0.5, range, decimals(*v), suffix, &t);
+            let (vr, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(vx, cy - 9.0),
+                egui::Id::unique(("props-v", uid)),
+                *v,
+                &widgets::HotSpec { speed: 0.5, range, decimals: decimals(*v), suffix },
+                &t,
+            );
             app.auto.add(&format!("{base}.value"), vr, &pr.name);
             if let Some(nv) = nv {
                 set(actions, json!(nv));
@@ -311,8 +317,14 @@ fn transform_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter,
                 }
             }
             for d in 0..n.min(c.len()) {
-                let (vr, nv, _) =
-                    widgets::hot_number_at(ui, pos2(x, cy - 9.0), egui::Id::unique(("props-v", uid, d)), c[d], 1.0, (-1e9, 1e9), decimals(c[d]), suffix, &t);
+                let (vr, nv, _) = widgets::hot_number_at(
+                    ui,
+                    pos2(x, cy - 9.0),
+                    egui::Id::unique(("props-v", uid, d)),
+                    c[d],
+                    &widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: decimals(c[d]), suffix },
+                    &t,
+                );
                 app.auto.add(&format!("{base}.value.{d}"), vr, &pr.name);
                 if let Some(nv) = nv {
                     let mut nc = c.clone();
@@ -358,7 +370,7 @@ fn text_section(
         v["layer"] = json!(lid);
         actions.push(("layer.setText".into(), v));
     };
-    section_header(app, ui, p, x0, w, y, "Text", "properties.text");
+    section_header(app, p, x0, w, y, "Text", "properties.text");
     y += ROW_H + 4.0;
     // Font family and style.
     let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
@@ -383,7 +395,14 @@ fn text_section(
     let col2 = x0 + w / 2.0;
     let field = |app: &mut EffectcraftApp, ui: &mut egui::Ui, x: f32, y: f32, glyph: &str, key: &str, v: f64, range: (f64, f64), suffix: &str| {
         p.text(pos2(x, y + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(x + 26.0, y), egui::Id::unique(("props-text", key)), v, 0.5, range, decimals(v), suffix, &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x + 26.0, y),
+            egui::Id::unique(("props-text", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range, decimals: decimals(v), suffix },
+            &t,
+        );
         app.auto.add(&format!("properties.text.{key}"), r, key);
         nv
     };
@@ -462,7 +481,7 @@ fn paragraph_section(
     actions: &mut Actions,
 ) -> f32 {
     let t = app.tokens;
-    section_header(app, ui, p, x0, w, y, "Paragraph", "properties.paragraph");
+    section_header(app, p, x0, w, y, "Paragraph", "properties.paragraph");
     y += ROW_H + 4.0;
     for (i, (j, key)) in ALIGNS.into_iter().enumerate() {
         let r = Rect::from_min_size(pos2(x0 + i as f32 * 26.0, y), vec2(22.0, 22.0));
diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs
index c002558ab..5e39a30c2 100644
--- a/crates/ui-egui/src/panels/render_queue.rs
+++ b/crates/ui-egui/src/panels/render_queue.rs
@@ -415,20 +415,21 @@ fn pick_output(app: &EffectcraftApp, it: &RenderQueueItem) -> Option {
+    queue: &'a [RenderQueueItem],
+    progress: Option<&'a effectcraft_engine::render_queue::JobState>,
+    rendering: bool,
+    available: &'a [OutputFormat],
+}
+
+/// Comps dragged from the Project over the panel: an outline while hovering, queued on release.
+fn queue_drop(app: &EffectcraftApp, ui: &egui::Ui, rect: Rect, p: &egui::Painter, rendering: bool, actions: &mut Vec) {
     let t = app.tokens;
-    let p = ui.painter().with_clip_rect(rect);
-    let queue: Vec = app.session.project.render_queue.clone();
-    let progress = app.session.render_progress();
-    let rendering = app.session.is_rendering();
-    let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
-    let mut actions: Vec<(&'static str, Value)> = Vec::new();
-    app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
     if !rendering
         && let Some(crate::panels::DragPayload::Item(id)) = egui::DragAndDrop::payload::(ui.ctx()).as_deref()
         && app.session.project.comp(effectcraft_engine::project::ItemId(*id)).is_some()
@@ -448,14 +449,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             p.rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
         }
     }
-    let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
-    let state_id = egui::Id::unique("rq-ui");
-    let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
+}
 
-    // ---------------------------------------------------------- Current Render strip
-    let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
-    p.rect_filled(top, 0.0, t.tl_ruler_bg);
-    let x0 = rect.min.x + 12.0;
+/// The Current Render strip inside `top`: title, elapsed / remaining time, Render, Stop, Notify
+/// and the progress bar.
+fn current_render_strip(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, top: Rect, rq: &RqView, actions: &mut Vec) {
+    let t = app.tokens;
+    let RqView { queue, progress, rendering, available } = *rq;
+    let x0 = top.min.x + 12.0;
     p.text(pos2(x0, top.min.y + 16.0), Align2::LEFT_CENTER, tr("Current Render"), Tokens::semibold(12.5), t.text);
     let cur_item = progress.as_ref().and_then(|p| p.current).and_then(|id| queue.iter().find(|i| i.id == id));
     let cur_name = cur_item.and_then(|i| app.session.project.item(i.comp)).map(|i| i.name.clone());
@@ -515,6 +516,194 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     };
     p.text(pos2(bar.min.x + 8.0, bar.center().y), Align2::LEFT_CENTER, bar_text, Tokens::ui(11.5), t.text);
     app.auto.add("renderQueue.progress", bar, &format!("{:.0}%", frac * 100.0));
+}
+
+/// The footer: the message line and the totals.
+fn footer(p: &egui::Painter, rect: Rect, rq: &RqView, t: &Tokens) {
+    let RqView { queue, progress, rendering, .. } = *rq;
+    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
+    p.rect_filled(foot, 0.0, t.tl_ruler_bg);
+    p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
+    let msg = match &progress {
+        Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
+        _ => {
+            let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
+            format!("Message: {done} of {} done", queue.len())
+        }
+    };
+    p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
+    let started = queue.iter().filter(|i| i.started.is_some()).count();
+    let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
+    p.text(
+        pos2(foot.max.x - 12.0, foot.center().y),
+        Align2::RIGHT_CENTER,
+        format!("Renders Started: {started}     Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
+        Tokens::ui(11.0),
+        t.text_dim,
+    );
+}
+
+/// An item row's context menu: Duplicate, Remove and Move Up / Down (row `k` of `len`).
+fn row_menu(ui: &mut egui::Ui, actions: &mut Vec, item_id: u64, k: usize, len: usize) {
+    if ui.button(tr("Duplicate")).clicked() {
+        actions.push(("renderQueue.duplicate", json!({"item": item_id})));
+        ui.close();
+    }
+    if ui.button(tr("Remove")).clicked() {
+        actions.push(("renderQueue.remove", json!({"item": item_id})));
+        ui.close();
+    }
+    if k > 0 && ui.button(tr("Move Up")).clicked() {
+        actions.push(("renderQueue.move", json!({"item": item_id, "to": k})));
+        ui.close();
+    }
+    if k + 1 < len && ui.button(tr("Move Down")).clicked() {
+        actions.push(("renderQueue.move", json!({"item": item_id, "to": k + 2})));
+        ui.close();
+    }
+}
+
+/// Log: Errors Only / Plus Settings / Plus Per Frame Info.
+fn log_dropdown(app: &mut EffectcraftApp, ui: &mut egui::Ui, row: &ItemRow, it: &RenderQueueItem, actions: &mut Vec) {
+    let ItemRow { lp, r, n, rendering, .. } = *row;
+    let t = app.tokens;
+    let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
+    let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
+    lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
+    let lid = egui::Id::unique(("rq-log", it.id));
+    if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
+        widgets::open_popup(ui, lid);
+    }
+    let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
+    if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
+        actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
+    }
+    app.auto.add(&aid("log"), ld, it.log.label());
+}
+
+/// Where an item row's expanded line is drawn.
+#[derive(Clone, Copy)]
+struct ItemRow<'a> {
+    lp: &'a egui::Painter,
+    list: Rect,
+    r: Rect,
+    /// The item's 1-based position in the queue.
+    n: usize,
+    rendering: bool,
+}
+
+/// The expanded "Output To" line: the template menu, and the file name (click to choose it or
+/// edit the template in place).
+fn output_to(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    row: &ItemRow,
+    it: &RenderQueueItem,
+    dd_right: f32,
+    editing: &mut Option<(u64, String)>,
+    actions: &mut Vec,
+) {
+    let ItemRow { lp, list, r, n, rendering } = *row;
+    let t = app.tokens;
+    let aid = |s: &str| format!("renderQueue.item.{n}.{s}");
+    let ox = dd_right + 14.0;
+    lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
+    let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
+    let oid = egui::Id::unique(("rq-out", it.id));
+    if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
+        widgets::open_popup(ui, oid);
+    }
+    app.auto.add(&aid("outputTo"), odd, "Output To");
+    let omenu = output_to_menu(it);
+    let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect();
+    if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
+        if omenu[i].1.get("choose").is_some() {
+            match pick_output(app, it) {
+                Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
+                Some(None) => {}
+                None => *editing = Some((it.id, it.output.output.clone())),
+            }
+        } else {
+            let mut params = omenu[i].1.clone();
+            params["item"] = json!(it.id);
+            actions.push(("renderQueue.setOutput", params));
+        }
+    }
+    let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
+    if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
+        let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
+        let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
+        if resp.lost_focus() {
+            if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
+                actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
+            }
+            *editing = None;
+        } else {
+            widgets::keep_focus(ui.ctx(), &resp, &buf);
+            *editing = Some((it.id, buf));
+        }
+    } else {
+        let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
+        let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
+        let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
+        lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
+        // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
+        // host without one, edits the name template in place.
+        let dialog = app.hooks.pick_save_file.is_some();
+        if resp.clicked() && !rendering {
+            let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
+            match picked {
+                Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
+                Some(None) => {}
+                None => *editing = Some((it.id, it.output.output.clone())),
+            }
+        }
+        let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
+        resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
+        app.auto.add(&aid("outputPath"), path_rect, &shown);
+    }
+}
+
+/// Delete removes the selected item when the panel has focus (`allowed`: not editing a path and
+/// not rendering).
+fn delete_selected(app: &EffectcraftApp, ui: &egui::Ui, allowed: bool, selected: &mut Option, actions: &mut Vec) {
+    if app.ui.focused == crate::dock::PanelKind::RenderQueue
+        && allowed
+        && let Some(id) = *selected
+        && app.dialog.is_none()
+        && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
+        && !ui.ctx().egui_wants_keyboard_input()
+    {
+        actions.push(("renderQueue.remove", json!({"item": id})));
+        *selected = None;
+    }
+}
+
+/// The Render Queue panel: the Current Render strip (elapsed and remaining time, Render/Stop and
+/// Notify, plus the progress bar), the column header, the queued items with their output template,
+/// status and timings, and the footer totals. Comps dragged from the Project are queued on
+/// release. The open items and the selected item are kept in egui temp data; output-template forms
+/// open through a small form, and every change runs a `renderQueue.*` command.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let p = ui.painter().with_clip_rect(rect);
+    let queue: Vec = app.session.project.render_queue.clone();
+    let progress = app.session.render_progress();
+    let rendering = app.session.is_rendering();
+    let available = app.session.exporter.as_ref().map(|e| e.formats()).unwrap_or_default();
+    let mut actions: Vec = Vec::new();
+    let rq = RqView { queue: &queue, progress: progress.as_ref(), rendering, available: &available };
+    app.auto.add("renderQueue.drop", rect, "Drop compositions to queue");
+    queue_drop(app, ui, rect, &p, rendering, &mut actions);
+    let mut forms: Vec<(RenderQueueItem, String)> = Vec::new();
+    let state_id = egui::Id::unique("rq-ui");
+    let (mut open, mut selected): (Vec, Option) = ui.data(|d| d.get_temp(state_id).unwrap_or_default());
+
+    // ---------------------------------------------------------- Current Render strip
+    let top = Rect::from_min_size(rect.min, vec2(rect.width(), TOP_H));
+    p.rect_filled(top, 0.0, t.tl_ruler_bg);
+    current_render_strip(app, ui, &p, top, &rq, &mut actions);
 
     // ---------------------------------------------------------- column header
     let head = Rect::from_min_size(pos2(rect.min.x, top.max.y), vec2(rect.width(), HEAD_H));
@@ -575,25 +764,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             selected = Some(it.id);
             app.ui.focused = crate::dock::PanelKind::RenderQueue;
         }
-        let item_id = it.id;
-        crate::widgets::context_menu(&row_resp, |ui| {
-            if ui.button(tr("Duplicate")).clicked() {
-                actions.push(("renderQueue.duplicate", json!({"item": item_id})));
-                ui.close();
-            }
-            if ui.button(tr("Remove")).clicked() {
-                actions.push(("renderQueue.remove", json!({"item": item_id})));
-                ui.close();
-            }
-            if k > 0 && ui.button(tr("Move Up")).clicked() {
-                actions.push(("renderQueue.move", json!({"item": item_id, "to": n - 1})));
-                ui.close();
-            }
-            if n < queue.len() && ui.button(tr("Move Down")).clicked() {
-                actions.push(("renderQueue.move", json!({"item": item_id, "to": n + 1})));
-                ui.close();
-            }
-        });
+        crate::widgets::context_menu(&row_resp, |ui| row_menu(ui, &mut actions, it.id, k, queue.len()));
         app.auto.add(&aid("row"), row, &format!("#{n}"));
         let is_open = open.contains(&it.id);
         let tw = Rect::from_center_size(pos2(cx_twirl + 8.0, row.center().y), vec2(14.0, 14.0));
@@ -686,78 +857,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     );
                     lp.text(pos2(dd.max.x + 14.0, r.center().y), Align2::LEFT_CENTER, info, Tokens::ui(11.0), t.text_faint);
                 }
-                // Log: Errors Only / Plus Settings / Plus Per Frame Info.
-                let ld = Rect::from_min_size(pos2(r.max.x - 190.0, r.min.y + 3.0), vec2(150.0, ROW_H - 6.0));
-                lp.text(pos2(ld.min.x - 6.0, r.center().y), Align2::RIGHT_CENTER, tr("Log:"), Tokens::ui(11.5), t.text_dim);
-                let lid = egui::Id::unique(("rq-log", it.id));
-                if widgets::dropdown(ui, ld, it.log.label(), &t, lid).clicked() && !rendering {
-                    widgets::open_popup(ui, lid);
-                }
-                let llabels: Vec = RenderLog::ALL.iter().map(|l| mark(it.log == *l, l.label())).collect();
-                if let Some(i) = widgets::popup_menu(ui, lid, ld.left_bottom(), &llabels, None) {
-                    actions.push(("renderQueue.setLog", json!({"item": it.id, "log": RenderLog::ALL[i].label()})));
-                }
-                app.auto.add(&aid("log"), ld, it.log.label());
+                log_dropdown(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, &mut actions);
                 continue;
             }
-            // Output To.
-            let ox = dd.max.x + 14.0;
-            lp.text(pos2(ox, r.center().y), Align2::LEFT_CENTER, tr("Output To:"), Tokens::ui(11.5), t.text_dim);
-            let odd = Rect::from_min_size(pos2(ox + 62.0, r.min.y + 3.0), vec2(22.0, ROW_H - 6.0));
-            let oid = egui::Id::unique(("rq-out", it.id));
-            if widgets::dropdown(ui, odd, "", &t, oid).clicked() && !rendering {
-                widgets::open_popup(ui, oid);
-            }
-            app.auto.add(&aid("outputTo"), odd, "Output To");
-            let omenu = output_to_menu(it);
-            let olabels: Vec = omenu.iter().map(|(l, _)| l.clone()).collect();
-            if let Some(i) = widgets::popup_menu(ui, oid, odd.left_bottom(), &olabels, None) {
-                if omenu[i].1.get("choose").is_some() {
-                    match pick_output(app, it) {
-                        Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
-                        Some(None) => {}
-                        None => editing = Some((it.id, it.output.output.clone())),
-                    }
-                } else {
-                    let mut params = omenu[i].1.clone();
-                    params["item"] = json!(it.id);
-                    actions.push(("renderQueue.setOutput", params));
-                }
-            }
-            let path_rect = Rect::from_min_max(pos2(odd.max.x + 6.0, r.min.y + 2.0), pos2(r.max.x - 8.0, r.max.y - 2.0));
-            if editing.as_ref().is_some_and(|(id, _)| *id == it.id) {
-                let mut buf = editing.as_ref().map(|(_, b)| b.clone()).unwrap_or_default();
-                let mut child = ui.new_child(egui::UiBuilder::new().max_rect(path_rect));
-                let resp = child.add(egui::TextEdit::singleline(&mut buf).desired_width(path_rect.width()).font(Tokens::ui(11.5)));
-                if resp.lost_focus() {
-                    if ui.input(|i| i.key_pressed(egui::Key::Enter)) && !buf.trim().is_empty() {
-                        actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": buf.trim()})));
-                    }
-                    editing = None;
-                } else {
-                    widgets::keep_focus(ui.ctx(), &resp, &buf);
-                    editing = Some((it.id, buf));
-                }
-            } else {
-                let shown = app.session.resolve_output(it).unwrap_or_else(|| "Not yet specified".into());
-                let resp = ui.interact(path_rect, egui::Id::unique(("rq-path", it.id)), Sense::click());
-                let col = if resp.hovered() { t.accent_hover } else { t.hot_text };
-                lp.with_clip_rect(path_rect.intersect(list)).text(pos2(path_rect.min.x, r.center().y), Align2::LEFT_CENTER, &shown, Tokens::ui(11.5), col);
-                // Clicking the file name opens the save dialog (Output Movie To); Alt-click, or a
-                // host without one, edits the name template in place.
-                let dialog = app.hooks.pick_save_file.is_some();
-                if resp.clicked() && !rendering {
-                    let picked = if ui.input(|i| i.modifiers.alt) { None } else { pick_output(app, it) };
-                    match picked {
-                        Some(Some(path)) => actions.push(("renderQueue.setOutput", json!({"item": it.id, "path": path}))),
-                        Some(None) => {}
-                        None => editing = Some((it.id, it.output.output.clone())),
-                    }
-                }
-                let how = if dialog { "Click to choose where to save; Alt-click to edit the template" } else { "Click to edit" };
-                resp.on_hover_text(tr_args("Template: {}\n{}", &[&it.output.output, &how]));
-                app.auto.add(&aid("outputPath"), path_rect, &shown);
-            }
+            output_to(app, ui, &ItemRow { lp: &lp, list, r, n, rendering }, it, dd.max.x, &mut editing, &mut actions);
         }
     }
     ui.set_clip_rect(panel_clip);
@@ -770,40 +873,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         None => d.remove::<(u64, String)>(editing_id),
     });
 
-    // Delete removes the selected item when the panel has focus.
-    if app.ui.focused == crate::dock::PanelKind::RenderQueue
-        && editing.is_none()
-        && !rendering
-        && let Some(id) = selected
-        && app.dialog.is_none()
-        && ui.input(|i| i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace))
-        && !ui.ctx().egui_wants_keyboard_input()
-    {
-        actions.push(("renderQueue.remove", json!({"item": id})));
-        selected = None;
-    }
+    delete_selected(app, ui, editing.is_none() && !rendering, &mut selected, &mut actions);
 
     // ---------------------------------------------------------- footer
-    let foot = Rect::from_min_max(pos2(rect.min.x, rect.max.y - FOOT_H), rect.max);
-    p.rect_filled(foot, 0.0, t.tl_ruler_bg);
-    p.line_segment([foot.left_top(), foot.right_top()], Stroke::new(1.0, t.separator));
-    let msg = match &progress {
-        Some(pr) if rendering => format!("Message: Rendering {} of {}", (pr.items_done + 1).min(pr.items_total), pr.items_total),
-        _ => {
-            let done = queue.iter().filter(|i| i.status == RenderStatus::Done).count();
-            format!("Message: {done} of {} done", queue.len())
-        }
-    };
-    p.text(pos2(foot.min.x + 12.0, foot.center().y), Align2::LEFT_CENTER, msg, Tokens::ui(11.0), t.text_dim);
-    let started = queue.iter().filter(|i| i.started.is_some()).count();
-    let total: f64 = queue.iter().filter_map(|i| i.render_time).sum();
-    p.text(
-        pos2(foot.max.x - 12.0, foot.center().y),
-        Align2::RIGHT_CENTER,
-        format!("Renders Started: {started}     Total Time Elapsed: {}", hms(progress.as_ref().filter(|_| rendering).map(|p| p.elapsed).unwrap_or(total))),
-        Tokens::ui(11.0),
-        t.text_dim,
-    );
+    footer(&p, rect, &rq, &t);
 
     open.retain(|id| queue.iter().any(|i| i.id == *id));
     ui.data_mut(|d| d.insert_temp(state_id, (open, selected)));
diff --git a/crates/ui-egui/src/panels/roto_tool.rs b/crates/ui-egui/src/panels/roto_tool.rs
index 52175899c..0d9eefe45 100644
--- a/crates/ui-egui/src/panels/roto_tool.rs
+++ b/crates/ui-egui/src/panels/roto_tool.rs
@@ -135,17 +135,15 @@ pub fn draw_overlay(
     }
 }
 
+/// The layer <-> screen mappings [`interact`] works with.
+pub struct LayerXform<'a> {
+    pub to_screen: &'a dyn Fn([f64; 2]) -> Pos2,
+    pub to_layer: &'a dyn Fn(Pos2) -> [f64; 2],
+}
+
 /// Pointer handling for the Roto Brush / Refine Edge tools. Returns true when it handled input.
-pub fn interact(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    painter: &egui::Painter,
-    area: Rect,
-    layer: &Layer,
-    zoom: f64,
-    to_screen: &dyn Fn([f64; 2]) -> Pos2,
-    to_layer: &dyn Fn(Pos2) -> [f64; 2],
-) -> bool {
+pub fn interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, painter: &egui::Painter, area: Rect, layer: &Layer, zoom: f64, xf: LayerXform) -> bool {
+    let LayerXform { to_screen, to_layer } = xf;
     let ctx = ui.ctx().clone();
     let mods = ui.input(|i| i.modifiers);
     let Some(kind) = app.ui.tool.roto_kind(mods.alt) else { return false };
diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs
index c285011fe..692f48236 100644
--- a/crates/ui-egui/src/panels/settings.rs
+++ b/crates/ui-egui/src/panels/settings.rs
@@ -302,19 +302,20 @@ fn theme_preview(ui: &mut egui::Ui, kind: ThemeKind, width: f32) {
     painter.line_segment([pos2(cti, graph.top()), pos2(cti, graph.bottom())], egui::Stroke::new(1.0, p.cti));
 }
 
+/// What [`theme_card`] shows: the setting `key`, the translated `title`, whether the family is
+/// `active`, the `cur`rent theme and the `options` (value, label) to pick from.
+struct ThemeFamily<'a> {
+    key: &'a str,
+    title: &'a str,
+    active: bool,
+    cur: &'a str,
+    options: &'a [(&'a str, &'a str)],
+}
+
 /// One theme family's card: its (translated) name, Active when the appearance shows it now, a preview of the
 /// chosen theme and a radio button per theme.
-fn theme_card(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    t: &Tokens,
-    key: &str,
-    title: &str,
-    active: bool,
-    cur: &str,
-    options: &[(&str, &str)],
-    acts: &mut Vec,
-) {
+fn theme_card(app: &mut EffectcraftApp, ui: &mut egui::Ui, t: &Tokens, fam: ThemeFamily, acts: &mut Vec) {
+    let ThemeFamily { key, title, active, cur, options } = fam;
     egui::Frame::new()
         .fill(t.field_bg)
         .stroke(egui::Stroke::new(1.0, if active { t.accent } else { t.field_border }))
@@ -396,8 +397,20 @@ fn appearance_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, cur: &Value, t: &T
     let light_active = mode == "light" || (mode == "auto" && system_light == Some(true));
     ui.columns(2, |cols| {
         let [l, d] = cols else { return };
-        theme_card(app, l, t, "appearance.lightTheme", tr("Light Theme"), light_active, &light, LIGHT_THEMES, acts);
-        theme_card(app, d, t, "appearance.darkTheme", tr("Dark Theme"), !light_active, &dark, DARK_THEMES, acts);
+        theme_card(
+            app,
+            l,
+            t,
+            ThemeFamily { key: "appearance.lightTheme", title: tr("Light Theme"), active: light_active, cur: &light, options: LIGHT_THEMES },
+            acts,
+        );
+        theme_card(
+            app,
+            d,
+            t,
+            ThemeFamily { key: "appearance.darkTheme", title: tr("Dark Theme"), active: !light_active, cur: &dark, options: DARK_THEMES },
+            acts,
+        );
     });
     ui.add_space(6.0);
 }
diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs
index 2a3fb5063..8a8e604e2 100644
--- a/crates/ui-egui/src/panels/text_panels.rs
+++ b/crates/ui-egui/src/panels/text_panels.rs
@@ -64,7 +64,8 @@ pub fn text_target(app: &EffectcraftApp) -> Option {
 /// A small toggle button at `r` showing `label` (semibold 13 when `bold`, else 12), filled with
 /// the accent while `on`; returns whether it was clicked. It only reports the click: the caller
 /// decides the new state.
-fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, label: &str, on: bool, bold: bool) -> bool {
+fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rect, id: &str, look: (&str, bool, bool)) -> bool {
+    let (label, on, bold) = look;
     let t = app.tokens;
     let resp = ui.interact(r, egui::Id::unique(("text-toggle", id)), Sense::click());
     p.rect_filled(
@@ -84,6 +85,103 @@ fn toggle(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, r: Rec
     resp.clicked()
 }
 
+/// Position of the numeric grid (`x0`, top `y`, width `w`) and whether edits are collected.
+#[derive(Clone, Copy)]
+struct Grid {
+    x0: f32,
+    y: f32,
+    w: f32,
+    enabled: bool,
+}
+
+/// Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 left).
+fn numeric_fields(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
+        ("size", "T", doc.size, (1.0, 2000.0), " px"),
+        ("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
+        ("", "", 0.0, (0.0, 0.0), ""),
+        ("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
+        ("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
+        ("", "", 0.0, (0.0, 0.0), ""),
+        ("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
+        ("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
+        ("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
+        ("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
+    ];
+    for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
+        if key.is_empty() {
+            continue;
+        }
+        let col = i % 2;
+        let row = i / 2;
+        let fx = x0 + col as f32 * (w / 2.0);
+        let fy = y + row as f32 * 28.0;
+        p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(fx + 26.0, fy),
+            egui::Id::unique(("char", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range, decimals: 0, suffix },
+            &t,
+        );
+        app.auto.add(&format!("character.{key}"), r, key);
+        if let Some(nv) = nv
+            && enabled
+        {
+            actions.push(json!({key: nv, "merge": format!("char-{key}")}));
+        }
+    }
+}
+
+/// Kerning: Metrics / Optical / a manual value (grid row 1, left).
+fn kerning_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: &egui::Painter, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fy = y + 28.0;
+    p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
+    let label = match doc.kerning {
+        Kerning::Metrics => "Metrics".to_string(),
+        Kerning::Optical => "Optical".to_string(),
+        Kerning::Manual(v) => format!("{v:.0}"),
+    };
+    let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
+    let kpop = egui::Id::unique("char-kerning-pop");
+    if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
+        widgets::open_popup(ui, kpop);
+    }
+    app.auto.add("character.kerning", kr, "Kerning");
+    let opts: Vec = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
+    if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
+        let v = match i {
+            0 => json!("metrics"),
+            1 => json!("optical"),
+            _ => json!(opts[i].parse::().unwrap_or(0.0)),
+        };
+        actions.push(json!({"kerning": v}));
+    }
+}
+
+/// Stroke Over Fill / Fill Over Stroke (grid row 2, right).
+fn stroke_order_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: Grid, doc: &TextDoc, actions: &mut Vec) {
+    let t = app.tokens;
+    let Grid { x0, y, w, enabled } = g;
+    let fx = x0 + w / 2.0;
+    let fy = y + 2.0 * 28.0;
+    let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
+    let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
+    let pop = egui::Id::unique("char-stroke-order-pop");
+    if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
+        widgets::open_popup(ui, pop);
+    }
+    app.auto.add("character.strokeOverFill", dr, "Stroke order");
+    if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
+        actions.push(json!({"strokeOverFill": i == 1}));
+    }
+}
+
 /// The Character panel for the text being edited (or the selected text layer): font family and
 /// style, size, leading, tracking, stroke, scale, baseline shift and other numeric fields, kerning,
 /// fill/stroke colours with eyedroppers, style toggles, OpenType features and variable-font axes.
@@ -122,76 +220,10 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(json!({"style": st}));
     }
     y += 34.0;
-    // Numeric fields in AE's two-column grid (Kerning is a popup, at row 1 right).
-    let fields: [(&str, &str, f64, (f64, f64), &str); 10] = [
-        ("size", "T", doc.size, (1.0, 2000.0), " px"),
-        ("leading", "A", doc.leading.unwrap_or(doc.size * 1.2), (0.0, 5000.0), " px"),
-        ("", "", 0.0, (0.0, 0.0), ""),
-        ("tracking", "VA", doc.tracking, (-1000.0, 10000.0), ""),
-        ("strokeWidth", "W", doc.stroke_width, (0.0, 500.0), " px"),
-        ("", "", 0.0, (0.0, 0.0), ""),
-        ("vScale", "↕T", doc.v_scale, (1.0, 1000.0), " %"),
-        ("hScale", "↔T", doc.h_scale, (1.0, 1000.0), " %"),
-        ("baselineShift", "A↑", doc.baseline_shift, (-1000.0, 1000.0), " px"),
-        ("tsume", "Ts", doc.tsume, (0.0, 100.0), " %"),
-    ];
-    for (i, (key, glyph, v, range, suffix)) in fields.into_iter().enumerate() {
-        if key.is_empty() {
-            continue;
-        }
-        let col = i % 2;
-        let row = i / 2;
-        let fx = x0 + col as f32 * (w / 2.0);
-        let fy = y + row as f32 * 28.0;
-        p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("char", key)), v, 0.5, range, 0, suffix, &t);
-        app.auto.add(&format!("character.{key}"), r, key);
-        if let Some(nv) = nv
-            && enabled
-        {
-            actions.push(json!({key: nv, "merge": format!("char-{key}")}));
-        }
-    }
-    // Kerning: Metrics / Optical / a manual value (row 1, left).
-    {
-        let fy = y + 28.0;
-        p.text(pos2(x0, fy + 9.0), Align2::LEFT_CENTER, "V/A", Tokens::semibold(11.0), t.text_dim);
-        let label = match doc.kerning {
-            Kerning::Metrics => "Metrics".to_string(),
-            Kerning::Optical => "Optical".to_string(),
-            Kerning::Manual(v) => format!("{v:.0}"),
-        };
-        let kr = Rect::from_min_size(pos2(x0 + 26.0, fy), vec2((w / 2.0 - 32.0).max(50.0), 20.0));
-        let kpop = egui::Id::unique("char-kerning-pop");
-        if widgets::dropdown(ui, kr, &label, &t, egui::Id::unique("char-kerning")).clicked() && enabled {
-            widgets::open_popup(ui, kpop);
-        }
-        app.auto.add("character.kerning", kr, "Kerning");
-        let opts: Vec = ["Metrics", "Optical", "0", "-50", "-25", "-10", "10", "25", "50", "100"].iter().map(|s| s.to_string()).collect();
-        if let Some(i) = widgets::popup_menu(ui, kpop, kr.left_bottom(), &opts, None) {
-            let v = match i {
-                0 => json!("metrics"),
-                1 => json!("optical"),
-                _ => json!(opts[i].parse::().unwrap_or(0.0)),
-            };
-            actions.push(json!({"kerning": v}));
-        }
-    }
-    // Stroke Over Fill / Fill Over Stroke (row 2, right).
-    {
-        let fx = x0 + w / 2.0;
-        let fy = y + 2.0 * 28.0;
-        let dr = Rect::from_min_size(pos2(fx, fy), vec2(w / 2.0, 20.0));
-        let labels = vec!["Fill Over Stroke".to_string(), "Stroke Over Fill".to_string()];
-        let pop = egui::Id::unique("char-stroke-order-pop");
-        if widgets::dropdown(ui, dr, &labels[doc.stroke_over_fill as usize], &t, egui::Id::unique("char-stroke-order")).clicked() && enabled {
-            widgets::open_popup(ui, pop);
-        }
-        app.auto.add("character.strokeOverFill", dr, "Stroke order");
-        if let Some(i) = widgets::popup_menu(ui, pop, dr.left_bottom(), &labels, Some(doc.stroke_over_fill as usize)) {
-            actions.push(json!({"strokeOverFill": i == 1}));
-        }
-    }
+    let grid = Grid { x0, y, w, enabled };
+    numeric_fields(app, ui, &p, grid, &doc, &mut actions);
+    kerning_field(app, ui, &p, grid, &doc, &mut actions);
+    stroke_order_field(app, ui, grid, &doc, &mut actions);
     y += 5.0 * 28.0 + 8.0;
     // Fill / stroke swatches with their enable boxes.
     for (i, (key, label, on, c)) in [("fill", "Fill", doc.apply_fill, doc.fill), ("stroke", "Stroke", doc.apply_stroke, doc.stroke)].into_iter().enumerate() {
@@ -242,7 +274,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let bw = ((w - 5.0 * 4.0) / 6.0).clamp(22.0, 30.0);
     for (i, (key, label, on, bold)) in toggles.into_iter().enumerate() {
         let r = Rect::from_min_size(pos2(x0 + i as f32 * (bw + 4.0), y), vec2(bw, 24.0));
-        if toggle(app, ui, &p, r, &format!("character.{key}"), label, on, bold) && enabled {
+        if toggle(app, ui, &p, r, &format!("character.{key}"), (label, on, bold)) && enabled {
             actions.push(json!({key: !on}));
         }
     }
@@ -296,8 +328,14 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let tag = a.tag.trim_end().to_string();
             let v = doc.variations.iter().chain(&instance).find(|(t2, _)| t2.trim_end() == tag).map(|(_, v)| *v).unwrap_or(a.default);
             p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, &tag, Tokens::semibold(10.5), t.text_dim);
-            let (r, nv, _) =
-                widgets::hot_number_at(ui, pos2(fx + 34.0, fy), egui::Id::unique(("char-axis", &tag)), v as f64, 1.0, (a.min as f64, a.max as f64), 0, "", &t);
+            let (r, nv, _) = widgets::hot_number_at(
+                ui,
+                pos2(fx + 34.0, fy),
+                egui::Id::unique(("char-axis", &tag)),
+                v as f64,
+                &widgets::HotSpec { speed: 1.0, range: (a.min as f64, a.max as f64), decimals: 0, suffix: "" },
+                &t,
+            );
             app.auto.add(&format!("character.axis.{tag}"), r, &a.name);
             if let Some(nv) = nv
                 && enabled
@@ -517,7 +555,14 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let fx = x0 + (i % 2) as f32 * (w / 2.0);
         let fy = y + (i / 2) as f32 * 28.0;
         p.text(pos2(fx, fy + 9.0), Align2::LEFT_CENTER, glyph, Tokens::semibold(11.0), t.text_dim);
-        let (r, nv, _) = widgets::hot_number_at(ui, pos2(fx + 26.0, fy), egui::Id::unique(("para-f", key)), v, 0.5, (-5000.0, 5000.0), 0, " px", &t);
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(fx + 26.0, fy),
+            egui::Id::unique(("para-f", key)),
+            v,
+            &widgets::HotSpec { speed: 0.5, range: (-5000.0, 5000.0), decimals: 0, suffix: " px" },
+            &t,
+        );
         app.auto.add(&format!("paragraph.{key}"), r, key);
         if let Some(nv) = nv
             && enabled
diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs
index ea18c7f1e..a45d8e708 100644
--- a/crates/ui-egui/src/panels/timeline.rs
+++ b/crates/ui-egui/src/panels/timeline.rs
@@ -15,6 +15,7 @@ use effectcraft_engine::render::EvalCtx;
 use effectcraft_engine::time::Tick;
 use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, pos2, vec2};
 use serde_json::json;
+use std::sync::Arc;
 
 use crate::i18n::tr;
 use crate::icons::{self, Icon};
@@ -169,19 +170,24 @@ fn pick_whip_id() -> egui::Id {
     egui::Id::unique("tl-pickwhip")
 }
 
+/// The automation id, label and hover hint of a pick whip.
+struct WhipText<'a> {
+    auto: &'a str,
+    label: &'a str,
+    hint: &'a str,
+}
+
 /// A pick whip at `rect`: dragging it starts a pick whip of `kind` from `layer`'s `prop` (0 for
 /// the layer's own), drawn and dropped at the end of [`show`].
-#[allow(clippy::too_many_arguments)]
 fn pick_whip(
     app: &mut EffectcraftApp,
     ui: &egui::Ui,
     p: &egui::Painter,
     rect: Rect,
     (kind, layer, prop): (u8, u64, u64),
-    auto: &str,
-    label: &str,
-    hint: &str,
+    text: WhipText<'_>,
 ) -> egui::Response {
+    let WhipText { auto, label, hint } = text;
     let resp = ui.interact(rect, egui::Id::unique(("pick-whip", kind, layer, prop)), Sense::drag()).on_hover_text(hint);
     icons::paint(p, rect.shrink(1.0), Icon::PickWhip, if resp.hovered() || resp.dragged() { app.tokens.text } else { app.tokens.text_dim });
     app.auto.add(auto, rect, label);
@@ -1415,12 +1421,9 @@ fn mode_targets(comp: &Comp, layer: LayerId, selected: &[LayerId]) -> Vec {
     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)]
-fn layer_switch(
-    ui: &mut egui::Ui,
-    t: &Tokens,
+/// Where and what a layer switch is: its cell, icon, state and the switch it sets.
+#[derive(Clone, Copy)]
+struct SwitchSpec {
     row: Rect,
     br: Rect,
     icon: Icon,
@@ -1429,11 +1432,20 @@ fn layer_switch(
     layer: u64,
     name: &'static str,
     others: bool,
+}
+
+/// A layer's switch (A/V Features and Switches columns) in `row`, with the
+/// [`switch_gesture`].
+fn layer_switch(
+    ui: &mut egui::Ui,
+    t: &Tokens,
+    spec: &SwitchSpec,
     comp: &Comp,
     project: &Project,
     selected: &[LayerId],
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let SwitchSpec { row, br, icon, on, id, layer, name, others } = *spec;
     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. With several layers selected, the whole selection is
@@ -1516,54 +1528,108 @@ fn replace_source_target(app: &EffectcraftApp, cid: ItemId, item: ItemId, lid: L
             .is_some_and(|l| !l.switches.locked && matches!(l.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }))
 }
 
-/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
-/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
-/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
-/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
-/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
-/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
-/// open rows and the drags in progress) lives in
-/// `app.ui.timeline` and egui's temp data.
-pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+/// The frame's layout and state shared by the Timeline's drawing steps.
+struct Tl {
+    t: Tokens,
+    cid: ItemId,
+    time: Tick,
+    fr: effectcraft_engine::time::FrameRate,
+    rect: Rect,
+    header_h: f32,
+    colhdr_h: f32,
+    footer_h: f32,
+    vis: Vis,
+    left_w: f32,
+    overflow: f32,
+    oscroll: f32,
+    cw: Cols,
+    graph_x0: f32,
+    graph_x1: f32,
+    fit_pps: f64,
+    pps: f64,
+    scroll_to: Option,
+    tm: TMap,
+    top: f32,
+    rows_rect: Rect,
+    ruler: Rect,
+    bin: Rect,
+    tc_rect: Rect,
+    wa_row: Rect,
+    fd: f64,
+    rh: f32,
+    max_scroll: f32,
+    ctx: egui::Context,
+    comp: Arc,
+    p: egui::Painter,
+    rows: Arc>,
+    actions: Vec<(String, serde_json::Value)>,
+}
+
+/// [`Tl`] plus what the layer rows and the interactions after them share.
+struct Rw {
+    tl: Tl,
+    lp: egui::Painter,
+    gp: egui::Painter,
+    graph_on: bool,
+    full_clip: Rect,
+    left_clip: Rect,
+    right_clip: Rect,
+    empty_rect: Rect,
+    outline_rect: Rect,
+    empty: egui::Response,
+    outline_empty: egui::Response,
+    selected: Vec,
+    sel_keys: Vec,
+    ui_actions: Vec,
+    hit_rows: Vec<(Rect, Row)>,
+    dim_hits: Vec<(u64, Vec)>,
+}
+
+/// One visible row: what the per-kind drawing steps need to know about it.
+struct RowCtx<'a> {
+    row: &'a Row,
+    layer: &'a Layer,
+    r: Rect,
+    left: Rect,
+    cy: f32,
+    is_sel: bool,
+    ri: usize,
+    indent: f32,
+}
+
+/// The responses of a layer bar's body and its two trim edges.
+struct BarResps {
+    body: egui::Response,
+    lin: egui::Response,
+    lout: egui::Response,
+}
+
+/// An expression row's property, expression and editor ids.
+#[derive(Clone, Copy)]
+struct ExprRow<'a> {
+    prop: &'a Property,
+    ex: &'a effectcraft_engine::project::Expression,
+    buf_id: egui::Id,
+    edit_id: egui::Id,
+}
+
+/// A keyframe marker's hit result.
+struct KeyHit<'a> {
+    k: &'a effectcraft_engine::keyframe::Keyframe,
+    ct: Tick,
+    ks: bool,
+    kresp: egui::Response,
+    mods: egui::Modifiers,
+    this_key: serde_json::Value,
+}
+
+/// Computes this frame's layout: zoom, scroll and the panel's regions.
+fn layout(app: &mut EffectcraftApp, ui: &egui::Ui, rect: Rect, cid: ItemId, comp: &Arc) -> Tl {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
-    let Some(cid) = app.session.active_comp_id() else {
-        ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
-        // Dropping footage into an empty Timeline should create its first composition,
-        // just like dropping onto the Project panel's New Composition button (#649).
-        if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref()
-            && ui.rect_contains_pointer(rect)
-        {
-            let items: Vec = crate::panels::project_drop_items(app, *dragged)
-                .into_iter()
-                .map(ItemId)
-                .filter(|id| {
-                    app.session.project.item(*id).is_some_and(|item| match &item.kind {
-                        effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
-                        effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
-                        _ => false,
-                    })
-                })
-                .collect();
-            if !items.is_empty() {
-                ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
-                app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
-                if ctx.input(|i| i.pointer.any_released()) {
-                    app.session.state.project_selection = items;
-                    if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
-                        app.ui.status = e;
-                    }
-                    egui::DragAndDrop::clear_payload(&ctx);
-                }
-            }
-        }
-        return;
-    };
-    let Some(comp) = app.session.project.comp_arc(cid) else { return };
     let p = ui.painter().clone();
     let time = app.session.time();
     let fr = comp.frame_rate;
-
     // Geometry.
     let header_h = 48.0;
     let ruler_h = 34.0;
@@ -1581,7 +1647,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let graph_x0 = rect.min.x + left_w + 1.0;
     let graph_x1 = rect.max.x - GUTTER_W;
     ctx.data_mut(|d| d.insert_temp(egui::Id::unique("timeline-graph-area"), (graph_x0, graph_x1 - graph_x0)));
-    let fit_pps = fit_pps(graph_x1 - graph_x0, &comp);
+    let fit_pps = fit_pps(graph_x1 - graph_x0, comp);
     let pps = app.ui.timeline.pps.unwrap_or(fit_pps);
     if app.ui.timeline.pps.is_none() {
         app.ui.timeline.start = 0.0;
@@ -1605,17 +1671,434 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let top = rect.min.y;
     let rows_top = top + header_h + colhdr_h;
     let rows_rect = Rect::from_min_max(pos2(rect.min.x, rows_top), pos2(rect.max.x, rect.max.y - footer_h));
-    let graph_rect = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h + colhdr_h), pos2(rect.max.x, rect.max.y - footer_h));
     p.rect_filled(Rect::from_min_max(pos2(graph_x0, top), rect.max), 0.0, t.tl_bg);
     p.line_segment([pos2(graph_x0 - 1.0, top), pos2(graph_x0 - 1.0, rect.max.y)], Stroke::new(1.0, t.app_bg));
 
-    let mut actions: Vec<(String, serde_json::Value)> = Vec::new();
-    if !ui.input(|i| i.pointer.primary_down()) {
-        ctx.data_mut(|d| d.remove::(switch_drag_id()));
-    }
-    // ---- header (left): time display, search, switches.
-    let tc = crate::panels::timecode(&app.session, &comp, time);
+    let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
+    // The composition marker bin, at the right end of the ruler above the layers' scroll bar.
+    let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
     let tc_rect = Rect::from_min_size(pos2(rect.min.x + 12.0, top + 6.0), vec2(150.0, 24.0));
+    let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
+    let fd = comp.frame_duration().seconds();
+    Tl {
+        t,
+        cid,
+        time,
+        fr,
+        rect,
+        header_h,
+        colhdr_h,
+        footer_h,
+        vis,
+        left_w,
+        overflow,
+        oscroll,
+        cw,
+        graph_x0,
+        graph_x1,
+        fit_pps,
+        pps,
+        scroll_to,
+        tm,
+        top,
+        rows_rect,
+        ruler,
+        bin,
+        tc_rect,
+        wa_row,
+        fd,
+        rh: t.row_h,
+        max_scroll: 0.0,
+        ctx,
+        comp: comp.clone(),
+        p,
+        rows: Arc::new(Vec::new()),
+        actions: Vec::new(),
+    }
+}
+
+/// Without an active composition: a hint, and dropping footage creates the first composition.
+fn empty_timeline(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let t = app.tokens;
+    let ctx = ui.ctx().clone();
+    ui.painter().text(rect.center(), Align2::CENTER_CENTER, "(no composition)", Tokens::ui(12.0), t.text_faint);
+    // Dropping footage into an empty Timeline should create its first composition,
+    // just like dropping onto the Project panel's New Composition button (#649).
+    if let Some(crate::panels::DragPayload::Item(dragged)) = egui::DragAndDrop::payload::(&ctx).as_deref()
+        && ui.rect_contains_pointer(rect)
+    {
+        let items: Vec = crate::panels::project_drop_items(app, *dragged)
+            .into_iter()
+            .map(ItemId)
+            .filter(|id| {
+                app.session.project.item(*id).is_some_and(|item| match &item.kind {
+                    effectcraft_engine::project::ItemKind::Footage(footage) => footage.kind != effectcraft_engine::project::FootageKind::Data,
+                    effectcraft_engine::project::ItemKind::Solid(_) | effectcraft_engine::project::ItemKind::Comp(_) => true,
+                    _ => false,
+                })
+            })
+            .collect();
+        if !items.is_empty() {
+            ui.painter().rect_stroke(rect.shrink(2.0), 0.0, Stroke::new(2.0, t.accent), StrokeKind::Inside);
+            app.auto.add("timeline.newCompDrop", rect, "Drop footage to create a composition");
+            if ctx.input(|i| i.pointer.any_released()) {
+                app.session.state.project_selection = items;
+                if let Err(e) = crate::menus::invoke(app, &ctx, "file.newCompFromSelection", json!({})) {
+                    app.ui.status = e;
+                }
+                egui::DragAndDrop::clear_payload(&ctx);
+            }
+        }
+    }
+}
+
+/// Starts the rows pass: the clipped painters and the background responses.
+fn begin_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, tl: Tl) -> Rw {
+    let rows_rect = tl.rows_rect;
+    let graph_x0 = tl.graph_x0;
+    let rect = tl.rect;
+    let lp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
+    let gp = tl.p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
+    let graph_on = app.ui.timeline.graph_editor;
+    super::graph::track_open(ui.ctx(), graph_on);
+    let full_clip = ui.clip_rect();
+    let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
+    let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
+    // Registered before the rows so keys, bars and editors on top of it get the pointer first.
+    let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
+    let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
+    let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
+    let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
+    app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
+    Rw {
+        lp,
+        gp,
+        graph_on,
+        full_clip,
+        left_clip,
+        right_clip,
+        empty_rect,
+        outline_rect,
+        empty,
+        outline_empty,
+        selected: app.session.state.selected_layers.clone(),
+        sel_keys: app.session.state.selected_keys.clone(),
+        ui_actions: Vec::new(),
+        hit_rows: Vec::new(),
+        dim_hits: Vec::new(),
+        tl,
+    }
+}
+
+/// The Timeline panel for the active composition inside `rect`: draws the header, ruler, work
+/// area and cache bars, the layer rows with their switch and value cells, the time graph and its
+/// keys, and the footer; and turns this frame's clicks, drags and drops into commands. Without an
+/// active comp it shows a hint, and dropping footage on it creates a composition. Most edits
+/// use [`crate::menus::invoke`]; time, switch and work-area gestures call `Session::execute`
+/// directly and ignore errors. Reported failures go to `app.ui.status`. UI state (scroll, zoom,
+/// open rows and the drags in progress) lives in
+/// `app.ui.timeline` and egui's temp data.
+pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    let Some(cid) = app.session.active_comp_id() else {
+        empty_timeline(app, ui, rect);
+        return;
+    };
+    let Some(comp) = app.session.project.comp_arc(cid) else { return };
+    let mut tl = layout(app, ui, rect, cid, &comp);
+    let g = &mut tl;
+    if !ui.input(|i| i.pointer.primary_down()) {
+        g.ctx.data_mut(|d| d.remove::(switch_drag_id()));
+    }
+    header_timecode(app, ui, g);
+    header_search_and_switches(app, ui, g);
+    time_navigator(app, ui, g);
+    ruler_background(app, g);
+    work_area_bar(app, ui, g);
+    cache_bars(app, g);
+    ruler_ticks(app, g);
+    ruler_scrub(app, ui, g);
+    column_header_icons(app, g);
+    column_header_interact(app, ui, g);
+    let rows = Arc::new(with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &g.ctx));
+    let total_h: f32 = rows.iter().map(|r| row_height(r, g.rh)).sum();
+    g.max_scroll = (total_h - g.rows_rect.height() + g.rh).max(0.0);
+    g.rows = rows;
+    reveal_scroll(app, g);
+    wheel_zoom_scroll(app, ui, g);
+    pick_whip_edge_scroll(app, g);
+    let snap_project = app.session.project.clone();
+    let snap_expr = app.session.expr.clone();
+    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: g.time, expr: snap_expr.as_deref(), footage: None };
+    let mut rw = begin_rows(app, ui, tl);
+    let g = &mut rw;
+    draw_rows(app, ui, g, &ectx);
+    ui.set_clip_rect(g.full_clip);
+    background_menus(app, ui, g);
+    outline_marquee(ui, g);
+    pick_whip_drag(g);
+    alt_scale_and_graph(app, ui, g, &ectx);
+    graph_empty_area(ui, g);
+    layer_reorder_drag(ui, g);
+    quick_offset_follow(g);
+    key_drag_follow(app, g);
+    key_box_select(app, ui, g);
+    current_time_indicator(app, g);
+    let footer = footer_bar(app, ui, g);
+    footer_zoom(app, ui, g, footer);
+    comp_markers_ui(app, ui, g);
+    hand_tool_pan(app, ui, g);
+    drop_targets(app, ui, g);
+    let Rw { tl, ui_actions, .. } = rw;
+    apply_ui_acts(app, &tl.comp, ui_actions);
+    apply_actions(app, ui, &tl.ctx, tl.actions);
+}
+
+/// Applies the UI-only changes the frame collected (twirls, time, ending an undo merge).
+fn apply_ui_acts(app: &mut EffectcraftApp, comp: &Comp, ui_actions: Vec) {
+    for a in ui_actions {
+        match a {
+            UiAct::ToggleLayer(id, all) => {
+                let tl = &mut app.ui.timeline;
+                // Twirled by hand, the layer shows its whole tree again.
+                tl.reveal.clear();
+                tl.layer_reveal.remove(&id);
+                tl.full_groups.remove(&id);
+                let open = !tl.open_layers.contains(&id);
+                if open {
+                    tl.open_layers.insert(id);
+                } else {
+                    tl.open_layers.remove(&id);
+                }
+                if all && let Some(l) = comp.layer(LayerId(id)) {
+                    let mut uids = vec![];
+                    walk_groups(&l.props, &mut uids);
+                    for u in uids {
+                        if open {
+                            tl.open_groups.insert(u);
+                        } else {
+                            tl.open_groups.remove(&u);
+                        }
+                    }
+                }
+            }
+            UiAct::ToggleGroup(layer, uid, all) => {
+                let tl = &mut app.ui.timeline;
+                tl.toggle_group(layer, uid);
+                let open = tl.open_groups.contains(&uid);
+                if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) {
+                    let mut uids = vec![];
+                    walk_groups(g, &mut uids);
+                    for u in uids {
+                        if open {
+                            tl.open_groups.insert(u);
+                        } else {
+                            tl.open_groups.remove(&u);
+                        }
+                    }
+                }
+            }
+            UiAct::SetTime(tt) => app.session.set_time(tt),
+            UiAct::EndMerge => app.session.history.merge_key = None,
+            UiAct::ChangeRenderer => {
+                if super::dialogs::open_comp_settings(app).is_ok() {
+                    app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
+                }
+            }
+        }
+    }
+}
+
+/// Runs the commands the frame collected through [`crate::menus::invoke`].
+fn apply_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, actions: Vec<(String, serde_json::Value)>) {
+    if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
+        app.ui.viewer.property_interacting = true;
+        ctx.request_repaint();
+    }
+    for (id, params) in actions {
+        if id == "__endMerge" {
+            app.session.history.merge_key = None;
+            continue;
+        }
+        if let Err(e) = crate::menus::invoke(app, ctx, &id, params) {
+            app.ui.status = e;
+        }
+    }
+}
+
+/// Draws the visible rows, top to bottom, each by its kind.
+fn draw_rows(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
+    let comp = g.tl.comp.clone();
+    let rows = g.tl.rows.clone();
+    let (rect, rows_rect, rh, cw_name) = (g.tl.rect, g.tl.rows_rect, g.tl.rh, g.tl.cw.name);
+    let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
+    // Rows stripe by their index among the rows that are not expression editors, so adding or
+    // removing an expression leaves every other row's stripe alone; an editor takes the index
+    // (and so the stripe) of its property's row, the one just above.
+    let mut striped = 0usize;
+    for row in rows.iter() {
+        let ri = if matches!(row.kind, RowKind::Expr { .. }) {
+            striped.saturating_sub(1)
+        } else {
+            striped += 1;
+            striped - 1
+        };
+        // Outline widgets never spill into the time graph (narrow timelines).
+        ui.set_clip_rect(g.left_clip);
+        let hh = row_height(row, rh);
+        let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
+        y += hh;
+        if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
+            continue;
+        }
+        g.hit_rows.push((r, row.clone()));
+        let Some(layer) = comp.layer(row.layer) else { continue };
+        let rc = RowCtx {
+            row,
+            layer,
+            r,
+            left: Rect::from_min_max(r.min, pos2(g.tl.graph_x0 - 1.0, r.max.y)),
+            cy: r.min.y + rh / 2.0,
+            is_sel: g.selected.contains(&layer.id),
+            ri,
+            indent: cw_name + 6.0 + 14.0 * row.depth as f32,
+        };
+        match &row.kind {
+            RowKind::Layer => layer_row(app, ui, g, &rc),
+            RowKind::Group { .. } => group_row(app, ui, g, &rc),
+            RowKind::Waveform { .. } => waveform_row(app, g, &rc),
+            RowKind::Expr { .. } => expr_row(app, ui, g, &rc),
+            RowKind::Prop { .. } => prop_row(app, ui, g, &rc, ectx),
+        }
+    }
+}
+
+/// A layer row: switches, label, name, cells and the layer bar.
+fn layer_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    layer_row_background(g, rc);
+    layer_av_switches(app, ui, g, rc);
+    layer_label_swatch(app, ui, g, rc);
+    layer_number_and_cells(app, ui, g, rc);
+    let (row_resp, name_x) = layer_twirl_and_icon(app, ui, g, rc);
+    layer_name(app, ui, g, rc, name_x);
+    layer_row_actions(app, ui, g, rc, &row_resp);
+    layer_switches(app, ui, g, rc);
+    if app.ui.timeline.show_modes && rc.layer.source.is_av() {
+        layer_mode_cell(app, ui, g, rc);
+        layer_matte_cell(app, ui, g, rc);
+    }
+    if g.tl.vis.parent {
+        layer_parent_cell(app, ui, g, rc);
+    }
+    ui.set_clip_rect(g.right_clip);
+    if !g.graph_on {
+        layer_bar(app, ui, g, rc);
+    }
+}
+
+/// The layer's duration bar: source extent, move and trim, markers.
+fn layer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let (bar, lc) = layer_bar_paint(g, rc);
+    layer_source_extent(app, ui, g, rc, bar, lc);
+    let bars = layer_bar_hits(app, ui, g, rc, bar);
+    let quick = layer_quick_offset_begin(ui, g, rc, &bars.body);
+    layer_bar_drag(g, rc, &bars, quick);
+    layer_bar_markers(app, ui, g, rc);
+}
+
+/// A group row (effect, mask, shape group, layer style).
+fn group_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let shown = group_background(app, g, rc);
+    group_switches(app, ui, g, rc);
+    group_name(app, ui, g, rc, &shown);
+    group_popups(app, ui, g, rc);
+    group_mask_mode(ui, g, rc);
+    group_row_click(app, ui, g, rc);
+}
+
+/// An expression row: its controls and the editor.
+fn expr_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    let Some(prop) = rc.layer.props.find(*uid) else { return };
+    let Some(ex) = prop.expr.as_ref() else { return };
+    let st = ExprRow { prop, ex, buf_id: expr_buf_id(*uid), edit_id: egui::Id::unique(("expr-edit", uid)) };
+    expr_controls(app, ui, g, rc, &st);
+    expr_language_menu(app, ui, g, rc, &st);
+    expr_editor_cell(app, ui, g, rc, &st);
+}
+
+/// A property row: keyframe navigator, stopwatch, name, value editors and the keyframes.
+fn prop_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>) {
+    let RowKind::Prop { uid } = &rc.row.kind else { return };
+    let Some(prop) = rc.layer.props.find(*uid) else { return };
+    prop_background(app, g, rc, prop);
+    prop_key_navigator(app, ui, g, rc, prop);
+    prop_stopwatch(app, ui, g, rc, prop);
+    let name_x = prop_name(app, ui, g, rc, prop);
+    prop_value_cell(app, ui, g, rc, ectx, prop, name_x);
+    prop_expression_hint(app, ui, g, rc, prop);
+    if !g.graph_on {
+        for k in &prop.keys {
+            key_marker(app, ui, g, rc, prop, k);
+        }
+    }
+}
+
+/// One keyframe marker: its look, selection, menu and drag.
+fn key_marker(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, k: &effectcraft_engine::keyframe::Keyframe) {
+    let RowKind::Prop { uid } = &rc.row.kind else { return };
+    let ct = rc.layer.comp_time(k.time);
+    let x = g.tl.tm.x(ct.seconds());
+    if x < g.tl.graph_x0 - 8.0 || x > g.tl.rect.max.x + 8.0 {
+        return;
+    }
+    let kref = effectcraft_engine::KeyRef { layer: rc.layer.id, prop: *uid, time: k.time };
+    let ks = g.sel_keys.contains(&kref);
+    let kr = key_paint(g, rc, k, x, ks);
+    let kresp = key_hit(app, ui, g, rc, prop, k, kr);
+    let mods = ui.input(|i| i.modifiers);
+    let this_key = json!({"keys": [{"layer": rc.layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
+    let kh = KeyHit { k, ct, ks, kresp, mods, this_key };
+    key_select(g, &kh);
+    key_double_click(g, rc, prop, &kh);
+    key_context_menu(app, g, rc, prop, &kh);
+    key_drag_begin(app, g, &kh);
+}
+
+/// Drop targets: effects and presets on a layer, footage and files into the stack.
+fn drop_targets(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let ctx = g.tl.ctx.clone();
+    let rows_rect = g.tl.rows_rect;
+    if let Some(payload) = egui::DragAndDrop::payload::(&ctx)
+        && !matches!(*payload, crate::panels::DragPayload::Property { .. })
+        && 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());
+        // 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 {
+            drop_replace_source(app, g, &payload, ptr, released);
+        } else if matches!(payload.as_ref(), crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. }) {
+            drop_on_layer(app, g, &payload, ptr, released);
+        } else {
+            drop_insert_layers(app, g, &payload, ptr, released);
+        }
+        if released {
+            egui::DragAndDrop::clear_payload(&ctx);
+        }
+    }
+}
+
+/// The header's time display: click to switch style, drag to scrub, double-click to type a time.
+fn header_timecode(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let time = g.time;
+    let fr = g.fr;
+    let tc_rect = g.tc_rect;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
+    let tc = crate::panels::timecode(&app.session, &comp, time);
     let tc_resp = ui.interact(tc_rect, egui::Id::unique("tl-timecode"), Sense::click_and_drag());
     p.text(tc_rect.left_center(), Align2::LEFT_CENTER, &tc, Tokens::semibold(16.0), t.timecode);
     // Under it the other style (frames under timecode, timecode under frames) and the rate.
@@ -1657,6 +2140,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-tc-edit"), buf));
         }
     }
+}
+
+/// The header's search field and the composition switches.
+fn header_search_and_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let rect = g.rect;
+    let left_w = g.left_w;
+    let top = g.top;
+    let tc_rect = g.tc_rect;
+    let comp = g.comp.clone();
     let sr = Rect::from_min_size(pos2(tc_rect.max.x + 14.0, top + 12.0), vec2((left_w - 420.0).clamp(120.0, 220.0), 22.0));
     let mut search = app.ui.timeline.search.clone();
     widgets::search_field(ui, sr, &mut search, "Search", &t);
@@ -1685,11 +2178,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         bx -= 28.0;
     }
+}
 
-    // ---- ruler (right).
-    let ruler = Rect::from_min_max(pos2(graph_x0, top + header_h - ruler_h), pos2(rect.max.x, top + header_h));
-    // The composition marker bin, at the right end of the ruler above the layers' scroll bar.
-    let bin = Rect::from_min_max(pos2(graph_x1 + 2.0, ruler.max.y - 16.0), pos2(rect.max.x - 2.0, ruler.max.y - 2.0));
+/// The time navigator: the visible span as a bar with end handles.
+fn time_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let t = g.t;
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let fit_pps = g.fit_pps;
+    let pps = g.pps;
+    let tm = g.tm;
+    let top = g.top;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
     let nav = Rect::from_min_max(pos2(graph_x0 + 6.0, top + 6.0), pos2(graph_x1, top + 14.0));
     // Time navigator: the visible span as a grey bar with blue end handles (as in After
     // Effects), over a dark track.
@@ -1730,12 +2232,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             set_zoom(&mut app.ui.timeline, &comp, span_px / span.max(min_span), fit_pps, keep_t, keep_px);
         }
     }
+}
+
+fn ruler_background(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let p = g.p.clone();
     p.rect_filled(ruler, 0.0, t.tl_ruler_bg);
     app.auto.add("timeline.ruler", ruler, &format!("{},{}", tm.start, tm.pps));
+}
+
+/// The work area bar beside the column headers, with its begin / end handles.
+fn work_area_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
+    let t = g.t;
+    let fr = g.fr;
+    let pps = g.pps;
+    let tm = g.tm;
+    let wa_row = g.wa_row;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let p = g.p.clone();
+    let actions = &mut g.actions;
     // Work area bar: below the ruler, beside the column headers (After Effects' layout), with
     // blue begin / end handles; the cache bar runs under it. Dragging the bar moves the work
     // area, the handles move its ends; Shift snaps (#252).
-    let wa_row = Rect::from_min_max(pos2(graph_x0, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     let (wa0, wa1) = (comp.work_area.0.seconds(), comp.work_area.1.seconds());
     let wa = Rect::from_min_max(pos2(tm.x(wa0), wa_row.min.y + 3.0), pos2(tm.x(wa1), wa_row.max.y - 6.0));
     p.with_clip_rect(wa_row).rect_filled(wa, 0.0, t.tl_work_area_bar);
@@ -1791,10 +2312,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"}));
         }
     }
+}
+
+/// The RAM (green) and disk (blue) cache bars under the work area.
+fn cache_bars(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let cid = g.cid;
+    let tm = g.tm;
+    let wa_row = g.wa_row;
+    let fd = g.fd;
+    let comp = g.comp.clone();
+    let p = g.p.clone();
     // Cache bar (green: cached frames of what the viewer shows: its resolution, view and options).
     let series = app.shown_series(cid);
     let cached = app.frames.cached_frames(&series);
-    let fd = comp.frame_duration().seconds();
     let cy0 = wa_row.max.y - 4.0;
     let mut run: Option<(i64, i64)> = None;
     let draw_run = |a: i64, b: i64| {
@@ -1843,6 +2374,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             draw_blue(a, b);
         }
     }
+}
+
+/// The ruler's tick marks and labels.
+fn ruler_ticks(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let fr = g.fr;
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let pps = g.pps;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let bin = g.bin;
+    let fd = g.fd;
+    let p = g.p.clone();
     // Ticks + labels.
     // Label spacing in whole frames (AE: `00:15f`-style seconds:frames labels).
     let fps_i = fr.as_f64().round().max(1.0) as i64;
@@ -1871,6 +2416,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         s += secs_per_label;
     }
+}
+
+/// Scrubbing in the ruler: the current time follows the pointer.
+fn ruler_scrub(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let graph_x0 = g.graph_x0;
+    let graph_x1 = g.graph_x1;
+    let pps = g.pps;
+    let tm = g.tm;
+    let ruler = g.ruler;
+    let bin = g.bin;
+    let comp = g.comp.clone();
     // Scrub in the ruler: the current time jumps to the pointer as soon as the button goes down
     // (After Effects), then follows it.
     let rresp = ui.interact(
@@ -1929,8 +2485,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             app.scrub_audio(cid, t, now);
         }
     }
+}
 
-    // ---- column headers (right-click: show/hide columns; click the name header: Source/Layer Name).
+/// The column headers' background, icons and titles.
+fn column_header_icons(app: &mut EffectcraftApp, g: &Tl) {
+    let t = g.t;
+    let rect = g.rect;
+    let header_h = g.header_h;
+    let colhdr_h = g.colhdr_h;
+    let vis = g.vis;
+    let graph_x0 = g.graph_x0;
+    let top = g.top;
+    let p = g.p.clone();
+    let cw = &g.cw;
     let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     p.rect_filled(Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y)), 0.0, t.panel_bg);
     p.line_segment([pos2(rect.min.x, ch.max.y), pos2(rect.max.x, ch.max.y)], Stroke::new(1.0, t.separator));
@@ -1980,6 +2547,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             htext(x + 6.0, s);
         }
     }
+}
+
+/// Column header clicks, automation ids and the right-click column menu.
+fn column_header_interact(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Tl) {
+    let rect = g.rect;
+    let header_h = g.header_h;
+    let colhdr_h = g.colhdr_h;
+    let vis = g.vis;
+    let graph_x0 = g.graph_x0;
+    let top = g.top;
+    let ctx = g.ctx.clone();
+    let cw = &g.cw;
+    let actions = &mut g.actions;
+    let ch = Rect::from_min_max(pos2(rect.min.x, top + header_h), pos2(rect.max.x, top + header_h + colhdr_h));
     let hdr_rect = Rect::from_min_max(ch.min, pos2(graph_x0 - 1.0, ch.max.y));
     let hresp = ui.interact(hdr_rect, egui::Id::unique("tl-colhdr"), Sense::click());
     app.auto.add("timeline.header", hdr_rect, "Columns (right-click)");
@@ -2004,12 +2585,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     if hresp.clicked()
         && let Some(pt) = hresp.interact_pointer_pos()
-        && column_at(&cw, pt.x) == Some("name")
+        && column_at(cw, pt.x) == Some("name")
     {
         app.ui.timeline.source_name = !app.ui.timeline.source_name;
     }
     if hresp.secondary_clicked() {
-        let under = hresp.interact_pointer_pos().and_then(|pt| column_at(&cw, pt.x));
+        let under = hresp.interact_pointer_pos().and_then(|pt| column_at(cw, pt.x));
         ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-colhdr-under"), under.unwrap_or("")));
     }
     crate::widgets::context_menu(&hresp, |ui| {
@@ -2034,12 +2615,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     });
     let _ = cw.end;
+}
 
-    // ---- rows.
-    let rows = with_expr_rows(build_rows(app, &comp), &comp, &app.ui.timeline, &ctx);
-    let rh = t.row_h;
-    let total_h: f32 = rows.iter().map(|r| row_height(r, rh)).sum();
-    let max_scroll = (total_h - rows_rect.height() + rh).max(0.0);
+/// Scrolls an expression editor or the requested layer / property into view.
+fn reveal_scroll(app: &mut EffectcraftApp, g: &Tl) {
+    let scroll_to = g.scroll_to;
+    let rows_rect = g.rows_rect;
+    let rh = g.rh;
+    let ctx = g.ctx.clone();
+    let comp = g.comp.clone();
+    let rows = &g.rows;
     // An expression editor asked to take the keyboard comes into view; a request whose
     // expression didn't appear, or that wasn't taken within a few passes, is dropped (it never
     // takes the keyboard later).
@@ -2065,6 +2650,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         app.ui.timeline.scroll_y = rows.iter().take(i).map(|r| row_height(r, rh)).sum();
     }
+}
+
+/// Mouse wheel and pinch over the rows: zoom time or scroll.
+fn wheel_zoom_scroll(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Tl) {
+    let overflow = g.overflow;
+    let graph_x0 = g.graph_x0;
+    let fit_pps = g.fit_pps;
+    let pps = g.pps;
+    let tm = g.tm;
+    let rows_rect = g.rows_rect;
+    let max_scroll = g.max_scroll;
+    let comp = g.comp.clone();
     if ui.rect_contains_pointer(rows_rect) {
         let (dy, dx, zoom) = ui.input(|i| (i.smooth_scroll_delta.y, i.smooth_scroll_delta.x, i.modifiers.alt));
         // Ctrl/Cmd+wheel and a trackpad pinch reach egui as a zoom factor rather than a scroll
@@ -2090,6 +2687,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// A held pick whip near the top or bottom scrolls the rows.
+fn pick_whip_edge_scroll(app: &mut EffectcraftApp, g: &Tl) {
+    let rect = g.rect;
+    let rows_rect = g.rows_rect;
+    let rh = g.rh;
+    let max_scroll = g.max_scroll;
+    let ctx = g.ctx.clone();
     // A pick whip held near the top or bottom of the layers (or past it) scrolls them, as in
     // After Effects.
     if ctx.data(|d| d.get_temp::(pick_whip_id())).is_some()
@@ -2102,1095 +2708,1453 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     app.ui.timeline.scroll_y = app.ui.timeline.scroll_y.clamp(0.0, max_scroll);
-    let snap_project = app.session.project.clone();
-    let snap_expr = app.session.expr.clone();
-    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
-    let lp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y))));
-    let gp = p.with_clip_rect(rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max)));
-    let mut y = rows_rect.min.y - app.ui.timeline.scroll_y;
-    let selected = app.session.state.selected_layers.clone();
-    let sel_keys = app.session.state.selected_keys.clone();
-    let mut ui_actions: Vec = Vec::new();
-    let idx_of = |id: LayerId| comp.index_of(id).unwrap_or(0);
-    let graph_on = app.ui.timeline.graph_editor;
-    super::graph::track_open(ui.ctx(), graph_on);
-    let full_clip = ui.clip_rect();
-    let left_clip = full_clip.intersect(Rect::from_min_max(pos2(rect.min.x, rows_rect.min.y), pos2(graph_x0 - 1.0, rows_rect.max.y)));
-    let right_clip = full_clip.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
-    // Registered before the rows so keys, bars and editors on top of it get the pointer first.
-    let empty_rect = if graph_on { Rect::NOTHING } else { Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max) };
-    let empty = ui.interact(empty_rect, egui::Id::unique("tl-graph-bg"), Sense::click_and_drag());
-    let outline_rect = Rect::from_min_max(rows_rect.min, pos2(graph_x0 - 1.0, rows_rect.max.y));
-    let outline_empty = ui.interact(outline_rect, egui::Id::unique("tl-layer-bg"), Sense::click_and_drag());
-    app.auto.add("timeline.layerMarquee", outline_rect, "Drag empty outline to select layers");
-    let mut hit_rows: Vec<(Rect, Row)> = vec![];
-    // The value fields of multi-dimension properties (per dimension), for the pick whip.
-    let mut dim_hits: Vec<(u64, Vec)> = vec![];
-    // Rows stripe by their index among the rows that are not expression editors, so adding or
-    // removing an expression leaves every other row's stripe alone; an editor takes the index
-    // (and so the stripe) of its property's row, the one just above.
-    let mut striped = 0usize;
-    for row in rows.iter() {
-        let ri = if matches!(row.kind, RowKind::Expr { .. }) {
-            striped.saturating_sub(1)
-        } else {
-            striped += 1;
-            striped - 1
-        };
-        // Outline widgets never spill into the time graph (narrow timelines).
-        ui.set_clip_rect(left_clip);
-        let hh = row_height(row, rh);
-        let r = Rect::from_min_size(pos2(rect.min.x, y), vec2(rect.width(), hh));
-        y += hh;
-        if r.max.y < rows_rect.min.y || r.min.y > rows_rect.max.y {
+}
+
+fn layer_row_background(g: &Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let r = rc.r;
+    let left = rc.left;
+    let is_sel = rc.is_sel;
+    lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt });
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { t.tl_graph_selected } else { t.tl_bg });
+    lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
+    gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
+}
+
+/// The A/V feature switches (video, audio, solo, lock).
+fn layer_av_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let sw = &layer.switches;
+    // Every layer has the Video switch (the eye), nulls, cameras and lights included,
+    // 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", 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));
+        if !applicable || !vis.av {
             continue;
         }
-        hit_rows.push((r, row.clone()));
-        let Some(layer) = comp.layer(row.layer) else { continue };
-        let is_sel = selected.contains(&layer.id);
-        let left = Rect::from_min_max(r.min, pos2(graph_x0 - 1.0, r.max.y));
-        let cy = r.min.y + rh / 2.0;
-        match &row.kind {
-            RowKind::Layer => {
-                // After Effects draws layer rows as one flat tone separated by dark hairlines
-                // (outline and time graph alike), not as alternating stripes.
-                let _ = ri;
-                lp.rect_filled(left, 0.0, if is_sel { t.row_selected } else { t.row_alt });
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, if is_sel { t.tl_graph_selected } else { t.tl_bg });
-                lp.line_segment([pos2(rect.min.x, r.max.y - 0.5), pos2(graph_x0, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
-                gp.line_segment([pos2(graph_x0, r.max.y - 0.5), pos2(r.max.x, r.max.y - 0.5)], Stroke::new(1.0, t.app_bg));
-                // A/V features.
-                let sw = &layer.switches;
-                // Every layer has the Video switch (the eye), nulls, cameras and lights included,
-                // 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", 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));
-                    if !applicable || !vis.av {
-                        continue;
-                    }
-                    lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
-                    layer_switch(
-                        ui,
-                        &t,
-                        r,
-                        br,
-                        icon,
-                        on,
-                        egui::Id::unique(("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.
-                if vis.label {
-                    let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
-                    lp.rect_filled(lr, 2.0, t.label(layer.label));
-                    let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
-                    app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
-                    // Click (or right-click) the swatch for the label menu (After Effects).
-                    let pop = egui::Id::unique(("label-pop", layer.id.0));
-                    if lresp.clicked() || lresp.secondary_clicked() {
-                        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": edited_layers(layer.id, &selected), "label": lab.name()})));
-                    }
-                }
-                if vis.num {
-                    lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", idx_of(layer.id)), Tokens::ui(11.5), t.text_dim);
-                }
-                layer_cells(app, ui, &lp, &cw, &comp, layer, r, cy, &mut actions);
-                // Row click → select; double-click → rename; drag → reorder. Registered before the
-                // twirl and the name field so those sit on top and get their clicks.
-                let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
-                let row_resp = ui.interact(
-                    Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)),
-                    egui::Id::unique(("row", layer.id.0)),
-                    Sense::click_and_drag(),
-                );
-                // Twirl + icon + name.
-                let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
-                let open = app.ui.timeline.open_layers.contains(&layer.id.0);
-                if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
-                    ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
-                }
-                app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
-                icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
-                // A guide layer carries a guide mark before its name (After Effects).
-                let name_x = if layer.switches.guide {
-                    let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0));
-                    icons::paint(&lp, mark, Icon::Grid, t.text_dim);
-                    app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer");
-                    cw.name + 50.0
-                } else {
-                    cw.name + 34.0
-                };
-                let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
-                let rename_id = egui::Id::unique("tl-rename");
-                let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
-                if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
-                    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
-                    let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
-                    app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
-                    if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
-                        // Just started: focus the field with the whole name selected.
-                        er.request_focus();
-                        widgets::select_all(&ctx, er.id, &buf);
-                    }
-                    if er.lost_focus() {
-                        if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
-                            actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
-                        }
-                        ctx.data_mut(|d| d.remove::<(u64, String)>(rename_id));
-                    } else {
-                        ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
-                    }
-                } else {
-                    // The selected layer's name sits in a light cell with dark text.
-                    let name_col = if is_sel { Color32::from_gray(0x16) } else { t.text };
-                    if is_sel {
-                        let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col);
-                        let cell = Rect::from_min_max(
-                            pos2(name_rect.min.x - 3.0, r.min.y + 1.0),
-                            pos2((name_rect.min.x + g.size().x + 4.0).max(cw.name_end - 4.0).min(cw.name_end - 2.0), r.max.y - 1.0),
-                        );
-                        lp.rect_filled(cell, 0.0, SELECTED_NAME_BG);
-                    }
-                    let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
-                    lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
-                }
-                // Locked layers can't be selected or moved (unlock them with the lock switch).
-                let locked = layer.switches.locked;
-                if row_resp.drag_started() && !locked {
-                    // The selected layers move together; an unselected row moves alone.
-                    let moving: Vec =
-                        if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
-                    ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving));
-                }
-                if row_resp.drag_stopped() {
-                    ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
-                }
-                app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
-                if row_resp.clicked() && !locked {
-                    let m = ui.input(|i| i.modifiers);
-                    if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
-                        actions.push(("layer.select".into(), json!({"layers": layers})));
-                    } else {
-                        actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
-                        set_anchor(&ctx, cid, layer.id);
-                    }
-                }
-                if row_resp.double_clicked() {
-                    match double_click_action(layer) {
-                        Some(a) => actions.push(a),
-                        None => start_rename(&ctx, layer.id.0, &layer.name),
-                    }
-                }
-                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", 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",
-                        switch_applies(layer, "frameBlend", &app.session.project),
-                    ),
-                    (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 {
-                        continue;
-                    }
-                    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::unique(("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.
-                if app.ui.timeline.show_modes && layer.source.is_av() {
-                    let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
-                    let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
-                    let pop = egui::Id::unique(("mode-pop", layer.id.0));
-                    if mresp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let opts: Vec = BlendMode::ALL
-                        .iter()
-                        .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": 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::unique(("pt", layer.id.0))).clicked() {
-                        // 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));
-                    let label = match layer.track_matte {
-                        Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", idx_of(l.id), l.name)).unwrap_or("None".into()),
-                        None => "No Track Matte".into(),
-                    };
-                    // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
-                    let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
-                    let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
-                    let pop = egui::Id::unique(("trk-pop", layer.id.0));
-                    if tresp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    // The menu lists every layer: built only while it is open (per visible row
-                    // it made a big comp's timeline quadratic).
-                    let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
-                    let mut candidates: Vec<&Layer> = vec![];
-                    if widgets::popup_is_open(ui, pop) {
-                        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
-                            opts.push(format!("{}. {}", i + 1, l.name));
-                            candidates.push(l);
-                        }
-                        opts.push("-".into());
-                        for k in MatteKind::ALL {
-                            opts.push(k.label().into());
-                        }
-                    }
-                    let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
-                        let ids: Vec = candidates.iter().map(|l| l.id).collect();
-                        if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) {
-                            actions.push(a);
-                        }
-                    }
-                    if layer.track_matte.is_some() {
-                        let kr = Rect::from_center_size(pos2(mr.max.x - 26.0, cy), vec2(10.0, 10.0));
-                        let _ = kr;
-                    }
-                }
-                // Parent.
-                if vis.parent {
-                    pick_whip(
-                        app,
-                        ui,
-                        &lp,
-                        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
-                        (WHIP_PARENT, layer.id.0, 0),
-                        &format!("timeline.layer.{}.pickWhip", layer.id.0),
-                        "Parent pick whip",
-                        crate::i18n::tr("Parent pick whip: drag onto a layer"),
-                    );
-                    let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
-                    let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", idx_of(l.id), l.name)).unwrap_or_else(|| "None".into());
-                    let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
-                    app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
-                    let pop = egui::Id::unique(("par-pop", layer.id.0));
-                    if presp.clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let mut cands: Vec<&Layer> = vec![];
-                    let mut popts = vec!["None".to_string(), "-".to_string()];
-                    if widgets::popup_is_open(ui, pop) {
-                        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
-                            popts.push(format!("{}. {}", i + 1, l.name));
-                            cands.push(l);
-                        }
-                    }
-                    if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
-                        let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
-                        actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
-                    }
-                }
-                // Layer bar.
-                ui.set_clip_rect(right_clip);
-                if !graph_on {
-                    let x_in = tm.x(layer.in_point.seconds());
-                    let x_out = tm.x(layer.out_point.seconds());
-                    let bar = Rect::from_min_max(pos2(x_in, r.min.y + 1.0), pos2(x_out, r.max.y - 1.0));
-                    let lc = t.label(layer.label);
-                    let fill = bar_color(lc, is_sel);
-                    gp.rect_filled(bar, 0.0, fill);
-                    if is_sel {
-                        gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside);
-                    }
-                    // Source extent (e.g. trimmed footage) shown faint.
-                    if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source
-                        && let Some(d) = layer.source.item().and_then(|i| app.session.project.item(i)).and_then(|i| i.duration())
-                    {
-                        let xs = tm.x(layer.start_time.seconds());
-                        let xe = tm.x(layer.comp_time(d).seconds());
-                        let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
-                        gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
-                        // The bar past the source's end (its last frame held) or before its start
-                        // is striped, so the source's own span shows (#290). Time remapping
-                        // decides what shows there itself.
-                        if layer.props.get("timeRemap").is_none() {
-                            for (side, part) in [
-                                ("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
-                                ("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
-                            ] {
-                                if part.width() >= 1.0 {
-                                    stripes(&gp, part, lc);
-                                    app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
-                                }
-                            }
-                        }
-                        // Dragging the source bar outside the in/out span slips the source.
-                        for (side, gr) in [
-                            ("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))),
-                            ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max)),
-                        ] {
-                            if gr.width() < 2.0 {
-                                continue;
-                            }
-                            let gresp = ui
-                                .interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag())
-                                .on_hover_text(crate::i18n::tr("Drag to slip the source"));
-                            // Outside the bar it is empty track area: a plain click deselects.
-                            if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
-                                actions.push(("layer.select".into(), json!({"layers": []})));
-                            }
-                            app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
-                            if gresp.hovered() || gresp.dragged() {
-                                ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
-                            }
-                            let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
-                            if gresp.dragged() {
-                                let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
-                                acc += gresp.drag_delta().x;
-                                let frames = ((acc as f64 / pps) / fd).trunc() as i64;
-                                if frames != 0 {
-                                    acc -= (frames as f64 * fd * pps) as f32;
-                                    actions.push((
-                                        "layer.slip".into(),
-                                        json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)}),
-                                    ));
-                                }
-                                ctx.data_mut(|d| d.insert_temp(acc_id, acc));
-                            }
-                            if gresp.drag_stopped() {
-                                ctx.data_mut(|d| d.remove::(acc_id));
-                                ui_actions.push(UiAct::EndMerge);
-                            }
-                        }
-                    }
-                    app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
-                    // Interactions: move body, trim edges.
-                    let (body_r, in_r, out_r) = trim_zones(bar);
-                    let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
-                    let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
-                    let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
-                    app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
-                    app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
-                    if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
-                        trim_cursor(&ctx);
-                    }
-                    if body.double_clicked()
-                        && let Some(a) = double_click_action(layer)
-                    {
-                        actions.push(a);
-                    }
-                    let shift = ui.input(|i| i.modifiers.shift);
-                    if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, &rows, &selected, layer.id)).flatten() {
-                        // Shift-click: the layers from the last one clicked to this one, as in
-                        // the layer outline.
-                        actions.push(("layer.select".into(), json!({"layers": layers})));
-                    } else {
-                        if (body.clicked() || body.drag_started()) && !is_sel {
-                            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
-                        }
-                        if (body.clicked() || body.drag_started()) && !shift {
-                            set_anchor(&ctx, cid, layer.id);
-                        }
-                    }
-                    layer_context_menu(&body, &comp, layer, &selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut actions);
-                    let drag_key = format!("bar-{}", layer.id.0);
-                    // Ctrl/Cmd+Alt dragging a selected bar: Quick Offset (#1012), chosen as the
-                    // drag starts and followed below the rows until the button is released.
-                    let mods = ui.input(|i| i.modifiers);
-                    if body.drag_started()
-                        && is_sel
-                        && mods.command
-                        && mods.alt
-                        && let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
-                        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
-                    {
-                        ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
-                    }
-                    let quick = ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id);
-                    for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
-                        if kind == "move" && quick {
-                            continue;
-                        }
-                        if resp.dragged() {
-                            let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
-                            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
-                            acc += resp.drag_delta().x;
-                            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
-                            if frames != 0 {
-                                acc -= (frames as f64 * fd * pps) as f32;
-                                let d = frames as f64 * fd;
-                                // Moving or trimming a selected layer acts on the whole
-                                // selection; each layer trims from its own in/out point.
-                                let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) };
-                                let params = match kind {
-                                    "move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
-                                    "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
-                                    _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
-                                };
-                                actions.push(("layer.timing".into(), params));
-                            }
-                            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
-                        }
-                        if resp.drag_stopped() {
-                            ctx.data_mut(|d| d.remove::(egui::Id::unique(("bar-acc", layer.id.0, kind))));
-                            ui_actions.push(UiAct::EndMerge);
-                        }
-                    }
-                    // Markers on the layer (drag, Alt-drag duration, Cmd-click delete, dialog).
-                    let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
-                    super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
-                }
+        lp.rect_filled(br.shrink(0.5), 1.0, t.switch_well);
+        layer_switch(
+            ui,
+            &t,
+            &SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("av", layer.id.0, i)), layer: layer.id.0, name, others: true },
+            &comp,
+            &app.session.project,
+            selected,
+            &mut *actions,
+        );
+        app.auto.add(&format!("timeline.layer.{}.{name}", layer.id.0), br, name);
+    }
+}
+
+/// The label swatch and its colour menu.
+fn layer_label_swatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    // Label swatch.
+    if vis.label {
+        let lr = Rect::from_center_size(pos2(cw.label + 10.0, cy), vec2(12.0, 12.0));
+        lp.rect_filled(lr, 2.0, t.label(layer.label));
+        let lresp = ui.interact(lr, egui::Id::unique(("label", layer.id.0)), Sense::click());
+        app.auto.add(&format!("timeline.layer.{}.label", layer.id.0), lr, layer.label.name());
+        // Click (or right-click) the swatch for the label menu (After Effects).
+        let pop = egui::Id::unique(("label-pop", layer.id.0));
+        if lresp.clicked() || lresp.secondary_clicked() {
+            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": edited_layers(layer.id, selected), "label": lab.name()})));
+        }
+    }
+}
+
+fn layer_number_and_cells(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    if vis.num {
+        lp.text(pos2(cw.num + 13.0, cy), Align2::CENTER_CENTER, format!("{}", comp.index_of(layer.id).unwrap_or(0)), Tokens::ui(11.5), t.text_dim);
+    }
+    layer_cells(app, ui, &lp, cw, &comp, rc, &mut *actions);
+}
+
+/// The row's click target, twirl, icon and guide mark; returns the row response and the name's x.
+fn layer_twirl_and_icon(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) -> (egui::Response, f32) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    // Row click → select; double-click → rename; drag → reorder. Registered before the
+    // twirl and the name field so those sit on top and get their clicks.
+    let row_x0 = if vis.num { cw.num } else { cw.name + 16.0 };
+    let row_resp =
+        ui.interact(Rect::from_min_max(pos2(row_x0, r.min.y), pos2(cw.name_end, r.max.y)), egui::Id::unique(("row", layer.id.0)), Sense::click_and_drag());
+    // Twirl + icon + name.
+    let tw = Rect::from_center_size(pos2(cw.name + 8.0, cy), vec2(14.0, 14.0));
+    let open = app.ui.timeline.open_layers.contains(&layer.id.0);
+    if widgets::twirl(ui, tw, open, egui::Id::unique(("twirl", layer.id.0)), &t).clicked() {
+        ui_actions.push(UiAct::ToggleLayer(layer.id.0, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
+    }
+    app.auto.add(&format!("timeline.layer.{}.twirl", layer.id.0), tw, "twirl");
+    icons::paint(&lp, Rect::from_center_size(pos2(cw.name + 24.0, cy), vec2(13.0, 13.0)), layer_icon(layer, &app.session.project), t.text_dim);
+    // A guide layer carries a guide mark before its name (After Effects).
+    let name_x = if layer.switches.guide {
+        let mark = Rect::from_center_size(pos2(cw.name + 40.0, cy), vec2(11.0, 11.0));
+        icons::paint(&lp, mark, Icon::Grid, t.text_dim);
+        app.auto.add(&format!("timeline.layer.{}.guide", layer.id.0), mark, "Guide Layer");
+        cw.name + 50.0
+    } else {
+        cw.name + 34.0
+    };
+    (row_resp, name_x)
+}
+
+/// The layer name: the rename field or the (selected) name cell.
+fn layer_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, name_x: f32) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let is_sel = rc.is_sel;
+    let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.name_end - 4.0, r.max.y));
+    let rename_id = egui::Id::unique("tl-rename");
+    let renaming: Option<(u64, String)> = ctx.data(|d| d.get_temp(rename_id));
+    if let Some((rl, mut buf)) = renaming.filter(|(rl, _)| *rl == layer.id.0) {
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(name_rect.shrink2(vec2(0.0, 2.0))));
+        let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(name_rect.width()));
+        app.auto.add(&format!("timeline.layer.{rl}.nameEdit"), er.rect, &buf);
+        if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
+            // Just started: focus the field with the whole name selected.
+            er.request_focus();
+            widgets::select_all(&ctx, er.id, &buf);
+        }
+        if er.lost_focus() {
+            if !ui.input(|i| i.key_pressed(egui::Key::Escape)) {
+                actions.push(("layer.rename".into(), json!({"layer": rl, "name": buf})));
             }
-            RowKind::Group { uid, name, open, has_children, fx, eye } => {
-                // The row draws an effect's name in the interface language (#821); `name` stays
-                // the property tree's, which the automation labels below keep. Any other group
-                // (a mask, a shape group, a layer style) draws its own name.
-                let group = layer.props.find_group(*uid);
-                let shown = match group.and_then(|g| match &g.kind {
-                    GroupKind::Effect { effect } => Some(effect.as_str()),
-                    _ => None,
-                }) {
-                    Some(effect) => crate::i18n::effects::group(effect, name),
-                    None => name.clone(),
-                };
-                // Selected groups (an effect, a mask, a shape group…) highlight like selected
-                // property rows.
-                let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_group {
-                        t.row_selected
-                    } else if ri % 2 == 0 {
-                        t.row
-                    } else {
-                        t.row_alt
-                    },
-                );
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
-                // An effect's colour label (26.5): the same bar the Effect Controls header wears,
-                // on the row's left edge.
-                if let Some(lab) =
-                    group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
-                {
-                    let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
-                    let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
-                    lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
-                    app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
-                }
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                if *has_children {
-                    let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
-                    if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
-                        // Alt/Cmd-click twirls the whole tree under the group (as on a layer).
-                        ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name);
-                }
-                if let Some(en) = fx {
-                    let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
-                    if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
-                        actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
-                }
-                if let Some(en) = eye {
-                    // Layer style eye switch in the A/V column, like AE.
-                    let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
-                    lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
-                    if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() {
-                        actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
-                    }
-                    app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
-                }
-                let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id()));
-                if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) {
-                    let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0));
-                    let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field));
-                    let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width()));
-                    app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf);
-                    if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
-                        er.request_focus();
-                        widgets::select_all(&ctx, er.id, &buf);
-                    }
-                    if er.lost_focus() {
-                        if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name {
-                            actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf})));
-                        }
-                        ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id()));
-                    } else {
-                        ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf)));
-                    }
-                } else {
-                    lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, &shown, Tokens::ui(12.0), t.text);
-                }
-                text_anim_popups(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                shape_add_popup(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                if *uid == GEOM_BIT | layer.id.0 {
-                    let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
-                    let resp = ui
-                        .interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
-                        .on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
-                    if resp.hovered() {
-                        ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
-                    }
-                    app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
-                    if resp.clicked() {
-                        ui_actions.push(UiAct::ChangeRenderer);
-                    }
-                }
-                dash_buttons(app, ui, &lp, layer, *uid, cw.switches, cy, &mut actions);
-                // Mask mode + inverted inline.
-                if let Some(g) = layer.props.find_group(*uid)
-                    && let GroupKind::Mask { mode, inverted, .. } = g.kind
-                {
-                    let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
-                    let pop = egui::Id::unique(("maskmode", uid));
-                    if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
-                        widgets::open_popup(ui, pop);
-                    }
-                    let opts: Vec = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
-                    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
-                        actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
-                    }
-                    let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
-                    if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
-                        actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
-                    }
-                    lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
-                }
-                let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
-                let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
-                app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
-                if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
-                    actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
-                }
-                if gr.double_clicked() {
-                    if fx.is_some() {
-                        // An effect's name: open it in Effect Controls (as in After Effects).
-                        actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
-                        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
-                        actions.push(("window.panel".into(), json!({"panel": "EffectControls"})));
-                    } else {
-                        ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false));
-                    }
-                }
+            ctx.data_mut(|d| d.remove::<(u64, String)>(rename_id));
+        } else {
+            ctx.data_mut(|d| d.insert_temp(rename_id, (rl, buf)));
+        }
+    } else {
+        // The selected layer's name sits in a light cell with dark text.
+        let name_col = if is_sel { Color32::from_gray(0x16) } else { t.text };
+        if is_sel {
+            let g = lp.layout_no_wrap(display_name(app, layer), Tokens::ui(12.0), name_col);
+            let cell = Rect::from_min_max(
+                pos2(name_rect.min.x - 3.0, r.min.y + 1.0),
+                pos2((name_rect.min.x + g.size().x + 4.0).max(cw.name_end - 4.0).min(cw.name_end - 2.0), r.max.y - 1.0),
+            );
+            lp.rect_filled(cell, 0.0, SELECTED_NAME_BG);
+        }
+        let lpn = lp.with_clip_rect(name_rect.intersect(lp.clip_rect()));
+        lpn.text(pos2(name_rect.min.x, cy), Align2::LEFT_CENTER, display_name(app, layer), Tokens::ui(12.0), name_col);
+    }
+}
+
+/// Row click, drag, double-click and context menu.
+fn layer_row_actions(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, row_resp: &egui::Response) {
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let left = rc.left;
+    let is_sel = rc.is_sel;
+    // Locked layers can't be selected or moved (unlock them with the lock switch).
+    let locked = layer.switches.locked;
+    if row_resp.drag_started() && !locked {
+        // The selected layers move together; an unselected row moves alone.
+        let moving: Vec = if is_sel { comp.layers.iter().filter(|l| selected.contains(&l.id)).map(|l| l.id.0).collect() } else { vec![layer.id.0] };
+        ctx.data_mut(|d| d.insert_temp(layer_drag_id(), moving));
+    }
+    if row_resp.drag_stopped() {
+        ctx.data_mut(|d| d.insert_temp(layer_drop_id(), true));
+    }
+    app.auto.add(&format!("timeline.layer.{}.row", layer.id.0), left, &layer.name);
+    if row_resp.clicked() && !locked {
+        let m = ui.input(|i| i.modifiers);
+        if let Some(layers) = m.shift.then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
+            actions.push(("layer.select".into(), json!({"layers": layers})));
+        } else {
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "toggle": m.command})));
+            set_anchor(&ctx, cid, layer.id);
+        }
+    }
+    if row_resp.double_clicked() {
+        match double_click_action(layer) {
+            Some(a) => actions.push(a),
+            None => start_rename(&ctx, layer.id.0, &layer.name),
+        }
+    }
+    layer_context_menu(row_resp, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
+}
+
+/// The eight layer switches.
+fn layer_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let vis = g.tl.vis;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let sw = &layer.switches;
+    let sws: [(Icon, bool, &str, bool); 8] = [
+        (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",
+            switch_applies(layer, "frameBlend", &app.session.project),
+        ),
+        (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 {
+            continue;
+        }
+        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,
+            &SwitchSpec { row: r, br, icon, on, id: egui::Id::unique(("sw", layer.id.0, i)), layer: layer.id.0, name, others: false },
+            &comp,
+            &app.session.project,
+            selected,
+            &mut *actions,
+        );
+        app.auto.add(&format!("timeline.layer.{}.switch.{name}", layer.id.0), br, name);
+    }
+}
+
+/// The blend mode dropdown and Preserve Transparency.
+fn layer_mode_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let mr = Rect::from_min_size(pos2(cw.mode + 2.0, cy - 9.0), vec2(80.0, 18.0));
+    let mresp = widgets::dropdown(ui, mr, layer.blend_mode.label(), &t, egui::Id::unique(("mode", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.mode", layer.id.0), mr, "Mode");
+    let pop = egui::Id::unique(("mode-pop", layer.id.0));
+    if mresp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    let opts: Vec =
+        BlendMode::ALL.iter().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": 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::unique(("pt", layer.id.0))).clicked() {
+        // 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
+            }),
+        ));
+    }
+}
+
+/// The track matte picker.
+fn layer_matte_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    // Track matte: layer picker + kind.
+    let mr = Rect::from_min_size(pos2(cw.trkmat + 2.0, cy - 9.0), vec2(104.0, 18.0));
+    let label = match layer.track_matte {
+        Some(m) => comp.layer(m.layer).map(|l| format!("{} {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or("None".into()),
+        None => "No Track Matte".into(),
+    };
+    // The cell is too narrow for the mode as well: it is in the tooltip and ticked in the menu.
+    let tip = layer.track_matte.map(|m| format!("{label} · {}", crate::i18n::tr(m.kind.label())));
+    let tresp = widgets::dropdown_tip(ui, mr, &label, tip.as_deref(), &t, egui::Id::unique(("trkmat", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.trackMatte", layer.id.0), mr, "Track Matte");
+    let pop = egui::Id::unique(("trk-pop", layer.id.0));
+    if tresp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    // The menu lists every layer: built only while it is open (per visible row
+    // it made a big comp's timeline quadratic).
+    let mut opts = vec!["No Track Matte".to_string(), "-".to_string()];
+    let mut candidates: Vec<&Layer> = vec![];
+    if widgets::popup_is_open(ui, pop) {
+        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id && l.source.is_av()) {
+            opts.push(format!("{}. {}", i + 1, l.name));
+            candidates.push(l);
+        }
+        opts.push("-".into());
+        for k in MatteKind::ALL {
+            opts.push(k.label().into());
+        }
+    }
+    let current = layer.track_matte.and_then(|m| opts.iter().position(|o| o == m.kind.label()));
+    if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, current) {
+        let ids: Vec = candidates.iter().map(|l| l.id).collect();
+        if let Some(a) = opts.get(i).and_then(|o| track_matte_pick(layer, &ids, i, o)) {
+            actions.push(a);
+        }
+    }
+    if layer.track_matte.is_some() {
+        let kr = Rect::from_center_size(pos2(mr.max.x - 26.0, cy), vec2(10.0, 10.0));
+        let _ = kr;
+    }
+}
+
+/// The parent pick whip and dropdown.
+fn layer_parent_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let comp = g.tl.comp.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    pick_whip(
+        app,
+        ui,
+        &lp,
+        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
+        (WHIP_PARENT, layer.id.0, 0),
+        WhipText {
+            auto: &format!("timeline.layer.{}.pickWhip", layer.id.0),
+            label: "Parent pick whip",
+            hint: crate::i18n::tr("Parent pick whip: drag onto a layer"),
+        },
+    );
+    let pr_rect = Rect::from_min_size(pos2(cw.parent + 20.0, cy - 9.0), vec2(94.0, 18.0));
+    let plabel = layer.parent.and_then(|p| comp.layer(p)).map(|l| format!("{}. {}", comp.index_of(l.id).unwrap_or(0), l.name)).unwrap_or_else(|| "None".into());
+    let presp = widgets::dropdown(ui, pr_rect, &plabel, &t, egui::Id::unique(("parent", layer.id.0)));
+    app.auto.add(&format!("timeline.layer.{}.parent", layer.id.0), pr_rect, "Parent");
+    let pop = egui::Id::unique(("par-pop", layer.id.0));
+    if presp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    let mut cands: Vec<&Layer> = vec![];
+    let mut popts = vec!["None".to_string(), "-".to_string()];
+    if widgets::popup_is_open(ui, pop) {
+        for (i, l) in comp.layers.iter().enumerate().filter(|(_, l)| l.id != layer.id) {
+            popts.push(format!("{}. {}", i + 1, l.name));
+            cands.push(l);
+        }
+    }
+    if let Some(i) = widgets::popup_menu(ui, pop, pr_rect.left_bottom(), &popts, None) {
+        let par = if i == 0 { serde_json::Value::Null } else { json!(cands[i - 2].id.0) };
+        actions.push(("layer.setParent".into(), json!({"layers": [layer.id.0], "parent": par})));
+    }
+}
+
+/// Paints the layer bar; returns it and its label colour.
+fn layer_bar_paint(g: &Rw, rc: &RowCtx<'_>) -> (Rect, Color32) {
+    let t = g.tl.t;
+    let tm = g.tl.tm;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let is_sel = rc.is_sel;
+    let x_in = tm.x(layer.in_point.seconds());
+    let x_out = tm.x(layer.out_point.seconds());
+    let bar = Rect::from_min_max(pos2(x_in, r.min.y + 1.0), pos2(x_out, r.max.y - 1.0));
+    let lc = t.label(layer.label);
+    let fill = bar_color(lc, is_sel);
+    gp.rect_filled(bar, 0.0, fill);
+    if is_sel {
+        gp.rect_stroke(bar, 0.0, Stroke::new(1.0, Color32::from_white_alpha(90)), StrokeKind::Inside);
+    }
+    (bar, lc)
+}
+
+/// Dragging the source bar outside the in/out span slips the source.
+fn slip_source_handles(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ghost: Rect, bar: Rect) {
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    // Dragging the source bar outside the in/out span slips the source.
+    for (side, gr) in [("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max))] {
+        if gr.width() < 2.0 {
+            continue;
+        }
+        let gresp =
+            ui.interact(gr, egui::Id::unique(("ghost", side, layer.id.0)), Sense::click_and_drag()).on_hover_text(crate::i18n::tr("Drag to slip the source"));
+        // Outside the bar it is empty track area: a plain click deselects.
+        if gresp.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
+            actions.push(("layer.select".into(), json!({"layers": []})));
+        }
+        app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip");
+        if gresp.hovered() || gresp.dragged() {
+            ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal);
+        }
+        let acc_id = egui::Id::unique(("slip-acc", layer.id.0));
+        if gresp.dragged() {
+            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
+            acc += gresp.drag_delta().x;
+            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
+            if frames != 0 {
+                acc -= (frames as f64 * fd * pps) as f32;
+                actions.push(("layer.slip".into(), json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)})));
             }
-            RowKind::Waveform { item } => {
-                lp.rect_filled(left, 0.0, if ri % 2 == 0 { t.row } else { t.row_alt });
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text);
-                let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
-                gp.rect_filled(wr, 0.0, t.tl_bg);
-                app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
-                match super::waveform::summary(app, &ctx, ItemId(*item)) {
-                    Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
-                    None => {
-                        gp.text(
-                            pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
-                            Align2::LEFT_CENTER,
-                            "Building waveform…",
-                            Tokens::ui(11.0),
-                            t.text_faint,
-                        );
-                    }
-                }
-            }
-            RowKind::Expr { uid, lines } => {
-                let Some(prop) = layer.props.find(*uid) else { continue };
-                let Some(ex) = prop.expr.clone() else { continue };
-                let (buf_id, edit_id) = (expr_buf_id(*uid), egui::Id::unique(("expr-edit", uid)));
-                // The same background as its property's row, selection included.
-                let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_prop {
-                        t.row_selected
-                    } else if ri % 2 == 0 {
-                        t.row
-                    } else {
-                        t.row_alt
-                    },
-                );
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
-                lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
-                // "=" enable switch and the property pick whip (AE's expression controls).
-                let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
-                let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
-                lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
-                let expr_col = t.expr_text;
-                lp.text(
-                    en_r.center(),
-                    Align2::CENTER_CENTER,
-                    if ex.enabled { "=" } else { "≠" },
-                    Tokens::semibold(13.0),
-                    if ex.enabled { expr_col } else { t.text_dim },
-                );
-                app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
-                if en.clicked() {
-                    actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
-                }
-                let pw = pick_whip(
-                    app,
-                    ui,
-                    &lp,
-                    Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)),
-                    (WHIP_EXPR, layer.id.0, *uid),
-                    &format!("timeline.prop.{uid}.pickWhip"),
-                    "Expression pick whip",
-                    crate::i18n::tr("Expression pick whip: drag onto a property"),
-                );
-                if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
-                    // Pressed while the expression is being edited (its text is buffered while
-                    // the editor has the focus): the reference will go in at the cursor,
-                    // replacing the selection (#284).
-                    let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| {
-                        let n = buf.chars().count();
-                        let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
-                            .and_then(|st| st.cursor.char_range())
-                            .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
-                        (buf, sel)
-                    });
-                    ctx.data_mut(|d| {
-                        if let Some(into) = into {
-                            d.insert_temp(whip_into_id(*uid), into);
-                        } else {
-                            d.remove::(whip_into_id(*uid));
-                        }
-                    });
-                }
-                // Syntax errors: warning in the outline (the expression is disabled).
-                if let Some(check) = app.session.expr_check
-                    && let Err(e) = check(&ex.text)
-                {
-                    let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
-                    lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
-                    let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
-                }
-                // ⓕ: the Expression Language menu inserts at the cursor.
-                let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
-                let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
-                lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
-                lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
-                app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
-                let mut picked: Option<&'static str> = None;
-                egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
-                    for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
-                        ui.menu_button(cat, |ui| {
-                            widgets::menu_scroll(ui, false, |ui| {
-                                for (label, text) in items {
-                                    if ui.button(label).clicked() {
-                                        picked = Some(text);
-                                        ui.close();
-                                    }
-                                }
-                            })
-                        });
-                    }
-                });
-                if let Some(text) = picked {
-                    let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
-                    let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
-                        .and_then(|st| st.cursor.char_range())
-                        .map(|r| r.primary.index.0)
-                        .unwrap_or(cur.chars().count())
-                        .min(cur.chars().count());
-                    let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
-                    let mut next = cur.clone();
-                    next.insert_str(byte, text);
-                    actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
-                    ctx.data_mut(|d| d.remove::(buf_id));
-                }
-                // The editor in the time-graph area.
-                ui.set_clip_rect(right_clip);
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
-                let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
-                let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
-                if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) {
-                    // Asked for once (a pick whip put a reference in, or an Alt-click added the
-                    // expression): editing starts in the expression as it is now.
-                    ctx.data_mut(|d| d.remove::(expr_focus_id()));
-                    buf = ex.text.clone();
-                    let n = buf.chars().count();
-                    let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
-                    let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
-                    st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
-                    st.store(&ctx, edit_id);
-                    ctx.memory_mut(|m| m.request_focus(edit_id));
-                }
-                // Settings ▸ Scripting & Expressions ▸ Expressions Editor.
-                let sp = app.session.prefs.scripting.clone();
-                let resp = super::expr_editor::editor(ui, edit_id, &mut buf, er, &sp, if ex.enabled { expr_col } else { t.text_dim }, *lines, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
-                // Its bottom edge drags to set its height in whole lines, as in After Effects.
-                let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
-                let eresp = ui.interact(edge, egui::Id::unique(("expr-edge", uid)), Sense::drag());
-                app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
-                if eresp.hovered() || eresp.dragged() {
-                    ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
-                }
-                if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
-                    let n = ((py - r.min.y) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
-                    app.ui.timeline.expr_lines.insert(*uid, n as usize);
-                }
-                let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
-                if commit {
-                    resp.surrender_focus();
-                }
-                if resp.has_focus() && !commit {
-                    ctx.data_mut(|d| d.insert_temp(buf_id, buf));
-                } else {
-                    if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
-                        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
-                    }
-                    ctx.data_mut(|d| d.remove::(buf_id));
-                }
-            }
-            RowKind::Prop { uid } => {
-                let Some(prop) = layer.props.find(*uid) else { continue };
-                app.auto.add(&format!("timeline.prop.{uid}.row"), r, &prop.name);
-                let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
-                lp.rect_filled(
-                    left,
-                    0.0,
-                    if sel_prop {
-                        t.row_selected
-                    } else if ri % 2 == 0 {
-                        t.row
-                    } else {
-                        t.row_alt
-                    },
-                );
-                gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
-                let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
-                // Keyframe navigator (in the A/V column, like AE).
-                // (in the Keys column when it is shown).
-                if prop.is_animated() && (vis.keys || vis.av) {
-                    let lt = layer.layer_time(time);
-                    let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some();
-                    let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 };
-                    let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0));
-                    let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0));
-                    let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0));
-                    icons::paint(&lp, prev.shrink(2.0), Icon::ChevronLeft, t.text_dim);
-                    icons::paint(&lp, next.shrink(2.0), Icon::ChevronRight, t.text_dim);
-                    let kc = if at_key { t.keyframe_selected } else { Color32::TRANSPARENT };
-                    lp.add(egui::Shape::convex_polygon(
-                        vec![mid.center() + vec2(0.0, -5.0), mid.center() + vec2(5.0, 0.0), mid.center() + vec2(0.0, 5.0), mid.center() + vec2(-5.0, 0.0)],
-                        kc,
-                        Stroke::new(1.0, t.text_dim),
-                    ));
-                    if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
-                        && let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
-                    {
-                        ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
-                    }
-                    if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
-                        && let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
-                    {
-                        ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
-                    }
-                    if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
-                        actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
-                    }
-                    app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
-                }
-                // Stopwatch: Alt+click adds an expression; a press toggles the animation, and
-                // dragging on over other stopwatches gives them the same state (#284).
-                if !prop.static_only {
-                    let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
-                    let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
-                    let col = if prop.is_animated() {
-                        t.hot_text
-                    } else if resp.hovered() {
-                        t.text
-                    } else {
-                        t.text_dim
-                    };
-                    icons::paint(&lp, swr, Icon::Stopwatch, col);
-                    app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
-                    if ui.input(|i| i.modifiers.alt) {
-                        if resp.clicked() {
-                            actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
-                            if prop.expr.is_none() {
-                                // Adding one: its editor opens, comes into view and takes the
-                                // keyboard with the new expression selected (#1072).
-                                app.ui.timeline.expr_closed.remove(uid);
-                                focus_expression(&ctx, *uid, [usize::MAX, 0]);
-                            } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
-                                // Removing it: nothing is left to take the keyboard.
-                                ctx.data_mut(|d| d.remove::(expr_focus_id()));
-                            }
-                        }
-                    } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
-                        actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
-                    } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
-                        // Keyboard or accessibility activation.
-                        actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
-                    }
-                }
-                let name_x = indent + 12.0;
-                // 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
-                let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
-                let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
-                let pname = if prop.has_expression() { format!("{base}  =") } else { base };
-                lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
-                let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
-                let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
-                app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
-                if name_resp.drag_started() {
-                    // Drop on the Essential Graphics panel to expose the property.
-                    egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
-                }
-                if name_resp.dragged() {
-                    egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer)
-                        .show(|ui| ui.label(&prop.name));
-                }
-                if name_resp.clicked() {
-                    actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
-                }
-                crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut actions));
-                // Value editors.
-                let value = ectx.value(layer, prop);
-                let vx = (cw.switches + 4.0).max(name_x + 120.0);
-                let dims = value_editor(app, ui, &lp, layer, prop, &value, pos2(vx, cy), &mut actions);
-                if !dims.is_empty() {
-                    dim_hits.push((*uid, dims));
-                }
-                // The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on
-                // a property it links this one to it, adding the expression if there is none.
-                if vis.parent && !prop.static_only {
-                    pick_whip(
-                        app,
-                        ui,
-                        &lp,
-                        Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
-                        (WHIP_PROP, layer.id.0, *uid),
-                        &format!("timeline.prop.{uid}.propertyPickWhip"),
-                        "Property pick whip",
-                        crate::i18n::tr("Property pick whip: drag onto a property"),
-                    );
-                }
-                ui.set_clip_rect(right_clip);
-                // Expression text row hint.
-                if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
-                    gp.text(
-                        pos2(graph_x0 + 8.0, cy),
-                        Align2::LEFT_CENTER,
-                        format!("= {}", e.text.lines().next().unwrap_or("")),
-                        Tokens::mono(11.0),
-                        t.expr_text,
-                    );
-                }
-                // Keyframes.
-                if !graph_on {
-                    for k in &prop.keys {
-                        let ct = layer.comp_time(k.time);
-                        let x = tm.x(ct.seconds());
-                        if x < graph_x0 - 8.0 || x > rect.max.x + 8.0 {
-                            continue;
-                        }
-                        let kref = effectcraft_engine::KeyRef { layer: layer.id, prop: *uid, time: k.time };
-                        let ks = sel_keys.contains(&kref);
-                        let icon = k.icon();
-                        let c = pos2(x, cy);
-                        // Labelled keys take their label colour (brighter when selected).
-                        let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
-                            Some(l) => {
-                                let c = t.label(*l);
-                                if ks { c } else { c.gamma_multiply(0.75) }
-                            }
-                            None if ks => t.keyframe_selected,
-                            None => t.keyframe,
-                        };
-                        if k.roving {
-                            // Roving keys: a small dot (their time follows their neighbours).
-                            gp.circle_filled(c, 2.5, kcol);
-                        } else {
-                            icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
-                        }
-                        let kr = Rect::from_center_size(c, vec2(12.0, 14.0));
-                        let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
-                        app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
-                        // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
-                        app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
-                        let mods = ui.input(|i| i.modifiers);
-                        let this_key = json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}], "selectProperties": true});
-                        if kresp.clicked() && mods.command {
-                            // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
-                            actions.push(("keys.select".into(), this_key.clone()));
-                            if mods.alt {
-                                actions.push(("keys.toggleHold".into(), json!({})));
-                            } else {
-                                let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
-                                actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
-                            }
-                        } else if kresp.clicked() && mods.shift {
-                            // Shift+click: in or out of the selection.
-                            let mut p = this_key.clone();
-                            p["toggle"] = json!(true);
-                            actions.push(("keys.select".into(), p));
-                        } else if (kresp.clicked() || kresp.drag_started()) && !ks {
-                            let mut p = this_key.clone();
-                            p["add"] = json!(mods.shift);
-                            actions.push(("keys.select".into(), p));
-                        }
-                        if kresp.double_clicked() {
-                            // Edit the key's value (numeric properties), as After Effects does.
-                            if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
-                                actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
-                            } else {
-                                ui_actions.push(UiAct::SetTime(ct));
-                            }
-                        }
-                        crate::widgets::context_menu(&kresp, |ui| {
-                            for (lbl, cmd, params) in [
-                                ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
-                                ("-", "", json!({})),
-                                ("Copy", "keys.copy", json!({})),
-                                ("Paste", "keys.paste", json!({})),
-                                ("-", "", json!({})),
-                                ("Select Equal Keyframes", "keys.selectEqual", json!({})),
-                                ("Select Previous Keyframes", "keys.selectPrevious", json!({})),
-                                ("Select Following Keyframes", "keys.selectFollowing", json!({})),
-                                ("-", "", json!({})),
-                                ("Keyframe Interpolation…", "keys.interpolation", json!({})),
-                                ("Keyframe Velocity…", "keys.velocity", json!({})),
-                                ("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
-                                ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
-                                ("-", "", json!({})),
-                                ("Easy Ease", "keys.easyEase", json!({})),
-                                ("Easy Ease In", "keys.easyEaseIn", json!({})),
-                                ("Easy Ease Out", "keys.easyEaseOut", json!({})),
-                                ("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
-                                ("-", "", json!({})),
-                                ("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
-                                ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
-                                ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
-                                ("Select All Keyframes", "keys.selectAll", json!({})),
-                                ("Delete", "keys.delete", json!({})),
-                            ] {
-                                if lbl == "Select All Keyframes" {
-                                    // Keyframe colour labels.
-                                    ui.menu_button(crate::i18n::tr("Label"), |ui| {
-                                        for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
-                                            if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
-                                                if !ks {
-                                                    actions.push((
-                                                        "keys.select".into(),
-                                                        json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]}),
-                                                    ));
-                                                }
-                                                actions.push(("keys.setLabel".into(), json!({"label": i})));
-                                                ui.close();
-                                            }
-                                        }
-                                    });
-                                }
-                                if lbl == "-" {
-                                    ui.separator();
-                                    continue;
-                                }
-                                if lbl == "Rove Across Time" && !prop.spatial {
-                                    continue;
-                                }
-                                if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
-                                    continue;
-                                }
-                                if ui.button(lbl).clicked() {
-                                    if !ks {
-                                        actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
-                                    }
-                                    actions.push((cmd.into(), params));
-                                    ui.close();
-                                }
-                            }
-                        });
-                        // Alt-drag the first or last key of a selected group: scale the group in time.
-                        if kresp.drag_started()
-                            && mods.alt
-                            && !mods.command
-                            && let Some(c) = app.session.active_comp_arc()
-                        {
-                            super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
-                        }
-                        // A plain drag moves the selected keys. The timeline follows it (below): this
-                        // widget's id changes with the key's time, so its own drag would end after
-                        // the first frame of movement.
-                        // (From where the button went down: a drag starts a few pixels later.)
-                        if kresp.drag_started()
-                            && !super::graph_tools::alt_scale_active(&ctx)
-                            && let Some(p) = ctx.input(|i| i.pointer.press_origin())
-                        {
-                            // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
-                            let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
-                            ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
-                        }
-                    }
+            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
+        }
+        if gresp.drag_stopped() {
+            ctx.data_mut(|d| d.remove::(acc_id));
+            ui_actions.push(UiAct::EndMerge);
+        }
+    }
+}
+
+/// The source extent behind the bar (striped past its ends).
+fn layer_source_extent(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect, lc: Color32) {
+    let tm = g.tl.tm;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    if let LayerSource::Footage { .. } | LayerSource::Comp { .. } = layer.source
+        && let Some(d) = layer.source.item().and_then(|i| app.session.project.item(i)).and_then(|i| i.duration())
+    {
+        let xs = tm.x(layer.start_time.seconds());
+        let xe = tm.x(layer.comp_time(d).seconds());
+        let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0));
+        gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18));
+        // The bar past the source's end (its last frame held) or before its start
+        // is striped, so the source's own span shows (#290). Time remapping
+        // decides what shows there itself.
+        if layer.props.get("timeRemap").is_none() {
+            for (side, part) in [
+                ("beforeSource", Rect::from_min_max(bar.min, pos2(ghost.min.x, bar.max.y))),
+                ("afterSource", Rect::from_min_max(pos2(ghost.max.x, bar.min.y), bar.max)),
+            ] {
+                if part.width() >= 1.0 {
+                    stripes(&gp, part, lc);
+                    app.auto.add(&format!("timeline.layer.{}.bar.{side}", layer.id.0), part, "Past the source");
                 }
             }
         }
+        slip_source_handles(app, ui, g, rc, ghost, bar);
     }
+}
+
+/// The bar's move body and trim edges, click selection.
+fn layer_bar_hits(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, bar: Rect) -> BarResps {
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name);
+    // Interactions: move body, trim edges.
+    let (body_r, in_r, out_r) = trim_zones(bar);
+    let body = ui.interact(body_r, egui::Id::unique(("bar", layer.id.0)), Sense::click_and_drag());
+    let lin = ui.interact(in_r, egui::Id::unique(("bar-in", layer.id.0)), Sense::drag());
+    let lout = ui.interact(out_r, egui::Id::unique(("bar-out", layer.id.0)), Sense::drag());
+    app.auto.add(&format!("timeline.layer.{}.bar.in", layer.id.0), in_r, "Trim In");
+    app.auto.add(&format!("timeline.layer.{}.bar.out", layer.id.0), out_r, "Trim Out");
+    if lin.hovered() || lout.hovered() || lin.dragged() || lout.dragged() {
+        trim_cursor(&ctx);
+    }
+    if body.double_clicked()
+        && let Some(a) = double_click_action(layer)
+    {
+        actions.push(a);
+    }
+    let shift = ui.input(|i| i.modifiers.shift);
+    if let Some(layers) = (body.clicked() && shift).then(|| shift_range(&ctx, cid, &comp, rows, selected, layer.id)).flatten() {
+        // Shift-click: the layers from the last one clicked to this one, as in
+        // the layer outline.
+        actions.push(("layer.select".into(), json!({"layers": layers})));
+    } else {
+        if (body.clicked() || body.drag_started()) && !is_sel {
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0], "add": shift})));
+        }
+        if (body.clicked() || body.drag_started()) && !shift {
+            set_anchor(&ctx, cid, layer.id);
+        }
+    }
+    layer_context_menu(&body, &comp, layer, selected, LayerMenuCaps::of(&app.session.project, layer), &mut app.auto, &mut *actions);
+    BarResps { body, lin, lout }
+}
+
+fn layer_quick_offset_begin(ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, body: &egui::Response) -> bool {
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let selected = &g.selected;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    let mods = ui.input(|i| i.modifiers);
+    if body.drag_started()
+        && is_sel
+        && mods.command
+        && mods.alt
+        && let Some(n) = quick_offset_layers(&comp, selected.iter().copied())
+        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
+    {
+        ctx.data_mut(|d| d.insert_temp(quick_bar_id(), QuickBar { layer: layer.id, origin_x: p.x, layers: n, applied: 0 }));
+    }
+    ctx.data(|d| d.get_temp::(quick_bar_id())).is_some_and(|q| q.layer == layer.id)
+}
+
+/// Moving and trimming a layer bar by whole frames.
+fn layer_bar_drag(g: &mut Rw, rc: &RowCtx<'_>, bars: &BarResps, quick: bool) {
+    let BarResps { body, lin, lout } = bars;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let selected = &g.selected;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let is_sel = rc.is_sel;
+    let drag_key = format!("bar-{}", layer.id.0);
+    for (resp, kind) in [(&body, "move"), (&lin, "in"), (&lout, "out")] {
+        if kind == "move" && quick {
+            continue;
+        }
+        if resp.dragged() {
+            let acc_id = egui::Id::unique(("bar-acc", layer.id.0, kind));
+            let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0));
+            acc += resp.drag_delta().x;
+            let frames = ((acc as f64 / pps) / fd).trunc() as i64;
+            if frames != 0 {
+                acc -= (frames as f64 * fd * pps) as f32;
+                let d = frames as f64 * fd;
+                // Moving or trimming a selected layer acts on the whole
+                // selection; each layer trims from its own in/out point.
+                let targets = if is_sel { json!(selected.iter().map(|l| l.0).collect::>()) } else { json!([layer.id.0]) };
+                let params = match kind {
+                    "move" => json!({"layers": targets, "delta": d, "merge": drag_key}),
+                    "in" => json!({"layers": targets, "inDelta": d, "merge": drag_key}),
+                    _ => json!({"layers": targets, "outDelta": d, "merge": drag_key}),
+                };
+                actions.push(("layer.timing".into(), params));
+            }
+            ctx.data_mut(|d| d.insert_temp(acc_id, acc));
+        }
+        if resp.drag_stopped() {
+            ctx.data_mut(|d| d.remove::(egui::Id::unique(("bar-acc", layer.id.0, kind))));
+            ui_actions.push(UiAct::EndMerge);
+        }
+    }
+}
+
+fn layer_bar_markers(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let comp = g.tl.comp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let gclip = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
+    super::markers_ui::layer_markers(app, ui, gclip, &comp, layer, tm, r);
+}
+
+/// The group row's background and effect label bar; returns the name to draw.
+fn group_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) -> String {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let ri = rc.ri;
+    let RowKind::Group { uid, name, .. } = &rc.row.kind else { return String::new() };
+    let group = layer.props.find_group(*uid);
+    let shown = match group.and_then(|g| match &g.kind {
+        GroupKind::Effect { effect } => Some(effect.as_str()),
+        _ => None,
+    }) {
+        Some(effect) => crate::i18n::effects::group(effect, name),
+        None => name.clone(),
+    };
+    // Selected groups (an effect, a mask, a shape group…) highlight like selected
+    // property rows.
+    let sel_group = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_group {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
+    // An effect's colour label (26.5): the same bar the Effect Controls header wears,
+    // on the row's left edge.
+    if let Some(lab) =
+        group.filter(|g| matches!(g.kind, GroupKind::Effect { .. })).and_then(|g| g.label).filter(|l| *l != effectcraft_engine::color::Label::None)
+    {
+        let [lr, lg, lb] = app.session.prefs.label_rgb(lab);
+        let bar = Rect::from_min_size(left.min, vec2(4.0, left.height()));
+        lp.rect_filled(bar, 0.0, Color32::from_rgb(lr, lg, lb));
+        app.auto.add(&format!("timeline.group.{uid}.label"), bar, &app.session.prefs.label_name(lab));
+    }
+    shown
+}
+
+/// The group's twirl, effect switch and style eye.
+fn group_switches(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, open, has_children, fx, eye, .. } = &rc.row.kind else { return };
+    if *has_children {
+        let tw = Rect::from_center_size(pos2(indent, cy), vec2(13.0, 13.0));
+        if widgets::twirl(ui, tw, *open, egui::Id::unique(("gtw", uid)), &t).clicked() {
+            // Alt/Cmd-click twirls the whole tree under the group (as on a layer).
+            ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, ui.input(|i| i.modifiers.alt || i.modifiers.command)));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.twirl"), tw, name);
+    }
+    if let Some(en) = fx {
+        let fr_ = Rect::from_center_size(pos2(cw.switches + SW * 3.0 + SW / 2.0, cy), vec2(16.0, 16.0));
+        if widgets::icon_toggle(ui, fr_, Icon::Fx, *en, &t, egui::Id::unique(("gfx", uid)), Sense::click()).clicked() {
+            actions.push(("effect.toggle".into(), json!({"layer": layer.id.0, "effect": uid})));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.fx"), fr_, name);
+    }
+    if let Some(en) = eye {
+        // Layer style eye switch in the A/V column, like AE.
+        let er = Rect::from_center_size(pos2(cw.av + 10.0, cy), vec2(15.0, 15.0));
+        lp.rect_stroke(er.shrink(1.5), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
+        if widgets::icon_toggle(ui, er, Icon::Eye, *en, &t, egui::Id::unique(("geye", uid)), Sense::click()).clicked() {
+            actions.push(("layer.style.toggle".into(), json!({"layer": layer.id.0, "style": uid})));
+        }
+        app.auto.add(&format!("timeline.group.{uid}.eye"), er, name);
+    }
+}
+
+/// The group name, or its rename field.
+fn group_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, shown: &str) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, .. } = &rc.row.kind else { return };
+    let group_edit: Option<(u64, u64, String)> = ctx.data(|d| d.get_temp(group_rename_id()));
+    if let Some((_, _, mut buf)) = group_edit.filter(|(rl, ru, _)| *rl == layer.id.0 && ru == uid) {
+        let field = Rect::from_min_max(pos2(indent + 8.0, r.min.y + 2.0), pos2((cw.switches - 4.0).max(indent + 60.0), r.max.y - 2.0));
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(field));
+        let er = child.add(egui::TextEdit::singleline(&mut buf).font(Tokens::ui(12.0)).desired_width(field.width()));
+        app.auto.add(&format!("timeline.group.{uid}.nameEdit"), er.rect, &buf);
+        if ctx.data_mut(|d| d.remove_temp::(rename_focus_id())).unwrap_or(false) {
+            er.request_focus();
+            widgets::select_all(&ctx, er.id, &buf);
+        }
+        if er.lost_focus() {
+            if !ui.input(|i| i.key_pressed(egui::Key::Escape)) && !buf.trim().is_empty() && buf != *name {
+                actions.push(("prop.renameGroup".into(), json!({"layer": layer.id.0, "prop": uid, "name": buf})));
+            }
+            ctx.data_mut(|d| d.remove::<(u64, u64, String)>(group_rename_id()));
+        } else {
+            ctx.data_mut(|d| d.insert_temp(group_rename_id(), (layer.id.0, *uid, buf)));
+        }
+    } else {
+        lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, shown, Tokens::ui(12.0), t.text);
+    }
+}
+
+/// Add / animate popups, Change Renderer and dash buttons.
+fn group_popups(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Group { uid, .. } = &rc.row.kind else { return };
+    text_anim_popups(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+    shape_add_popup(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+    if *uid == GEOM_BIT | layer.id.0 {
+        let lr = lp.text(pos2(cw.switches + 6.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Change Renderer…"), Tokens::ui(11.5), t.accent);
+        let resp = ui
+            .interact(lr, egui::Id::unique(("tl-change-renderer", layer.id.0)), Sense::click())
+            .on_hover_text(crate::i18n::tr("Composition Settings ▸ 3D Renderer"));
+        if resp.hovered() {
+            ctx.set_cursor_icon(egui::CursorIcon::PointingHand);
+        }
+        app.auto.add(&format!("timeline.layer.{}.changeRenderer", layer.id.0), lr, "Change Renderer…");
+        if resp.clicked() {
+            ui_actions.push(UiAct::ChangeRenderer);
+        }
+    }
+    dash_buttons(app, ui, &lp, layer, *uid, pos2(cw.switches, cy), &mut *actions);
+}
+
+/// Mask mode and inverted, inline.
+fn group_mask_mode(ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Group { uid, .. } = &rc.row.kind else { return };
+    // Mask mode + inverted inline.
+    if let Some(g) = layer.props.find_group(*uid)
+        && let GroupKind::Mask { mode, inverted, .. } = g.kind
+    {
+        let mr = Rect::from_min_size(pos2(cw.switches + 4.0, cy - 9.0), vec2(80.0, 18.0));
+        let pop = egui::Id::unique(("maskmode", uid));
+        if widgets::dropdown(ui, mr, mode.label(), &t, egui::Id::unique(("mm", uid))).clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        let opts: Vec = effectcraft_engine::project::MaskMode::ALL.iter().map(|m| m.label().to_string()).collect();
+        if let Some(i) = widgets::popup_menu(ui, pop, mr.left_bottom(), &opts, None) {
+            actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "mode": opts[i]})));
+        }
+        let ir = Rect::from_min_size(pos2(mr.max.x + 8.0, cy - 8.0), vec2(16.0, 16.0));
+        if widgets::checkbox(ui, ir, inverted, &t, egui::Id::unique(("minv", uid))).clicked() {
+            actions.push(("layer.setMask".into(), json!({"layer": layer.id.0, "mask": uid, "inverted": !inverted})));
+        }
+        lp.text(pos2(ir.max.x + 4.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Inverted"), Tokens::ui(11.0), t.text_dim);
+    }
+}
+
+/// Group row click and double-click.
+fn group_row_click(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>) {
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let r = rc.r;
+    let indent = rc.indent;
+    let RowKind::Group { uid, name, fx, .. } = &rc.row.kind else { return };
+    let gr_rect = Rect::from_min_max(pos2(indent + 8.0, r.min.y), pos2(cw.switches, r.max.y));
+    let gr = ui.interact(gr_rect, egui::Id::unique(("grow", uid)), Sense::click());
+    app.auto.add(&format!("timeline.group.{uid}.name"), gr_rect, name);
+    if gr.clicked() && uid & (WAVE_BIT | GEOM_BIT) == 0 {
+        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
+    }
+    if gr.double_clicked() {
+        if fx.is_some() {
+            // An effect's name: open it in Effect Controls (as in After Effects).
+            actions.push(("layer.select".into(), json!({"layers": [layer.id.0]})));
+            actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "selectKeys": false})));
+            actions.push(("window.panel".into(), json!({"panel": "EffectControls"})));
+        } else {
+            ui_actions.push(UiAct::ToggleGroup(layer.id.0, *uid, false));
+        }
+    }
+}
+
+fn waveform_row(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let row = rc.row;
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let cy = rc.cy;
+    let ri = rc.ri;
+    let RowKind::Waveform { item, .. } = &rc.row.kind else { return };
+    lp.rect_filled(left, 0.0, if ri.is_multiple_of(2) { t.row } else { t.row_alt });
+    let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
+    lp.text(pos2(indent + 10.0, cy), Align2::LEFT_CENTER, "Waveform", Tokens::ui(12.0), t.text);
+    let wr = Rect::from_min_max(pos2(graph_x0, r.min.y), r.max);
+    gp.rect_filled(wr, 0.0, t.tl_bg);
+    app.auto.add(&format!("timeline.layer.{}.waveform", layer.id.0), wr, "Waveform");
+    match super::waveform::summary(app, &ctx, ItemId(*item)) {
+        Some(s) => super::waveform::draw(&gp, wr.shrink2(vec2(0.0, 2.0)), &tm, layer, &s, Color32::from_rgb(0x5f, 0xc8, 0x8a)),
+        None => {
+            gp.text(
+                pos2(tm.x(layer.in_point.seconds()).max(wr.min.x) + 6.0, wr.center().y),
+                Align2::LEFT_CENTER,
+                "Building waveform…",
+                Tokens::ui(11.0),
+                t.text_faint,
+            );
+        }
+    }
+}
+
+/// The expression row's outline side: enable, pick whip and syntax warning.
+fn expr_controls(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let row = rc.row;
+    let layer = rc.layer;
+    let left = rc.left;
+    let cy = rc.cy;
+    let ri = rc.ri;
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_prop {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    let indent = cw.name + 6.0 + 14.0 * row.depth as f32;
+    lp.text(pos2(indent + 12.0, cy), Align2::LEFT_CENTER, crate::i18n::tr("Expression:"), Tokens::ui(11.5), t.text_dim);
+    lp.text(pos2(indent + 82.0, cy), Align2::LEFT_CENTER, &prop.name, Tokens::ui(11.5), t.text);
+    // "=" enable switch and the property pick whip (AE's expression controls).
+    let en_r = Rect::from_center_size(pos2(cw.switches + 10.0, cy), vec2(16.0, 16.0));
+    let en = ui.interact(en_r, egui::Id::unique(("expr-en", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Enable Expression"));
+    lp.rect_stroke(en_r.shrink(1.0), 2.0, Stroke::new(1.0, t.separator), StrokeKind::Inside);
+    let expr_col = t.expr_text;
+    lp.text(en_r.center(), Align2::CENTER_CENTER, if ex.enabled { "=" } else { "≠" }, Tokens::semibold(13.0), if ex.enabled { expr_col } else { t.text_dim });
+    app.auto.add(&format!("timeline.prop.{uid}.exprEnable"), en_r, "Enable Expression");
+    if en.clicked() {
+        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "enabled": !ex.enabled})));
+    }
+    let pw = pick_whip(
+        app,
+        ui,
+        &lp,
+        Rect::from_center_size(pos2(cw.switches + 30.0, cy), vec2(15.0, 15.0)),
+        (WHIP_EXPR, layer.id.0, *uid),
+        WhipText {
+            auto: &format!("timeline.prop.{uid}.pickWhip"),
+            label: "Expression pick whip",
+            hint: crate::i18n::tr("Expression pick whip: drag onto a property"),
+        },
+    );
+    if pw.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) {
+        // Pressed while the expression is being edited (its text is buffered while
+        // the editor has the focus): the reference will go in at the cursor,
+        // replacing the selection (#284).
+        let into = ctx.data(|d| d.get_temp::(buf_id)).map(|buf| {
+            let n = buf.chars().count();
+            let sel = egui::text_edit::TextEditState::load(&ctx, edit_id)
+                .and_then(|st| st.cursor.char_range())
+                .map_or([n, n], |r| [r.primary.index.0.min(r.secondary.index.0).min(n), r.primary.index.0.max(r.secondary.index.0).min(n)]);
+            (buf, sel)
+        });
+        ctx.data_mut(|d| {
+            if let Some(into) = into {
+                d.insert_temp(whip_into_id(*uid), into);
+            } else {
+                d.remove::(whip_into_id(*uid));
+            }
+        });
+    }
+    // Syntax errors: warning in the outline (the expression is disabled).
+    if let Some(check) = app.session.expr_check
+        && let Err(e) = check(&ex.text)
+    {
+        let wr = Rect::from_center_size(pos2(cw.switches + 50.0, cy), vec2(14.0, 14.0));
+        lp.text(wr.center(), Align2::CENTER_CENTER, "⚠", Tokens::ui(12.0), Color32::from_rgb(0xf0, 0xa0, 0x30));
+        let _ = ui.interact(wr, egui::Id::unique(("expr-err", uid)), Sense::hover()).on_hover_text(e);
+    }
+}
+
+/// The Expression Language menu inserts at the cursor.
+fn expr_language_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let RowKind::Expr { uid, .. } = &rc.row.kind else { return };
+    // ⓕ: the Expression Language menu inserts at the cursor.
+    let lang_r = Rect::from_center_size(pos2(cw.switches + 70.0, cy), vec2(16.0, 16.0));
+    let lang = ui.interact(lang_r, egui::Id::unique(("expr-lang", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Expression Language Menu"));
+    lp.circle_stroke(lang_r.center(), 6.5, Stroke::new(1.0, if lang.hovered() { t.text } else { t.text_dim }));
+    lp.text(lang_r.center(), Align2::CENTER_CENTER, "f", Tokens::semibold(10.0), if lang.hovered() { t.text } else { t.text_dim });
+    app.auto.add(&format!("timeline.prop.{uid}.exprLanguage"), lang_r, "Expression Language Menu");
+    let mut picked: Option<&'static str> = None;
+    egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
+        for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
+            ui.menu_button(cat, |ui| {
+                widgets::menu_scroll(ui, false, |ui| {
+                    for (label, text) in items {
+                        if ui.button(label).clicked() {
+                            picked = Some(text);
+                            ui.close();
+                        }
+                    }
+                })
+            });
+        }
+    });
+    if let Some(text) = picked {
+        let cur: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
+        let at = egui::text_edit::TextEditState::load(&ctx, edit_id)
+            .and_then(|st| st.cursor.char_range())
+            .map(|r| r.primary.index.0)
+            .unwrap_or(cur.chars().count())
+            .min(cur.chars().count());
+        let byte = cur.char_indices().nth(at).map(|(b, _)| b).unwrap_or(cur.len());
+        let mut next = cur.clone();
+        next.insert_str(byte, text);
+        actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": next})));
+        ctx.data_mut(|d| d.remove::(buf_id));
+    }
+}
+
+/// The expression editor in the time-graph area.
+fn expr_editor_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, st: &ExprRow<'_>) {
+    let ExprRow { prop, ex, buf_id, edit_id, .. } = *st;
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let rh = g.tl.rh;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let right_clip = g.right_clip;
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let RowKind::Expr { uid, lines, .. } = &rc.row.kind else { return };
+    let expr_col = t.expr_text;
+    // The editor in the time-graph area.
+    ui.set_clip_rect(right_clip);
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.expr_bg);
+    let er = Rect::from_min_max(pos2(graph_x0 + 8.0, r.min.y + 3.0), pos2(rect.max.x - 14.0, r.max.y - 3.0));
+    let mut buf: String = ctx.data(|d| d.get_temp(buf_id)).unwrap_or_else(|| ex.text.clone());
+    if let Some(f) = ctx.data(|d| d.get_temp::(expr_focus_id())).filter(|f| f.prop == *uid) {
+        // Asked for once (a pick whip put a reference in, or an Alt-click added the
+        // expression): editing starts in the expression as it is now.
+        ctx.data_mut(|d| d.remove::(expr_focus_id()));
+        buf = ex.text.clone();
+        let n = buf.chars().count();
+        let [a, b] = f.select.map(|before_end| egui::text::CCursor::new(n.saturating_sub(before_end)));
+        let mut st = egui::text_edit::TextEditState::load(&ctx, edit_id).unwrap_or_default();
+        st.cursor.set_char_range(Some(egui::text::CCursorRange::two(a, b)));
+        st.store(&ctx, edit_id);
+        ctx.memory_mut(|m| m.request_focus(edit_id));
+    }
+    // Settings ▸ Scripting & Expressions ▸ Expressions Editor.
+    let sp = app.session.prefs.scripting.clone();
+    let resp = super::expr_editor::editor(
+        ui,
+        edit_id,
+        &mut buf,
+        er,
+        super::expr_editor::EditorOpts { prefs: &sp, color: if ex.enabled { expr_col } else { t.text_dim }, rows: *lines },
+        &t,
+    );
+    app.auto.add(&format!("timeline.prop.{uid}.expression"), er, &prop.name);
+    // Its bottom edge drags to set its height in whole lines, as in After Effects.
+    let edge = Rect::from_min_max(pos2(er.min.x, r.max.y - 4.0), pos2(er.max.x, r.max.y));
+    let eresp = ui.interact(edge, egui::Id::unique(("expr-edge", uid)), Sense::drag());
+    app.auto.add(&format!("timeline.prop.{uid}.expressionHeight"), edge, "Expression field height");
+    if eresp.hovered() || eresp.dragged() {
+        ctx.set_cursor_icon(egui::CursorIcon::ResizeVertical);
+    }
+    if let Some(py) = eresp.interact_pointer_pos().filter(|_| eresp.dragged()).map(|p| p.y) {
+        let n = ((py - r.min.y) / rh).round().clamp(1.0, EXPR_MAX_LINES as f32);
+        app.ui.timeline.expr_lines.insert(*uid, n as usize);
+    }
+    let commit = resp.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl));
+    if commit {
+        resp.surrender_focus();
+    }
+    if resp.has_focus() && !commit {
+        ctx.data_mut(|d| d.insert_temp(buf_id, buf));
+    } else {
+        if (resp.lost_focus() || commit) && buf.trim_end() != ex.text {
+            actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid, "expression": buf.trim_end()})));
+        }
+        ctx.data_mut(|d| d.remove::(buf_id));
+    }
+}
+
+fn prop_background(app: &mut EffectcraftApp, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let lp = g.lp.clone();
+    let gp = g.gp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let left = rc.left;
+    let ri = rc.ri;
+    let uid = &prop.uid;
+
+    app.auto.add(&format!("timeline.prop.{uid}.row"), r, &prop.name);
+    let sel_prop = app.session.state.selected_props.contains(&(layer.id, *uid));
+    lp.rect_filled(
+        left,
+        0.0,
+        if sel_prop {
+            t.row_selected
+        } else if ri.is_multiple_of(2) {
+            t.row
+        } else {
+            t.row_alt
+        },
+    );
+    gp.rect_filled(Rect::from_min_max(pos2(graph_x0, r.min.y), r.max), 0.0, t.tl_bg);
+}
+
+/// The keyframe navigator (previous, toggle, next).
+fn prop_key_navigator(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let fr = g.tl.fr;
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    // Keyframe navigator (in the A/V column, like AE).
+    // (in the Keys column when it is shown).
+    if prop.is_animated() && (vis.keys || vis.av) {
+        let lt = layer.layer_time(time);
+        let at_key = effectcraft_engine::keyframe::key_at(&prop.keys, lt).is_some();
+        let nav_x = if vis.keys { cw.keys + 12.0 } else { cw.av + 18.0 };
+        let prev = Rect::from_center_size(pos2(nav_x, cy), vec2(12.0, 14.0));
+        let mid = Rect::from_center_size(pos2(nav_x + 16.0, cy), vec2(14.0, 14.0));
+        let next = Rect::from_center_size(pos2(nav_x + 32.0, cy), vec2(12.0, 14.0));
+        icons::paint(&lp, prev.shrink(2.0), Icon::ChevronLeft, t.text_dim);
+        icons::paint(&lp, next.shrink(2.0), Icon::ChevronRight, t.text_dim);
+        let kc = if at_key { t.keyframe_selected } else { Color32::TRANSPARENT };
+        lp.add(egui::Shape::convex_polygon(
+            vec![mid.center() + vec2(0.0, -5.0), mid.center() + vec2(5.0, 0.0), mid.center() + vec2(0.0, 5.0), mid.center() + vec2(-5.0, 0.0)],
+            kc,
+            Stroke::new(1.0, t.text_dim),
+        ));
+        if ui.interact(prev, egui::Id::unique(("kprev", uid)), Sense::click()).clicked()
+            && let Some(k) = prop.keys.iter().rev().find(|k| layer.comp_time(k.time).0 < time.0 - fr.frame_duration().0 / 2)
+        {
+            ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
+        }
+        if ui.interact(next, egui::Id::unique(("knext", uid)), Sense::click()).clicked()
+            && let Some(k) = prop.keys.iter().find(|k| layer.comp_time(k.time).0 > time.0 + fr.frame_duration().0 / 2)
+        {
+            ui_actions.push(UiAct::SetTime(layer.comp_time(k.time)));
+        }
+        if ui.interact(mid, egui::Id::unique(("kmid", uid)), Sense::click()).clicked() {
+            actions.push(("prop.toggleKey".into(), json!({"layer": layer.id.0, "prop": uid})));
+        }
+        app.auto.add(&format!("timeline.prop.{uid}.addKey"), mid, "Add or remove keyframe");
+    }
+}
+
+/// The stopwatch.
+fn prop_stopwatch(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let uid = &prop.uid;
+
+    // Stopwatch: Alt+click adds an expression; a press toggles the animation, and
+    // dragging on over other stopwatches gives them the same state (#284).
+    if !prop.static_only {
+        let swr = Rect::from_center_size(pos2(indent, cy), vec2(15.0, 15.0));
+        let resp = ui.interact(swr, egui::Id::unique(("stopwatch", uid)), Sense::click_and_drag());
+        let col = if prop.is_animated() {
+            t.hot_text
+        } else if resp.hovered() {
+            t.text
+        } else {
+            t.text_dim
+        };
+        icons::paint(&lp, swr, Icon::Stopwatch, col);
+        app.auto.add(&format!("timeline.prop.{uid}.stopwatch"), swr, &prop.name);
+        if ui.input(|i| i.modifiers.alt) {
+            if resp.clicked() {
+                actions.push(("prop.setExpression".into(), json!({"layer": layer.id.0, "prop": uid})));
+                if prop.expr.is_none() {
+                    // Adding one: its editor opens, comes into view and takes the
+                    // keyboard with the new expression selected (#1072).
+                    app.ui.timeline.expr_closed.remove(uid);
+                    focus_expression(&ctx, *uid, [usize::MAX, 0]);
+                } else if ctx.data(|d| d.get_temp::(expr_focus_id())).is_some_and(|f| f.prop == *uid) {
+                    // Removing it: nothing is left to take the keyboard.
+                    ctx.data_mut(|d| d.remove::(expr_focus_id()));
+                }
+            }
+        } else if let Some((value, merge)) = switch_gesture(ui, &resp, r, "stopwatch", prop.is_animated()) {
+            actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid, "value": value, "merge": merge})));
+        } else if resp.clicked() && !resp.clicked_by(egui::PointerButton::Primary) {
+            // Keyboard or accessibility activation.
+            actions.push(("prop.toggleAnimation".into(), json!({"layer": layer.id.0, "prop": uid})));
+        }
+    }
+}
+
+/// The property name; returns its x.
+fn prop_name(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property) -> f32 {
+    let t = g.tl.t;
+    let cid = g.tl.cid;
+    let ctx = g.tl.ctx.clone();
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let layer = rc.layer;
+    let r = rc.r;
+    let cy = rc.cy;
+    let indent = rc.indent;
+    let uid = &prop.uid;
+
+    let name_x = indent + 12.0;
+    // 3D layers show Rotation as "Z Rotation" next to X/Y Rotation.
+    let is_tr_rot = layer.transform().and_then(|t| t.get("rotation")).is_some_and(|r| r.uid == prop.uid);
+    let base = if is_tr_rot && prop.name == "Rotation" && layer.is_3d() { "Z Rotation".to_string() } else { prop.name.clone() };
+    let pname = if prop.has_expression() { format!("{base}  =") } else { base };
+    lp.text(pos2(name_x, cy), Align2::LEFT_CENTER, &pname, Tokens::ui(12.0), if prop.has_expression() { t.expr_text } else { t.text });
+    let name_rect = Rect::from_min_max(pos2(name_x, r.min.y), pos2(cw.switches - 2.0, r.max.y));
+    let name_resp = ui.interact(name_rect, egui::Id::unique(("pname", uid)), Sense::click_and_drag());
+    app.auto.add(&format!("timeline.prop.{uid}.name"), name_rect, &prop.name);
+    if name_resp.drag_started() {
+        // Drop on the Essential Graphics panel to expose the property.
+        egui::DragAndDrop::set_payload(&ctx, crate::panels::DragPayload::Property { layer: layer.id.0, prop: *uid });
+    }
+    if name_resp.dragged() {
+        egui::Tooltip::always_open(ctx.clone(), ui.layer_id(), egui::Id::unique("prop-drag-tip"), egui::PopupAnchor::Pointer).show(|ui| ui.label(&prop.name));
+    }
+    if name_resp.clicked() {
+        actions.push(("prop.select".into(), json!({"layer": layer.id.0, "prop": uid, "add": ui.input(|i| i.modifiers.shift)})));
+    }
+    crate::widgets::context_menu(&name_resp, |ui| prop_menu(ui, &mut app.auto, cid, layer, prop, &mut *actions));
+    name_x
+}
+
+/// The value editors and the property pick whip.
+fn prop_value_cell(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, rc: &RowCtx<'_>, ectx: &EvalCtx<'_>, prop: &Property, name_x: f32) {
+    let vis = g.tl.vis;
+    let cw = &g.tl.cw;
+    let actions = &mut g.tl.actions;
+    let lp = g.lp.clone();
+    let dim_hits = &mut g.dim_hits;
+    let layer = rc.layer;
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    let value = ectx.value(layer, prop);
+    let vx = (cw.switches + 4.0).max(name_x + 120.0);
+    let ve = ValueEdit::new(ui, &lp, layer, prop, pos2(vx, cy));
+    let dims = value_editor(app, ui, &ve, &value, &mut *actions);
+    if !dims.is_empty() {
+        dim_hits.push((*uid, dims));
+    }
+    // The property pick whip (Parent & Link column, AE CC 2018 and later): dropped on
+    // a property it links this one to it, adding the expression if there is none.
+    if vis.parent && !prop.static_only {
+        pick_whip(
+            app,
+            ui,
+            &lp,
+            Rect::from_center_size(pos2(cw.parent + 9.0, cy), vec2(15.0, 15.0)),
+            (WHIP_PROP, layer.id.0, *uid),
+            WhipText {
+                auto: &format!("timeline.prop.{uid}.propertyPickWhip"),
+                label: "Property pick whip",
+                hint: crate::i18n::tr("Property pick whip: drag onto a property"),
+            },
+        );
+    }
+}
+
+/// The one-line expression hint when its editor is closed.
+fn prop_expression_hint(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, rc: &RowCtx<'_>, prop: &Property) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let right_clip = g.right_clip;
+    let gp = g.gp.clone();
+    let cy = rc.cy;
+    let uid = &prop.uid;
+
+    ui.set_clip_rect(right_clip);
+    // Expression text row hint.
+    if let Some(e) = prop.expr.as_ref().filter(|e| e.enabled && app.ui.timeline.expr_closed.contains(uid)) {
+        gp.text(pos2(graph_x0 + 8.0, cy), Align2::LEFT_CENTER, format!("= {}", e.text.lines().next().unwrap_or("")), Tokens::mono(11.0), t.expr_text);
+    }
+}
+
+/// Key click selection and interpolation shortcuts.
+fn key_select(g: &mut Rw, kh: &KeyHit<'_>) {
+    let KeyHit { k, ks, mods, ref kresp, ref this_key, .. } = *kh;
+    let actions = &mut g.tl.actions;
+    if kresp.clicked() && mods.command {
+        // Ctrl+click: Linear ↔ Auto Bezier; Ctrl+Alt+click: Hold on / off.
+        actions.push(("keys.select".into(), this_key.clone()));
+        if mods.alt {
+            actions.push(("keys.toggleHold".into(), json!({})));
+        } else {
+            let linear = k.in_interp == Interp::Linear && k.out_interp == Interp::Linear;
+            actions.push(("keys.interpolation".into(), json!({"interpolation": if linear { "autoBezier" } else { "linear" }})));
+        }
+    } else if kresp.clicked() && mods.shift {
+        // Shift+click: in or out of the selection.
+        let mut p = this_key.clone();
+        p["toggle"] = json!(true);
+        actions.push(("keys.select".into(), p));
+    } else if (kresp.clicked() || kresp.drag_started()) && !ks {
+        let mut p = this_key.clone();
+        p["add"] = json!(mods.shift);
+        actions.push(("keys.select".into(), p));
+    }
+}
+
+fn key_double_click(g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
+    let KeyHit { k, ct, ref kresp, .. } = *kh;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+
+    if kresp.double_clicked() {
+        // Edit the key's value (numeric properties), as After Effects does.
+        if matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
+            actions.push(("keys.set".into(), json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})));
+        } else {
+            ui_actions.push(UiAct::SetTime(ct));
+        }
+    }
+}
+
+/// The keyframe right-click menu.
+fn key_context_menu(app: &mut EffectcraftApp, g: &mut Rw, rc: &RowCtx<'_>, prop: &Property, kh: &KeyHit<'_>) {
+    let KeyHit { k, ks, ref kresp, .. } = *kh;
+    let t = g.tl.t;
+    let actions = &mut g.tl.actions;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+
+    crate::widgets::context_menu(kresp, |ui| {
+        for (lbl, cmd, params) in [
+            ("Edit Value…", "keys.set", json!({"layer": layer.id.0, "prop": uid, "time": k.time.seconds()})),
+            ("-", "", json!({})),
+            ("Copy", "keys.copy", json!({})),
+            ("Paste", "keys.paste", json!({})),
+            ("-", "", json!({})),
+            ("Select Equal Keyframes", "keys.selectEqual", json!({})),
+            ("Select Previous Keyframes", "keys.selectPrevious", json!({})),
+            ("Select Following Keyframes", "keys.selectFollowing", json!({})),
+            ("-", "", json!({})),
+            ("Keyframe Interpolation…", "keys.interpolation", json!({})),
+            ("Keyframe Velocity…", "keys.velocity", json!({})),
+            ("Toggle Hold Keyframe", "keys.toggleHold", json!({})),
+            ("Rove Across Time", "keys.interpolation", json!({"roving": !k.roving})),
+            ("-", "", json!({})),
+            ("Easy Ease", "keys.easyEase", json!({})),
+            ("Easy Ease In", "keys.easyEaseIn", json!({})),
+            ("Easy Ease Out", "keys.easyEaseOut", json!({})),
+            ("Time-Reverse Keyframes", "keys.timeReverse", json!({})),
+            ("-", "", json!({})),
+            ("Linear", "keys.interpolation", json!({"interpolation": "linear"})),
+            ("Bezier", "keys.interpolation", json!({"interpolation": "bezier"})),
+            ("Auto Bezier", "keys.interpolation", json!({"interpolation": "autoBezier"})),
+            ("Select All Keyframes", "keys.selectAll", json!({})),
+            ("Delete", "keys.delete", json!({})),
+        ] {
+            if lbl == "Select All Keyframes" {
+                // Keyframe colour labels.
+                ui.menu_button(crate::i18n::tr("Label"), |ui| {
+                    for (i, l) in effectcraft_engine::color::Label::ALL.into_iter().enumerate() {
+                        if widgets::label_entry(ui, l, &app.session.prefs.label_name(l), k.label as usize == i, &t).clicked() {
+                            if !ks {
+                                actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
+                            }
+                            actions.push(("keys.setLabel".into(), json!({"label": i})));
+                            ui.close();
+                        }
+                    }
+                });
+            }
+            if lbl == "-" {
+                ui.separator();
+                continue;
+            }
+            if lbl == "Rove Across Time" && !prop.spatial {
+                continue;
+            }
+            if lbl == "Edit Value…" && !matches!(k.value, Value::Scalar(_) | Value::Vec2(_) | Value::Vec3(_)) {
+                continue;
+            }
+            if ui.button(lbl).clicked() {
+                if !ks {
+                    actions.push(("keys.select".into(), json!({"keys": [{"layer": layer.id.0, "prop": uid, "time": k.time.seconds()}]})));
+                }
+                actions.push((cmd.into(), params));
+                ui.close();
+            }
+        }
+    });
+}
+
+/// Starts a key drag (or Alt-scale).
+fn key_drag_begin(app: &mut EffectcraftApp, g: &Rw, kh: &KeyHit<'_>) {
+    let KeyHit { ct, ks, mods, ref kresp, .. } = *kh;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let sel_keys = &g.sel_keys;
+    // Alt-drag the first or last key of a selected group: scale the group in time.
+    if kresp.drag_started()
+        && mods.alt
+        && !mods.command
+        && let Some(c) = app.session.active_comp_arc()
+    {
+        super::graph_tools::alt_scale_begin(app, &ctx, &c, ct.seconds());
+    }
+    // A plain drag moves the selected keys. The timeline follows it (below): this
+    // widget's id changes with the key's time, so its own drag would end after
+    // the first frame of movement.
+    // (From where the button went down: a drag starts a few pixels later.)
+    if kresp.drag_started()
+        && !super::graph_tools::alt_scale_active(&ctx)
+        && let Some(p) = ctx.input(|i| i.pointer.press_origin())
+    {
+        // Ctrl/Cmd+Alt: Quick Offset of the selected keys' layers (#1012).
+        let quick = (mods.command && mods.alt && ks).then(|| quick_offset_layers(&comp, sel_keys.iter().map(|k| k.layer))).flatten();
+        ctx.data_mut(|d| d.insert_temp(key_drag_id(), KeyDrag { origin_x: p.x, anchor: ct.seconds(), applied: 0, quick }));
+    }
+}
+
+/// Paints one keyframe marker; returns its hit rect.
+fn key_paint(g: &Rw, rc: &RowCtx<'_>, k: &effectcraft_engine::keyframe::Keyframe, x: f32, ks: bool) -> Rect {
+    let t = g.tl.t;
+    let gp = g.gp.clone();
+    let cy = rc.cy;
+    let icon = k.icon();
+    let c = pos2(x, cy);
+    // Labelled keys take their label colour (brighter when selected).
+    let kcol = match effectcraft_engine::color::Label::ALL.get(k.label as usize).filter(|_| k.label > 0) {
+        Some(l) => {
+            let c = t.label(*l);
+            if ks { c } else { c.gamma_multiply(0.75) }
+        }
+        None if ks => t.keyframe_selected,
+        None => t.keyframe,
+    };
+    if k.roving {
+        // Roving keys: a small dot (their time follows their neighbours).
+        gp.circle_filled(c, 2.5, kcol);
+    } else {
+        icons::keyframe(&gp, c, 11.0, icon.left, icon.right, kcol);
+    }
+    Rect::from_center_size(c, vec2(12.0, 14.0))
+}
+
+/// The key's hit target and automation ids.
+fn key_hit(
+    app: &mut EffectcraftApp,
+    ui: &mut egui::Ui,
+    g: &Rw,
+    rc: &RowCtx<'_>,
+    prop: &Property,
+    k: &effectcraft_engine::keyframe::Keyframe,
+    kr: Rect,
+) -> egui::Response {
+    let fr = g.tl.fr;
+    let layer = rc.layer;
+    let uid = &prop.uid;
+    let ct = layer.comp_time(k.time);
+    let kresp = ui.interact(kr, egui::Id::unique(("key", uid, k.time.0)), Sense::click_and_drag());
+    app.auto.add(&format!("timeline.key.{uid}.{}", fr.frame_at(ct)), kr, &format!("{} key", prop.name));
+    // Exact (layer-time ticks): keys a Quick Offset puts on subframes can share a frame.
+    app.auto.add(&format!("timeline.keyAt.{uid}.{}", k.time.0), kr, &format!("{} key", prop.name));
+    kresp
+}
+
+/// Right-click menus on the empty outline and graph areas.
+fn background_menus(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let full_clip = g.full_clip;
+    let empty_rect = g.empty_rect;
+    let empty = g.empty.clone();
+    let outline_empty = g.outline_empty.clone();
     ui.set_clip_rect(full_clip);
     // The background responses sit below rows, bars and editors, preserving their menus.
     if !graph_on {
@@ -3231,6 +4195,18 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         });
     }
+}
+
+/// Click and marquee-select on the empty outline.
+fn outline_marquee(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let actions = &mut g.tl.actions;
+    let outline_rect = g.outline_rect;
+    let lp = g.lp.clone();
+    let outline_empty = g.outline_empty.clone();
+    let hit_rows = &mut g.hit_rows;
     if outline_empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
         actions.push(("layer.select".into(), json!({"layers": []})));
     }
@@ -3255,6 +4231,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         actions.push(("layer.select".into(), json!({"layers": layers, "add": ui.input(|i| i.modifiers.shift || i.modifiers.command)})));
     }
     // Pick whips: a line follows the pointer; releasing over a row links to it (an expression's
+}
+
+/// A pick whip in flight: its line, the target and the link on release.
+fn pick_whip_drag(g: &mut Rw) {
+    let t = g.tl.t;
+    let graph_x0 = g.tl.graph_x0;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    let dim_hits = &mut g.dim_hits;
     // or a property's, also over a property in Effect Controls).
     if let Some((kind, src_layer, src_prop, start)) = ctx.data(|d| d.get_temp::(pick_whip_id())) {
         let ptr = ctx.input(|i| i.pointer.latest_pos()).unwrap_or(start);
@@ -3314,16 +4300,42 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     // Alt-drag key group scaling (follows the pointer until release).
+}
+
+fn alt_scale_and_graph(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw, ectx: &EvalCtx<'_>) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let gp = g.gp.clone();
     if let Some(c) = app.session.active_comp_arc() {
-        super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut actions);
+        super::graph_tools::alt_scale_update(app, &ctx, &c, tm, &mut *actions);
     }
     // Graph editor.
     if graph_on {
-        super::graph::show(app, ui, &gp, &comp, &ectx, tm, Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max), &mut actions);
+        super::graph::show(
+            app,
+            ui,
+            &gp,
+            super::graph::GraphView { comp: &comp, ectx, tm },
+            Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max),
+            &mut *actions,
+        );
     }
     // A plain click on empty track area (beside a layer's bar, on a property row's empty span or
     // below the last row) deselects the layers and their keys and properties, as in After
-    // Effects; Shift / Cmd keep the selection. Drag: box-select keys.
+}
+
+/// Click / marquee on empty track area.
+fn graph_empty_area(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let gp = g.gp.clone();
+    let empty = g.empty.clone();
     if empty.clicked() && !ui.input(|i| i.modifiers.shift || i.modifiers.command) {
         actions.push(("layer.select".into(), json!({"layers": []})));
     }
@@ -3335,7 +4347,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         gp.rect_stroke(br, 0.0, Stroke::new(1.0, t.accent), StrokeKind::Middle);
         ctx.data_mut(|d| d.insert_temp(egui::Id::unique("tl-box"), br));
     }
-    // Layer reorder drag: a line where the layers will land; the release moves them there.
+}
+
+/// Layer reorder drag: a line where the layers land.
+fn layer_reorder_drag(ui: &mut egui::Ui, g: &mut Rw) {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
     if let Some(moving) = ctx.data(|d| d.get_temp::>(layer_drag_id())) {
         let dropped = ctx.data_mut(|d| d.remove_temp::(layer_drop_id())).unwrap_or(false);
         let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
@@ -3366,7 +4390,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     // Quick Offset of layer bars: the last selected layer moves by whole frames with the pointer
-    // until the button is released, wherever the bar's row has scrolled to.
+}
+
+/// Quick Offset of layer bars follows the pointer.
+fn quick_offset_follow(g: &mut Rw) {
+    let t = g.tl.t;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
     if let Some(mut q) = ctx.data(|d| d.get_temp::(quick_bar_id())) {
         let (down, ptr) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos()));
         match ptr.filter(|_| down) {
@@ -3388,7 +4421,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
     // Keyframe drag: the selected keys move by whole frames with the pointer until it is
     // released (one undo step); Shift snaps the grabbed key to the current time, the work area
-    // and composition markers.
+}
+
+/// The key drag moves the selected keys by whole frames.
+fn key_drag_follow(app: &mut EffectcraftApp, g: &mut Rw) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let pps = g.tl.pps;
+    let fd = g.tl.fd;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let ui_actions = &mut g.ui_actions;
     if let Some(mut kd) = ctx.data(|d| d.get_temp::(key_drag_id())) {
         let (down, ptr, shift) = ctx.input(|i| (i.pointer.primary_down(), i.pointer.latest_pos(), i.modifiers.shift));
         match ptr.filter(|_| down) {
@@ -3399,7 +4444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     // not the moving keys themselves.
                     let moving: Vec =
                         app.session.state.selected_keys.iter().filter_map(|k| comp.layer(k.layer).map(|l| l.comp_time(k.time).seconds())).collect();
-                    let mut cands = snap_candidates(&comp, &rows);
+                    let mut cands = snap_candidates(&comp, rows);
                     cands.retain(|c| !moving.iter().any(|m| (m - c).abs() < 1e-9));
                     cands.push(time.seconds());
                     target = snap_time(&cands, target, 8.0 / pps);
@@ -3430,13 +4475,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
+
+/// Releasing the key marquee selects the keys inside it.
+fn key_box_select(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &mut Rw) {
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let rh = g.tl.rh;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let rows = &g.tl.rows;
+    let actions = &mut g.tl.actions;
+    let empty = g.empty.clone();
     if empty.drag_stopped()
         && let Some(br) = ctx.data(|d| d.get_temp::(egui::Id::unique("tl-box")))
     {
         ctx.data_mut(|d| d.remove::(egui::Id::unique("tl-box")));
         let mut keys = vec![];
         let mut yy = rows_rect.min.y - app.ui.timeline.scroll_y;
-        for row in &rows {
+        for row in rows.iter() {
             let cy = yy + rh / 2.0;
             yy += row_height(row, rh);
             if let RowKind::Prop { uid } = row.kind
@@ -3461,8 +4518,17 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             actions.push(("keys.select".into(), json!({"keys": keys, "add": mods.shift, "selectProperties": true})));
         }
     }
+}
 
-    // CTI.
+fn current_time_indicator(app: &mut EffectcraftApp, g: &Rw) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let p = g.tl.p.clone();
     let cx = tm.x(time.seconds());
     if cx >= graph_x0 && cx <= rect.max.x {
         p.line_segment([pos2(cx, ruler.min.y + 12.0), pos2(cx, rows_rect.max.y)], Stroke::new(1.0, t.cti));
@@ -3476,9 +4542,19 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         p.add(egui::Shape::convex_polygon(head, t.cti, Stroke::NONE));
         app.auto.add("timeline.cti", Rect::from_center_size(pos2(cx, ruler.center().y), vec2(12.0, 20.0)), "Current time indicator");
     }
-    let _ = graph_rect;
+}
 
-    // Footer.
+/// The footer: scroll bars, the switches / modes toggle and render time.
+fn footer_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) -> Rect {
+    let t = g.tl.t;
+    let rect = g.tl.rect;
+    let footer_h = g.tl.footer_h;
+    let overflow = g.tl.overflow;
+    let oscroll = g.tl.oscroll;
+    let graph_x0 = g.tl.graph_x0;
+    let rows_rect = g.tl.rows_rect;
+    let max_scroll = g.tl.max_scroll;
+    let p = g.tl.p.clone();
     let footer = Rect::from_min_max(pos2(rect.min.x, rect.max.y - footer_h), rect.max);
     p.rect_filled(footer, 0.0, t.panel_bg);
     p.line_segment([footer.left_top(), footer.right_top()], Stroke::new(1.0, t.separator));
@@ -3514,6 +4590,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         t.text_faint,
     );
     // Zoom slider.
+    footer
+}
+
+/// The zoom buttons and slider.
+fn footer_zoom(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw, footer: Rect) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let fit_pps = g.tl.fit_pps;
+    let pps = g.tl.pps;
+    let tm = g.tl.tm;
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
     let zs = Rect::from_min_max(pos2(graph_x0 + 30.0, footer.center().y - 2.0), pos2((graph_x0 + 230.0).min(rect.max.x - 30.0), footer.center().y + 2.0));
     // Zoom out / zoom in buttons either side of the slider: the `-` / `=` steps. Everything here
     // zooms around the CTI and sets the one time zoom the layer bars and the Graph Editor share.
@@ -3545,16 +4635,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let f = ((pt.x - zs.min.x) / zs.width().max(1.0)).clamp(0.0, 1.0) as f64;
         set_zoom(&mut app.ui.timeline, &comp, fit_pps * (maxpps / fit_pps).powf(f), fit_pps, cti, cti_px);
     }
+}
 
+fn comp_markers_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let bin = g.tl.bin;
+    let comp = g.tl.comp.clone();
     // Composition markers in the ruler; protected regions shade the layer rows.
     let mstrip = Rect::from_min_max(pos2(graph_x0, ruler.max.y - 12.0), pos2(bin.min.x, ruler.max.y));
     let mrows = rows_rect.intersect(Rect::from_min_max(pos2(graph_x0, rows_rect.min.y), rows_rect.max));
     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 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).
+/// Spacebar held: the Hand tool.
+fn hand_tool_pan(app: &mut EffectcraftApp, ui: &mut egui::Ui, g: &Rw) {
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let graph_x1 = g.tl.graph_x1;
+    let pps = g.tl.pps;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ruler = g.tl.ruler;
+    let max_scroll = g.tl.max_scroll;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
     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::unique("tl-hand"), Sense::drag());
@@ -3572,184 +4680,130 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+}
 
-    // Drop targets: effects and animation presets from Effects & Presets go on the layer under the
-    // pointer. Footage and
-    // comps from the Project panel and files from the Media Browser go where they are dropped: a
-    // line shows the place in the stack, and over the time graph a marker shows the In point too
-    // (Shift: at the current time).
-    if let Some(payload) = egui::DragAndDrop::payload::(&ctx)
-        && !matches!(*payload, crate::panels::DragPayload::Property { .. })
-        && 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());
-        // 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.
-            if let Some(l) = target
-                && 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.dropTarget", lit, "Drop target layer");
-            }
-            if released && let Some(l) = target {
-                match payload.as_ref() {
-                    crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))),
-                    crate::panels::DragPayload::Preset { preset, .. } => {
-                        let (id, params) = preset.apply_to(l.0);
-                        actions.push((id.into(), params));
-                    }
-                    _ => {}
-                }
-            }
-        } else {
-            let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
-            let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
-            let index = match at.and_then(|i| layer_rows.get(i)) {
-                Some((_, l)) => comp.index_of(*l).unwrap_or(1),
-                None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
-            };
-            let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
-                let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
-                comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
-            });
+/// Alt-drag of an item over a layer: replace its source.
+fn drop_replace_source(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let cid = g.tl.cid;
+    let rows_rect = g.tl.rows_rect;
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    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.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
-            if let Some(s) = start {
-                let x = tm.x(s.seconds());
-                rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
-                // The In point's time in a tag beside the marker.
-                let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
-                let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
-                rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
-                rp.galley(tag.min, g, Color32::WHITE);
-            }
+            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 {
-                let time = json!(start.map(Tick::seconds));
-                match payload.as_ref() {
-                    crate::panels::DragPayload::Item(id) => {
-                        // Insert each selected item at the drop point, in selection order.
-                        for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
-                            actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
-                        }
-                    }
-                    crate::panels::DragPayload::Files(paths) => {
-                        actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
-                    }
-                    crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {}
-                }
+                actions.push(("layer.replaceSource".into(), json!({"layers": [l.0], "item": *id, "fixExpressions": false})));
             }
         }
-        if released {
-            egui::DragAndDrop::clear_payload(&ctx);
-        }
     }
+}
 
-    // Apply.
-    for a in ui_actions {
-        match a {
-            UiAct::ToggleLayer(id, all) => {
-                let tl = &mut app.ui.timeline;
-                // Twirled by hand, the layer shows its whole tree again.
-                tl.reveal.clear();
-                tl.layer_reveal.remove(&id);
-                tl.full_groups.remove(&id);
-                let open = !tl.open_layers.contains(&id);
-                if open {
-                    tl.open_layers.insert(id);
-                } else {
-                    tl.open_layers.remove(&id);
-                }
-                if all && let Some(l) = comp.layer(LayerId(id)) {
-                    let mut uids = vec![];
-                    walk_groups(&l.props, &mut uids);
-                    for u in uids {
-                        if open {
-                            tl.open_groups.insert(u);
-                        } else {
-                            tl.open_groups.remove(&u);
-                        }
-                    }
-                }
-            }
-            UiAct::ToggleGroup(layer, uid, all) => {
-                let tl = &mut app.ui.timeline;
-                tl.toggle_group(layer, uid);
-                let open = tl.open_groups.contains(&uid);
-                if all && let Some(g) = comp.layer(LayerId(layer)).and_then(|l| find_group(&l.props, uid)) {
-                    let mut uids = vec![];
-                    walk_groups(g, &mut uids);
-                    for u in uids {
-                        if open {
-                            tl.open_groups.insert(u);
-                        } else {
-                            tl.open_groups.remove(&u);
-                        }
-                    }
-                }
-            }
-            UiAct::SetTime(tt) => app.session.set_time(tt),
-            UiAct::EndMerge => app.session.history.merge_key = None,
-            UiAct::ChangeRenderer => {
-                if super::dialogs::open_comp_settings(app).is_ok() {
-                    app.dialog_state.comp.tab = super::comp_settings::Tab::Renderer;
-                }
+/// Effects and presets drop onto the layer under the pointer.
+fn drop_on_layer(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let rows_rect = g.tl.rows_rect;
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let hit_rows = &mut g.hit_rows;
+    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.
+    if let Some(l) = target
+        && 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.dropTarget", lit, "Drop target layer");
+    }
+    if released && let Some(l) = target {
+        match payload.as_ref() {
+            crate::panels::DragPayload::Effect(e) => actions.push(("effect.apply".into(), json!({"effect": e, "layers": [l.0]}))),
+            crate::panels::DragPayload::Preset { preset, .. } => {
+                let (id, params) = preset.apply_to(l.0);
+                actions.push((id.into(), params));
             }
+            _ => {}
         }
     }
-    if ui.input(|i| i.pointer.primary_down()) && actions.iter().any(|(id, _)| id == "prop.set") {
-        app.ui.viewer.property_interacting = true;
-        ctx.request_repaint();
+}
+
+/// Footage and files drop into the stack: a line shows the place.
+fn drop_insert_layers(app: &mut EffectcraftApp, g: &mut Rw, payload: &std::sync::Arc, ptr: Pos2, released: bool) {
+    let t = g.tl.t;
+    let time = g.tl.time;
+    let rect = g.tl.rect;
+    let graph_x0 = g.tl.graph_x0;
+    let tm = g.tl.tm;
+    let rows_rect = g.tl.rows_rect;
+    let ctx = g.tl.ctx.clone();
+    let comp = g.tl.comp.clone();
+    let p = g.tl.p.clone();
+    let actions = &mut g.tl.actions;
+    let graph_on = g.graph_on;
+    let hit_rows = &mut g.hit_rows;
+    let layer_rows: Vec<(Rect, LayerId)> = hit_rows.iter().filter(|(_, r)| matches!(r.kind, RowKind::Layer)).map(|(rr, r)| (*rr, r.layer)).collect();
+    let (at, line_y) = insertion(&layer_rows, ptr.y, rows_rect.min.y);
+    let index = match at.and_then(|i| layer_rows.get(i)) {
+        Some((_, l)) => comp.index_of(*l).unwrap_or(1),
+        None => layer_rows.last().and_then(|(_, l)| comp.index_of(*l)).unwrap_or(comp.layers.len()) + 1,
+    };
+    let start = (ptr.x >= graph_x0 && !graph_on).then(|| {
+        let t = if ctx.input(|i| i.modifiers.shift) { time } else { Tick::from_seconds_f64(tm.t(ptr.x)) };
+        comp.frame_rate.snap_nearest(t).clamp(Tick::ZERO, comp.duration - comp.frame_duration())
+    });
+    let rp = p.with_clip_rect(rows_rect);
+    rp.line_segment([pos2(rect.min.x, line_y), pos2(rows_rect.max.x, line_y)], Stroke::new(2.0, t.accent));
+    if let Some(s) = start {
+        let x = tm.x(s.seconds());
+        rp.line_segment([pos2(x, rows_rect.min.y), pos2(x, rows_rect.max.y)], Stroke::new(1.0, t.accent));
+        // The In point's time in a tag beside the marker.
+        let g = rp.layout_no_wrap(super::timecode(&app.session, &comp, s), Tokens::ui(11.0), Color32::WHITE);
+        let tag = Align2::LEFT_BOTTOM.anchor_size(pos2(x + 4.0, line_y - 3.0), g.size());
+        rp.rect_filled(tag.expand(2.0), 2.0, t.accent);
+        rp.galley(tag.min, g, Color32::WHITE);
     }
-    for (id, params) in actions {
-        if id == "__endMerge" {
-            app.session.history.merge_key = None;
-            continue;
-        }
-        if let Err(e) = crate::menus::invoke(app, &ctx, &id, params) {
-            app.ui.status = e;
+    if released {
+        let time = json!(start.map(Tick::seconds));
+        match payload.as_ref() {
+            crate::panels::DragPayload::Item(id) => {
+                // Insert each selected item at the drop point, in selection order.
+                for (offset, item) in crate::panels::project_drop_items(app, *id).into_iter().enumerate() {
+                    actions.push(("layer.addItem".into(), json!({"item": item, "index": index.saturating_add(offset), "time": time.clone()})));
+                }
+            }
+            crate::panels::DragPayload::Files(paths) => {
+                actions.push(("mediaBrowser.import".into(), json!({"paths": paths, "addToComp": true, "index": index, "time": time})))
+            }
+            crate::panels::DragPayload::Effect(_) | crate::panels::DragPayload::Preset { .. } | crate::panels::DragPayload::Property { .. } => {}
         }
     }
 }
 
 /// The optional per-layer cells: Comment (double-click to edit), In / Out / Duration (drag to
 /// change by frames), Stretch (click: Time Stretch dialog).
-#[allow(clippy::too_many_arguments)]
 fn layer_cells(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     lp: &egui::Painter,
     cw: &Cols,
     comp: &Comp,
-    layer: &Layer,
-    r: Rect,
-    cy: f32,
+    rc: &RowCtx<'_>,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (layer, r, cy) = (rc.layer, rc.r, rc.cy);
     let t = app.tokens;
     let ctx = ui.ctx().clone();
     let lid = layer.id.0;
@@ -3863,17 +4917,16 @@ fn find_group(g: &PropGroup, uid: u64) -> Option<&PropGroup> {
 }
 
 /// A stroke's Dashes "+" / "−" buttons (add or remove a Dash/Gap pair).
-#[allow(clippy::too_many_arguments)]
 fn dash_buttons(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     if !matches!(layer.source, LayerSource::Shape) || layer.props.find_group(uid).is_none_or(|g| g.match_id != "dashes") {
         return;
     }
@@ -3926,17 +4979,16 @@ const SHAPE_ADD_ITEMS: &[(&str, &str)] = &[
 
 /// A shape layer's Contents "Add:" pop-up menu: adds a shape, paint or path operation to the
 /// selected shape group, else to the layer's contents.
-#[allow(clippy::too_many_arguments)]
 fn shape_add_popup(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     if !matches!(layer.source, LayerSource::Shape) || layer.props.sub("contents").is_none_or(|c| c.uid != uid) {
         return;
     }
@@ -3969,17 +5021,16 @@ fn shape_add_popup(
 }
 
 /// The Text group's "Animate:" and an animator's "Add:" pop-up menus (AE's twirl-down menus).
-#[allow(clippy::too_many_arguments)]
 fn text_anim_popups(
     app: &mut EffectcraftApp,
     ui: &mut egui::Ui,
     p: &egui::Painter,
     layer: &Layer,
     uid: u64,
-    x: f32,
-    cy: f32,
+    at: Pos2,
     actions: &mut Vec<(String, serde_json::Value)>,
 ) {
+    let (x, cy) = (at.x, at.y);
     use effectcraft_engine::project::build::TEXT_ANIMATOR_KINDS;
     if !matches!(layer.source, LayerSource::Text) {
         return;
@@ -4160,7 +5211,6 @@ impl LayerMenuCaps {
 /// A layer row's or bar's context menu. As in After Effects, right-clicking a layer that is part
 /// of the selection keeps the selection and the menu acts on all of it; right-clicking an
 /// unselected layer selects only that layer.
-#[allow(clippy::too_many_arguments)]
 fn layer_context_menu(
     resp: &egui::Response,
     comp: &Comp,
@@ -4337,200 +5387,270 @@ fn fmt_num(v: f64, d: usize) -> String {
     format!("{v:.d$}")
 }
 
-/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
-/// multi-dimension value's fields, one per dimension (none for other values).
-fn value_editor(
-    app: &mut EffectcraftApp,
-    ui: &mut egui::Ui,
-    p: &egui::Painter,
-    layer: &Layer,
-    prop: &Property,
-    value: &Value,
+/// What the property value editors share: where to draw, what they edit, and how a change is
+/// worded as a `prop.set` command.
+struct ValueEdit<'a> {
+    p: &'a egui::Painter,
+    layer: &'a Layer,
+    prop: &'a Property,
     at: Pos2,
-    actions: &mut Vec<(String, serde_json::Value)>,
-) -> Vec {
-    let t = app.tokens;
-    let uid = prop.uid;
-    let mut dims = vec![];
-    let merge = format!("scrub-{uid}");
-    // With several layers selected the edit applies to all of them (#491): a scrub by the same
-    // change, a typed value as it is.
-    let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
-    // Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
-    // selected layers by selection order, the first unchanged and the last by all of it.
-    let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
-    let set = |actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value| {
+    uid: u64,
+    merge: String,
+    scrub: bool,
+    proportional: bool,
+}
+
+impl<'a> ValueEdit<'a> {
+    /// The editor of `prop` on `layer` at `at`; `ui` says whether a scrub is under way.
+    fn new(ui: &egui::Ui, p: &'a egui::Painter, layer: &'a Layer, prop: &'a Property, at: Pos2) -> Self {
+        let uid = prop.uid;
+        // With several layers selected the edit applies to all of them (#491): a scrub by the same
+        // change, a typed value as it is.
+        let scrub = ui.ctx().dragged_id().is_some() && ui.input(|i| i.pointer.is_decidedly_dragging());
+        // Proportional Scrubbing (After Effects 26.2): Ctrl+Alt (Cmd+Opt) spreads the change over the
+        // selected layers by selection order, the first unchanged and the last by all of it.
+        let proportional = scrub && ui.input(|i| widgets::proportional_scrub(i.modifiers));
+        Self { p, layer, prop, at, uid, merge: format!("scrub-{uid}"), scrub, proportional }
+    }
+
+    /// Queues the `prop.set` of one edit.
+    fn set(&self, actions: &mut Vec<(String, serde_json::Value)>, v: serde_json::Value) {
         actions.push((
             "prop.set".into(),
-            json!({"layer": layer.id.0, "prop": uid, "value": v, "merge": merge, "selected": true, "offset": scrub, "proportional": proportional}),
+            json!({"layer": self.layer.id.0, "prop": self.uid, "value": v, "merge": self.merge, "selected": true, "offset": self.scrub, "proportional": self.proportional}),
         ))
-    };
-    let is_3d = layer.is_3d();
-    let mut x = at.x;
-    let y = at.y - 9.0;
+    }
+}
+
+/// Inline value editor for a property row; pushes `prop.set` actions. Returns the rects of a
+/// multi-dimension value's fields, one per dimension (none for other values).
+fn value_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, value: &Value, actions: &mut Vec<(String, serde_json::Value)>) -> Vec {
     match value {
-        Value::Scalar(v) => {
-            let (min, max, dec, speed) = match &prop.ui {
-                ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
-                ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
-                _ => (-1e9, 1e9, 1, 1.0),
-            };
-            if matches!(prop.ui, ParamUi::Angle) {
-                let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
-                app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
-                if let Some(nv) = nv {
-                    set(actions, json!(nv));
-                }
-            } 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::unique(("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 {
-                    set(actions, json!(nv));
-                }
-            }
-        }
-        Value::Vec2(_) | Value::Vec3(_) => {
-            let c = value.components();
-            let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
-            let pct = matches!(prop.ui, ParamUi::Percent);
-            // Mask Feather: two linked values, never negative (as in After Effects, #203).
-            let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
-            let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
-            // Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
-            let linkable = feather || (pct && prop.match_id == "scale");
-            let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
-            if linkable {
-                let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
-                let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
-                icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
-                app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
-                if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
-                    app.ui.timeline.unlinked.insert(uid);
-                }
-                x += 18.0;
-            }
-            for d in 0..n.min(c.len()) {
-                let suffix = if pct && d + 1 == n { "%" } else { "" };
-                let (r, nv, _) =
-                    widgets::hot_number_at(ui, pos2(x, y), egui::Id::unique(("v", uid, d)), c[d], if pct { 0.5 } else { 1.0 }, range, 1, suffix, &t);
-                app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
-                dims.push(r);
-                if let Some(nv) = nv {
-                    let mut nc = c.clone();
-                    // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
-                    let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
-                    let from = if shown == 1 { d } else { 0 };
-                    for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
-                        if let Some(slot) = nc.get_mut(from + e) {
-                            *slot = v;
-                        }
-                    }
-                    set(actions, json!(nc));
-                    // Typed into these components only: other selected layers keep the rest of
-                    // their own value (#1434).
-                    if let Some((_, a)) = actions.last_mut() {
-                        a["dims"] = json!((from..from + shown).collect::>());
-                    }
-                }
-                x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
-                if d + 1 < n {
-                    p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text);
-                    x += 6.0;
-                }
-            }
-        }
-        Value::Color(c) => {
-            let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
-            let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-            let pop = egui::Id::unique(("cpop", uid));
-            if resp.clicked() {
-                widgets::open_popup(ui, pop);
-            }
-            // The eyedropper after the swatch samples the Composition panel (shape Fill and
-            // Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
-            let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
-            let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
-            if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
-                .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
-                .clicked()
-            {
-                super::effect_controls::toggle_pick(app, "color", layer, prop);
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
-            let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
-            if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
-                set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
-            }
-        }
-        Value::Bool(b) => {
-            let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
-            if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
-                set(actions, json!(!b));
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-        }
-        Value::Enum(i) => {
-            // Text Path Options ▸ Path lists the layer's masks.
-            let mask_opts: Option> = layer
-                .props
-                .prop("text/pathOptions/path")
-                .filter(|q| q.uid == uid)
-                .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
-            let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
-            if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
-                let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
-                let label = options.get(*i as usize).cloned().unwrap_or_default();
-                let pop = egui::Id::unique(("epop", uid));
-                if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
-                    widgets::open_popup(ui, pop);
-                }
-                app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-                if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) {
-                    set(actions, json!(ni));
-                }
-            } else {
-                p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
-            }
-        }
+        Value::Scalar(_) => scalar_editor(app, ui, ve, actions, value),
+        Value::Vec2(_) | Value::Vec3(_) => return vector_editor(app, ui, ve, actions, value),
+        Value::Color(_) => color_editor(app, ui, ve, actions, value),
+        Value::Bool(_) => bool_editor(app, ui, ve, actions, value),
+        Value::Enum(_) => enum_editor(app, ui, ve, actions, value),
+        Value::Layer(_) => layer_editor(app, ui, ve, actions, value),
+        Value::Gradient(_) => gradient_editor(app, ui, ve, actions),
         Value::Path(_) => {
-            p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), t.hot_text);
+            ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, crate::i18n::tr("Shape…"), Tokens::ui(12.0), app.tokens.hot_text);
         }
         Value::Text(_) => {}
-        Value::Layer(l) => {
-            // Layer references (a light's Source, a Displacement Map's layer…) pick from the
-            // comp's layers, as in Effect Controls; the choice is written through the same
-            // undoable prop.set as the other value arms.
-            let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
-            if let Some(comp) = app.session.active_comp_arc()
-                && let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
-            {
-                set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-        }
-        Value::Gradient(_) => {
-            // Opens the Gradient Editor at the current time (#612).
-            let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
-            let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
-            if resp.hovered() {
-                ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
-            }
-            app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
-            if resp.clicked() {
-                actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
-            }
-        }
         Value::Str(s) => {
-            p.text(pos2(x, at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), t.text_dim);
+            ve.p.text(pos2(ve.at.x, ve.at.y), Align2::LEFT_CENTER, s.chars().take(30).collect::(), Tokens::ui(12.0), app.tokens.text_dim);
+        }
+    }
+    Vec::new()
+}
+
+/// A number: scrubbable hot text, or the angle field.
+fn scalar_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Scalar(v) = value else { return };
+    let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let (x, y) = (at.x, at.y - 9.0);
+    let (min, max, dec, speed) = match &prop.ui {
+        ParamUi::Slider { min, max, decimals, .. } => (*min, *max, *decimals as usize, widgets::slider_scrub_speed(&prop.ui)),
+        ParamUi::Percent => (-1e6, 1e6, 1, 0.5),
+        _ => (-1e9, 1e9, 1, 1.0),
+    };
+    if matches!(prop.ui, ParamUi::Angle) {
+        let (rr, dr, nv) = super::fx_widgets::angle_field(ui, pos2(x, y), egui::Id::unique(("v", uid, 0)), *v, 1, &t);
+        app.auto.add(&format!("timeline.prop.{uid}.value"), dr, &prop.name);
+        app.auto.add(&format!("timeline.prop.{uid}.revolutions"), rr, &prop.name);
+        if let Some(nv) = nv {
+            ve.set(actions, json!(nv));
+        }
+    } 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::unique(("v", uid, 0)),
+            *v,
+            &widgets::HotSpec { speed, range: (min, max), decimals: dec.max(1), suffix },
+            &t,
+        );
+        app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+        if let Some(nv) = nv {
+            ve.set(actions, json!(nv));
+        }
+    }
+}
+
+/// A multi-dimension value: one hot-text field per dimension, with the link chain. Returns their rects.
+fn vector_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) -> Vec {
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let (mut x, y) = (at.x, at.y - 9.0);
+    let is_3d = layer.is_3d();
+    let mut dims = vec![];
+    let c = value.components();
+    let n = if prop.shown_dims > 0 && !(is_3d && c.len() == 3) { prop.shown_dims as usize } else { c.len() };
+    let pct = matches!(prop.ui, ParamUi::Percent);
+    // Mask Feather: two linked values, never negative (as in After Effects, #203).
+    let feather = prop.match_id == "feather" && layer.props.parent_of(uid).is_some_and(|g| matches!(g.kind, GroupKind::Mask { .. }));
+    let range = if feather { (0.0, 1e9) } else { (-1e9, 1e9) };
+    // Scale and Mask Feather: the chain link (Constrain Proportions, on by default).
+    let linkable = feather || (pct && prop.match_id == "scale");
+    let linked = linkable && !app.ui.timeline.unlinked.contains(&uid);
+    if linkable {
+        let lr = Rect::from_center_size(pos2(x + 7.0, at.y), vec2(14.0, 14.0));
+        let resp = ui.interact(lr, egui::Id::unique(("tl-link", uid)), Sense::click()).on_hover_text(crate::i18n::tr("Constrain Proportions"));
+        icons::paint(p, lr, Icon::Link, if linked { t.accent } else { t.text_faint });
+        app.auto.add(&format!("timeline.prop.{uid}.link"), lr, "Constrain Proportions");
+        if resp.clicked() && !app.ui.timeline.unlinked.remove(&uid) {
+            app.ui.timeline.unlinked.insert(uid);
+        }
+        x += 18.0;
+    }
+    for d in 0..n.min(c.len()) {
+        let suffix = if pct && d + 1 == n { "%" } else { "" };
+        let (r, nv, _) = widgets::hot_number_at(
+            ui,
+            pos2(x, y),
+            egui::Id::unique(("v", uid, d)),
+            c[d],
+            &widgets::HotSpec { speed: if pct { 0.5 } else { 1.0 }, range, decimals: 1, suffix },
+            &t,
+        );
+        app.auto.add(&format!("timeline.prop.{uid}.value.{d}"), r, &prop.name);
+        dims.push(r);
+        if let Some(nv) = nv {
+            let mut nc = c.clone();
+            // Alt edits one value of a linked pair (Ctrl+Alt is a proportional scrub).
+            let shown = if linked && !ui.input(|i| i.modifiers.alt && !widgets::proportional_scrub(i.modifiers)) { n.min(c.len()) } else { 1 };
+            let from = if shown == 1 { d } else { 0 };
+            for (e, v) in widgets::constrained(c.get(from..from + shown).unwrap_or_default(), d - from, nv).into_iter().enumerate() {
+                if let Some(slot) = nc.get_mut(from + e) {
+                    *slot = v;
+                }
+            }
+            ve.set(actions, json!(nc));
+            // Typed into these components only: other selected layers keep the rest of
+            // their own value (#1434).
+            if let Some((_, a)) = actions.last_mut() {
+                a["dims"] = json!((from..from + shown).collect::>());
+            }
+        }
+        x = r.max.x + if d + 1 < n { 2.0 } else { 0.0 };
+        if d + 1 < n {
+            p.text(pos2(x, at.y), Align2::LEFT_CENTER, ",", Tokens::ui(12.0), t.hot_text);
+            x += 6.0;
         }
     }
     dims
 }
 
+/// A colour: swatch, eyedropper and picker.
+fn color_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Color(c) = value else { return };
+    let (layer, prop, at, uid) = (ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    let r = Rect::from_min_size(pos2(x, at.y - 7.0), vec2(26.0, 14.0));
+    let resp = widgets::swatch(ui, r, [c[0] as f32, c[1] as f32, c[2] as f32, 1.0], egui::Id::unique(("sw", uid)), &t);
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+    let pop = egui::Id::unique(("cpop", uid));
+    if resp.clicked() {
+        widgets::open_popup(ui, pop);
+    }
+    // The eyedropper after the swatch samples the Composition panel (shape Fill and
+    // Stroke colours, text and effect colours), as in After Effects' Timeline (#633).
+    let er = Rect::from_center_size(pos2(r.max.x + 12.0, at.y), vec2(16.0, 16.0));
+    let active = app.ui.fx_pick.as_ref().is_some_and(|k| k.prop == uid && k.kind == "color");
+    if super::fx_widgets::eyedropper_button(ui, er, active, egui::Id::unique(("tl-eye", uid)), &t)
+        .on_hover_text(crate::i18n::tr("Eyedropper: click, then click in the Composition panel"))
+        .clicked()
+    {
+        super::effect_controls::toggle_pick(app, "color", layer, prop);
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.eyedropper"), er, &prop.name);
+    let mut rgb = [c[0] as f32, c[1] as f32, c[2] as f32];
+    if crate::widgets::color_popup(ui, pop, r.left_bottom(), &mut rgb) {
+        ve.set(actions, json!([rgb[0], rgb[1], rgb[2], 1.0]));
+    }
+}
+
+/// A checkbox.
+fn bool_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Bool(b) = value else { return };
+    let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    let r = Rect::from_min_size(pos2(x, at.y - 8.0), vec2(16.0, 16.0));
+    if widgets::checkbox(ui, r, *b, &t, egui::Id::unique(("cb", uid))).clicked() {
+        ve.set(actions, json!(!b));
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+}
+
+/// A popup (or the number when the property has no options).
+fn enum_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Enum(i) = value else { return };
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Text Path Options ▸ Path lists the layer's masks.
+    let mask_opts: Option> = layer
+        .props
+        .prop("text/pathOptions/path")
+        .filter(|q| q.uid == uid)
+        .map(|_| std::iter::once("None".to_string()).chain(layer.masks().into_iter().flat_map(|m| m.groups().map(|g| g.name.clone()))).collect());
+    let ui_opts = mask_opts.map(|options| ParamUi::Popup { options });
+    if let ParamUi::Popup { options } = ui_opts.as_ref().unwrap_or(&prop.ui) {
+        let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
+        let label = options.get(*i as usize).cloned().unwrap_or_default();
+        let pop = egui::Id::unique(("epop", uid));
+        if widgets::dropdown(ui, r, &label, &t, egui::Id::unique(("en", uid))).clicked() {
+            widgets::open_popup(ui, pop);
+        }
+        app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+        if let Some(ni) = widgets::popup_menu(ui, pop, r.left_bottom(), options, Some(*i as usize)) {
+            ve.set(actions, json!(ni));
+        }
+    } else {
+        p.text(pos2(x, at.y), Align2::LEFT_CENTER, fmt_num(*i as f64, 0), Tokens::ui(12.0), t.hot_text);
+    }
+}
+
+/// A layer reference picker.
+fn layer_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>, value: &Value) {
+    let Value::Layer(l) = value else { return };
+    let (prop, at, uid) = (ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Layer references (a light's Source, a Displacement Map's layer…) pick from the
+    // comp's layers, as in Effect Controls; the choice is written through the same
+    // undoable prop.set as the other value arms.
+    let r = Rect::from_min_size(pos2(x, at.y - 9.0), vec2(150.0, 18.0));
+    if let Some(comp) = app.session.active_comp_arc()
+        && let Some(chosen) = widgets::layer_picker(ui, r, &comp.layers, *l, &t, egui::Id::unique(("tl-lay", uid)))
+    {
+        ve.set(actions, chosen.map(|id| json!(id)).unwrap_or(serde_json::Value::Null));
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+}
+
+/// Opens the Gradient Editor at the current time (#612).
+fn gradient_editor(app: &mut EffectcraftApp, ui: &mut egui::Ui, ve: &ValueEdit<'_>, actions: &mut Vec<(String, serde_json::Value)>) {
+    let (p, layer, prop, at, uid) = (ve.p, ve.layer, ve.prop, ve.at, ve.uid);
+    let t = app.tokens;
+    let x = at.x;
+    // Opens the Gradient Editor at the current time (#612).
+    let r = p.text(pos2(x, at.y), Align2::LEFT_CENTER, crate::i18n::tr("Edit Gradient…"), Tokens::ui(12.0), t.hot_text);
+    let resp = ui.interact(r.expand(2.0), egui::Id::unique(("gradient-edit", uid)), Sense::click());
+    if resp.hovered() {
+        ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
+    }
+    app.auto.add(&format!("timeline.prop.{uid}.value"), r, &prop.name);
+    if resp.clicked() {
+        actions.push(("prop.editGradient".into(), json!({"layer": layer.id.0, "prop": uid})));
+    }
+}
+
 #[cfg(test)]
 mod tests;
diff --git a/crates/ui-egui/src/panels/tracker.rs b/crates/ui-egui/src/panels/tracker.rs
index f1c5b8b25..3edde09df 100644
--- a/crates/ui-egui/src/panels/tracker.rs
+++ b/crates/ui-egui/src/panels/tracker.rs
@@ -115,6 +115,32 @@ fn analyze_glyph(p: &egui::Painter, r: Rect, forward: bool, frame: bool, stop: b
     }
 }
 
+/// The two button rows at `y`: Track Camera / Warp Stabilizer, then Track Motion / Stabilize Motion.
+fn top_buttons(app: &mut EffectcraftApp, ui: &mut egui::Ui, ctx: &egui::Context, x0: f32, y: f32, half: f32, source: Option) {
+    let can_track = app.session.active_comp().is_some() && source.is_some();
+    // Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
+    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
+    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
+    let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
+    let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
+    if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
+        run(app, ctx, "track.camera", json!({}));
+    }
+    let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
+    if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
+        run(app, ctx, "track.warpStabilizer", json!({}));
+    }
+    let y = y + 28.0;
+    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
+    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
+    if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
+        run(app, ctx, "track.motion", src_p.clone());
+    }
+    if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
+        run(app, ctx, "track.stabilize", src_p.clone());
+    }
+}
+
 /// The Tracker panel: the camera/warp and motion/stabilize buttons, Motion Source and Current
 /// Track pickers, the track type and its channels, Motion Target with Edit Target and Options, the
 /// four Analyze buttons (the one whose direction is running offers Stop), Reset and Apply, and the
@@ -132,33 +158,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let w = (rect.width() - 20.0).max(120.0);
     let half = (w - 6.0) / 2.0;
     let mut y = rect.min.y + 10.0;
-    let has_comp = app.session.active_comp().is_some();
     let source = motion_source(app);
     let cur = current(app);
-    let can_track = has_comp && source.is_some();
 
-    // Track Camera / Warp Stabilizer and Track Motion / Stabilize Motion.
-    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
-    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
-    let src_p = source.map(|l| json!({"layer": l.0})).unwrap_or_else(|| json!({}));
-    let can_camera = app.session.is_enabled("track.camera") && !app.session.is_camera_analyzing();
-    if button(app, ui, r1, "Track Camera", can_camera, "tracker.trackCamera") {
-        run(app, &ctx, "track.camera", json!({}));
-    }
-    let can_warp = app.session.is_enabled("track.warpStabilizer") && !app.session.is_warp_analyzing();
-    if button(app, ui, r2, "Warp Stabilizer", can_warp, "tracker.warpStabilizer") {
-        run(app, &ctx, "track.warpStabilizer", json!({}));
-    }
-    y += 28.0;
-    let r1 = Rect::from_min_size(pos2(x0, y), vec2(half, 22.0));
-    let r2 = Rect::from_min_size(pos2(x0 + half + 6.0, y), vec2(half, 22.0));
-    if button(app, ui, r1, "Track Motion", can_track, "tracker.trackMotion") {
-        run(app, &ctx, "track.motion", src_p.clone());
-    }
-    if button(app, ui, r2, "Stabilize Motion", can_track, "tracker.stabilizeMotion") {
-        run(app, &ctx, "track.stabilize", src_p.clone());
-    }
-    y += 34.0;
+    top_buttons(app, ui, &ctx, x0, y, half, source);
+    y += 62.0;
     let Some(comp) = app.session.active_comp_arc() else {
         p.text(pos2(x0, y + 8.0), Align2::LEFT_CENTER, crate::i18n::tr("Open a composition to track motion."), Tokens::ui(12.0), t.text_faint);
         return;
diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs
index a9770d9ed..bcae99bc5 100644
--- a/crates/ui-egui/src/panels/viewer.rs
+++ b/crates/ui-egui/src/panels/viewer.rs
@@ -72,6 +72,52 @@ thread_local! {
 /// (move/scale/rotate/anchor/camera/shape/mask/guide edits, picks, marquees); failures worth showing go to
 /// `app.ui.status`, while per-frame drag updates ignore errors so a gesture is not aborted mid-drag. With no active
 /// composition it draws `empty_state` and returns.
+/// What the passes of one viewer frame share (see [`show`]).
+struct Vw<'a> {
+    t: Tokens,
+    cid: effectcraft_engine::project::ItemId,
+    nav: Rect,
+    bar: Rect,
+    err_bar: Rect,
+    err_h: f32,
+    pasteboard: Color32,
+    area: Rect,
+    outer: Rect,
+    comp_rect: Rect,
+    zoom: f32,
+    ppp: f32,
+    cw: f32,
+    ch: f32,
+    map: ViewerMap,
+    time: Tick,
+    overlays: bool,
+    mods: egui::Modifiers,
+    space_pan: bool,
+    text_owns: bool,
+    gid: egui::Id,
+    track_cur: Option<(LayerId, effectcraft_engine::project::Uid)>,
+    has_3d: bool,
+    ctx: egui::Context,
+    comp_name: String,
+    p: egui::Painter,
+    painter: egui::Painter,
+    selected: Vec,
+    sel_vertices: Vec,
+    ft: Option,
+    comp: &'a Comp,
+    gesture: Option,
+    handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)>,
+    axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])>,
+    vertex_hits: Vec,
+    vertex_marks: Vec,
+    key_hits: Vec,
+    motion_frames: Vec,
+    paths: Vec,
+    track_hits: Vec,
+    pin_hits: Vec,
+    puppet_layers: Vec<(&'a Layer, super::puppet_tool::Overlay)>,
+}
+
 pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let t = app.tokens;
     let ctx = ui.ctx().clone();
@@ -83,7 +129,75 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let comp_name = app.session.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
     let p = ui.painter().clone();
     VIEW_CAM.with(|c| c.set(app.session.view_camera(cid)));
+    let mut v = Vw {
+        t,
+        cid,
+        nav: Rect::NOTHING,
+        bar: Rect::NOTHING,
+        err_bar: Rect::NOTHING,
+        err_h: 0.0,
+        pasteboard: Color32::BLACK,
+        area: Rect::NOTHING,
+        outer: Rect::NOTHING,
+        comp_rect: Rect::NOTHING,
+        zoom: 1.0,
+        ppp: 1.0,
+        cw: 0.0,
+        ch: 0.0,
+        map: ViewerMap { origin: Pos2::ZERO, zoom: 1.0, comp: [0.0; 2], area: Rect::NOTHING },
+        time: Tick::ZERO,
+        overlays: false,
+        mods: egui::Modifiers::NONE,
+        space_pan: false,
+        text_owns: false,
+        gid: egui::Id::unique("viewer-gesture"),
+        track_cur: None,
+        has_3d: false,
+        ctx: ctx.clone(),
+        comp_name,
+        painter: p.clone(),
+        p,
+        selected: vec![],
+        sel_vertices: vec![],
+        ft: None,
+        comp: &comp,
+        gesture: None,
+        handle_hits: vec![],
+        axis_hits: vec![],
+        vertex_hits: vec![],
+        vertex_marks: vec![],
+        key_hits: vec![],
+        motion_frames: vec![],
+        paths: vec![],
+        track_hits: vec![],
+        pin_hits: vec![],
+        puppet_layers: vec![],
+    };
+    phase_nav(app, ui, rect, &mut v);
+    phase_layout(app, ui, rect, &mut v);
+    phase_frame(app, &mut v);
+    let snap_project = app.session.project.clone();
+    let snap_expr = app.session.expr.clone();
+    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time: v.time, expr: snap_expr.as_deref(), footage: None };
+    phase_guides(app, &ectx, &mut v);
+    phase_layers(app, ui, &ectx, &mut v);
+    phase_scene(app, &ectx, &mut v);
+    phase_rigs(app, ui, &ectx, &mut v);
+    let resp = phase_menu(app, ui, &ectx, &mut v);
+    phase_hover(app, ui, &ectx, &resp, &mut v);
+    phase_press(app, ui, &ectx, &resp, &mut v);
+    phase_dragstart(app, ui, &ectx, &resp, &mut v);
+    phase_drag(app, ui, &ectx, &resp, &mut v);
+    phase_stopped(app, ui, &ectx, &resp, &mut v);
+    phase_clicked(app, &ectx, &resp, &mut v);
+    phase_dblclick(app, &ectx, &resp, &mut v);
+    phase_tail(app, ui, &ectx, &mut v);
+}
 
+fn phase_nav(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let p = v.p.clone();
     // Navigator bar (open comps as breadcrumbs). After Effects shows a single breadcrumb row:
     // when the Composition Navigator (nested comp flow) is above the viewer, this row is left
     // out.
@@ -116,7 +230,16 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         x = r.max.x + 4.0;
     }
+    v.nav = nav;
+}
 
+fn phase_layout(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let nav = v.nav;
+    let ctx = v.ctx.clone();
+    let p = v.p.clone();
+    let comp = v.comp;
     // Bottom control bar.
     let bar_h = 36.0;
     let bar = Rect::from_min_max(pos2(rect.min.x, rect.max.y - bar_h), rect.max);
@@ -128,7 +251,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     // View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
     // keeps the overlays and the interaction.
     let full = locked_pane(app, ui, full, pasteboard);
-    let area = aux_views(app, ui, &comp, cid, full, pasteboard);
+    let area = aux_views(app, ui, comp, cid, full, pasteboard);
     p.rect_filled(area, 0.0, pasteboard);
     // View ▸ Show Rulers takes a strip on the top and left.
     let outer = area;
@@ -151,8 +274,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let painter = p.with_clip_rect(area);
     // Settings ▸ 3D ▸ Extended Viewer: custom 3D views (and Draft 3D) render the visible
     // pasteboard too, so 3D layers reaching past the comp frame stay visible there.
-    app.ui.viewer.extended = extended_region(app, &comp, cid, map);
+    app.ui.viewer.extended = extended_region(app, comp, cid, map);
+    v.bar = bar;
+    v.err_bar = err_bar;
+    v.err_h = err_h;
+    v.pasteboard = pasteboard;
+    v.area = area;
+    v.outer = outer;
+    v.comp_rect = comp_rect;
+    v.zoom = zoom;
+    v.ppp = ppp;
+    v.cw = cw;
+    v.ch = ch;
+    v.map = map;
+    v.painter = painter;
+}
 
+fn phase_frame(app: &mut EffectcraftApp, v: &mut Vw<'_>) {
+    let cid = v.cid;
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let ppp = v.ppp;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // Frame.
     let scale = app.viewer_scale(zoom, ppp);
     if !app.playback.playing {
@@ -185,11 +330,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         let bg = comp.background;
         painter.rect_filled(comp_rect, 0.0, Color32::from_rgb((bg[0] * 255.0) as u8, (bg[1] * 255.0) as u8, (bg[2] * 255.0) as u8));
     }
-    let snap_project = app.session.project.clone();
-    let snap_expr = app.session.expr.clone();
-    let ectx = EvalCtx { project: &snap_project, comp_id: cid, comp: &comp, time, expr: snap_expr.as_deref(), footage: None };
+    v.time = time;
+}
+
+fn phase_guides(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let cid = v.cid;
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let comp_name = v.comp_name.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // The frame (or snapshot) through Show Channel and exposure; ROI frames cover the region.
-    vt::draw_frame(app, &ctx, &painter, comp_rect, cid, &ectx);
+    vt::draw_frame(app, &ctx, &painter, comp_rect, cid, ectx);
     if app.ui.viewer.extended.is_some() {
         // The comp frame outlined over the extended render.
         painter.rect_stroke(comp_rect, 0.0, Stroke::new(1.0, Color32::from_gray(150)), StrokeKind::Outside);
@@ -275,17 +430,30 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
         app.auto.add("viewer.regionOfInterest", r, "Region of interest");
     }
+    v.overlays = overlays;
+}
+
+fn phase_layers(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let mut handle_hits = std::mem::take(&mut v.handle_hits);
+    let mut axis_hits = std::mem::take(&mut v.axis_hits);
+    let mut vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let mut vertex_marks = std::mem::take(&mut v.vertex_marks);
+    let mut key_hits = std::mem::take(&mut v.key_hits);
+    let mut motion_frames = std::mem::take(&mut v.motion_frames);
+    let mut paths = std::mem::take(&mut v.paths);
+    let comp = v.comp;
     // Overlays + interaction.
     let selected = app.session.state.selected_layers.clone();
     // Bounding-box handles: (layer, handle, screen point, its resize cursor).
-    let mut handle_hits: Vec<(LayerId, usize, Pos2, egui::CursorIcon)> = Vec::new();
     // 3D gizmo arrows: (layer, grabbable screen segment, position change per comp px in x and y).
-    let mut axis_hits: Vec<(LayerId, [Pos2; 2], [f64; 3], [f64; 3])> = Vec::new();
-    let mut vertex_hits: Vec = Vec::new();
-    let mut vertex_marks: Vec = Vec::new();
-    let mut key_hits: Vec = Vec::new();
-    let mut motion_frames: Vec = Vec::new();
-    let mut paths: Vec = Vec::new();
     let sel_vertices = app.session.state.selected_vertices.clone();
     // Pen path in progress ends when the tool changes or its mask is gone; Esc / Enter also end
     // free transform.
@@ -308,7 +476,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for l in comp.layers.iter().filter(|l| {
             matches!(l.source, LayerSource::Null) && !selected.contains(&l.id) && l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo)
         }) {
-            let outline = layer_outline(&ectx, l);
+            let outline = layer_outline(ectx, l);
             if outline.len() > 2 {
                 let col = if label_handles { t.label(l.label) } else { t.accent };
                 painter.add(egui::Shape::closed_line(outline.into_iter().map(|p| map.to_screen(p)).collect(), Stroke::new(1.0, col)));
@@ -322,12 +490,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         for l in comp.layers.iter().filter(|l| selected.contains(&l.id) && l.is_active_at(time) && (l.switches.video || l.source.is_av())) {
             let col = if label_handles { t.label(l.label) } else { t.accent };
             // Motion path of animated position (keyframes and spatial tangents are draggable).
-            ov::motion_path(app, &painter, &map, &ectx, l, col, &mut key_hits, &mut motion_frames);
+            ov::motion_path(app, &painter, &map, ectx, l, col, &mut key_hits, &mut motion_frames);
             // Masks and shape paths.
             if app.ui.viewer.show_masks {
                 let pen = ui.data(|d| d.get_temp::<(LayerId, u64)>(pen_id()));
                 let hover = ui.input(|i| i.pointer.hover_pos());
-                ov::draw_paths(app, &painter, &map, &ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
+                ov::draw_paths(app, &painter, &map, ectx, l, col, &sel_vertices, pen, hover, &mut vertex_hits, &mut vertex_marks, &mut paths);
             }
             // Settings ▸ Previews ▸ Show Internal Wireframes: outlines of the layers inside a
             // selected collapsed precomp.
@@ -336,7 +504,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 && let effectcraft_engine::project::LayerSource::Comp { item } = &l.source
                 && let Some(nc) = app.session.project.comp(*item)
             {
-                let (outer, _) = l2c(&ectx, l);
+                let (outer, _) = l2c(ectx, l);
                 let nctx = EvalCtx::new(&app.session.project, *item, nc, ectx.nested_time(l));
                 for nl in nc.layers.iter().filter(|nl| nl.is_active_at(nctx.time) && !nl.is_3d()) {
                     if let Some((_, q, _)) = layer_quad(&nctx, nl) {
@@ -359,9 +527,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             if editing_path {
                 continue;
             }
-            let Some((m, q, _)) = layer_quad(&ectx, l) else {
+            let Some((m, q, _)) = layer_quad(ectx, l) else {
                 // A 3D layer reaching past the camera: the part of its box in front of it.
-                let outline = layer_outline(&ectx, l);
+                let outline = layer_outline(ectx, l);
                 if outline.len() > 2 {
                     painter.add(egui::Shape::closed_line(outline.iter().map(|p| map.to_screen(*p)).collect(), Stroke::new(1.0, col)));
                     continue;
@@ -396,10 +564,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // 3D axis gizmo.
                 if l.is_3d() {
                     let w = ectx.world_matrix(l);
-                    let pc = cam_state(&ectx).projection(cw as f64, ch as f64) * w;
+                    let pc = cam_state(ectx).projection(cw as f64, ch as f64) * w;
                     let a3 = effectcraft_engine::geom::vec3(a[0], a[1], a[2]);
                     let len = 60.0 / zoom as f64;
-                    let pinv = parent_world_inverse(&ectx, l);
+                    let pinv = parent_world_inverse(ectx, l);
                     for (axis, col, name) in [
                         (effectcraft_engine::geom::vec3(len, 0.0, 0.0), Color32::from_rgb(0xe0, 0x50, 0x50), "x"),
                         (effectcraft_engine::geom::vec3(0.0, len, 0.0), Color32::from_rgb(0x60, 0xd0, 0x60), "y"),
@@ -428,7 +596,31 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.vertex_marks = vertex_marks;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.selected = selected;
+    v.sel_vertices = sel_vertices;
+}
 
+fn phase_scene(app: &mut EffectcraftApp, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let time = v.time;
+    let painter = v.painter.clone();
+    let selected = v.selected.clone();
+    let vertex_marks = std::mem::take(&mut v.vertex_marks);
+    let mut track_hits = std::mem::take(&mut v.track_hits);
+    let comp = v.comp;
     // Path points last, over the boxes' handles (#1042).
     ov::draw_vertex_marks(&painter, &vertex_marks, app.session.prefs.general.path_point_size as f32 + 1.0);
 
@@ -437,7 +629,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     let has_3d = comp.layers.iter().any(|l| l.is_3d() && l.is_active_at(time));
     if has_3d && app.session.prefs.three_d.extended_viewer {
         for l in comp.layers.iter().filter(|l| l.is_3d() && l.is_active_at(time) && l.has_video()) {
-            let q = layer_outline(&ectx, l);
+            let q = layer_outline(ectx, l);
             if q.len() > 2 {
                 let sq: Vec = q.iter().map(|p| map.to_screen(*p)).collect();
                 let outside = sq.iter().any(|p| !comp_rect.contains(*p));
@@ -448,7 +640,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         }
     }
     if has_3d && app.session.prefs.three_d.show_reference_axes {
-        let pc = cam_state(&ectx).projection(cw as f64, ch as f64);
+        let pc = cam_state(ectx).projection(cw as f64, ch as f64);
         let o3 = effectcraft_engine::geom::vec3(cw as f64 / 2.0, ch as f64 / 2.0, 0.0);
         let o = pc.apply(o3);
         let corner = pos2(area.min.x + 34.0, area.max.y - 34.0);
@@ -467,7 +659,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 
     // Track points of the current track (Tracker panel) on its layer, while that layer is selected.
-    let mut track_hits = vec![];
     let track_cur = super::tracker::viewer_track(app);
     if app.layer_controls_visible()
         && let Some((tl, tu)) = track_cur
@@ -475,7 +666,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && let Some(layer) = comp.layer(tl)
         && layer.is_active_at(time)
     {
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         track_hits = super::tracker::draw_overlay(app, &painter, &map, &m, layer, tu, time);
     }
 
@@ -487,7 +678,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && let Some(layer) = comp.layer(wl)
         && layer.is_active_at(time)
     {
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
         let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
         let at = map.to_screen([c.x, c.y]);
@@ -509,7 +700,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         if notices >= 3 {
             break;
         }
-        let (m, _) = l2c(&ectx, layer);
+        let (m, _) = l2c(ectx, layer);
         let (w, h) = effectcraft_engine::render::source_size(&app.session.project, layer);
         let c = m.apply(effectcraft_engine::geom::vec2(w as f64 / 2.0, h as f64 / 2.0));
         let at = map.to_screen([c.x, c.y]);
@@ -522,14 +713,32 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         app.auto.add(&format!("viewer.footageProblem.{}", layer.id.0), r, &text);
         notices += 1;
     }
+    v.vertex_marks = vertex_marks;
+    v.track_hits = track_hits;
+    v.track_cur = track_cur;
+    v.has_3d = has_3d;
+}
 
+fn phase_rigs(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let area = v.area;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let selected = v.selected.clone();
+    let paths = std::mem::take(&mut v.paths);
+    let mut pin_hits = std::mem::take(&mut v.pin_hits);
+    let mut puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
     // Free transform box of a path (double-click a path with the Selection tool).
     let ft = ov::draw_free_transform(app, &ctx, &painter, &map, &paths);
 
     // Cameras and lights (wireframes) and the 3D view name.
     if comp.has_3d() {
         let through_active = VIEW_CAM.with(|c| c.get()).is_none();
-        draw_rigs(app, &painter, &map, &ectx, &selected, cam_state(&ectx), through_active, None);
+        draw_rigs(app, &painter, &map, ectx, &selected, cam_state(ectx), through_active, None);
         let view = app.session.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default();
         let label = match (view, comp.active_camera(time)) {
             (View3D::ActiveCamera, Some(c)) => format!("Active Camera ({})", c.name),
@@ -539,10 +748,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     }
 
     // Puppet pins and meshes of the selected layers (Puppet tools).
-    let mut pin_hits: Vec = vec![];
     // The selected layers with a Puppet effect, top of the stack first (the layers the Puppet
     // tools work on).
-    let mut puppet_layers: Vec<(&Layer, super::puppet_tool::Overlay)> = vec![];
     if app.ui.tool.puppet_kind().is_some() {
         let sel_props: Vec = app.session.state.selected_props.iter().map(|(_, u)| *u).collect();
         for lid in &selected {
@@ -550,7 +757,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 && let Some(ov) = super::puppet_tool::overlay(app, &ctx, cid, l, time)
             {
                 let hover = ui.input(|i| i.pointer.hover_pos());
-                pin_hits.extend(super::puppet_tool::draw(&painter, &map, &ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
+                pin_hits.extend(super::puppet_tool::draw(&painter, &map, ectx, l, &ov, app.session.state.puppet.show_mesh, &sel_props, hover));
                 puppet_layers.push((l, ov));
             }
         }
@@ -564,17 +771,21 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
-    // The layer a Puppet tool press works on: while selected layers are rigged, only those
-    // ([`super::puppet_tool::target`]); otherwise the layer under the pointer, selected.
-    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
-        if puppet_layers.is_empty() {
-            return pick(app, &ectx, cpt, false);
-        }
-        super::puppet_tool::target(&ectx, &puppet_layers, cpt, |l| layers_at(&ectx, cpt).any(|h| h.id == l.id))
-    };
+    v.paths = paths;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+    v.ft = ft;
+}
 
+fn phase_menu(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) -> egui::Response {
+    let cid = v.cid;
+    let area = v.area;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // Project items, files and effects dropped on the viewer.
-    super::viewer_drop::show(app, ui, &painter, &map, &ectx);
+    super::viewer_drop::show(app, ui, &painter, &map, ectx);
 
     // Interaction.
     let resp = ui.interact(area, egui::Id::unique("viewer-interact"), Sense::click_and_drag());
@@ -584,9 +795,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         // Picking an already selected layer preserves the group; an unselected hit
         // becomes the target, using the same visible/unlocked hit test as left-click.
-        pick(app, &ectx, map.to_comp(pos), false);
+        pick(app, ectx, map.to_comp(pos), false);
         let point = map.to_comp(pos);
-        let hits: Vec<(u64, String)> = layers_at(&ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
+        let hits: Vec<(u64, String)> = layers_at(ectx, point).map(|l| (l.id.0, l.name.clone())).collect();
         ui.data_mut(|d| d.insert_temp(menu_hits_id, hits));
     }
     let context_layers: Vec =
@@ -641,6 +852,26 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             app.ui.status = e;
         }
     }
+    resp
+}
+
+fn phase_hover(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let axis_hits = std::mem::take(&mut v.axis_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
+    let track_hits = std::mem::take(&mut v.track_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let comp = v.comp;
     // Path vertices and tangent handles take the press before the bounding-box handles (#1042).
     let vertex_at = |pos: Pos2, tangents: bool| -> Option {
         let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
@@ -675,7 +906,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // The tool draws its own crosshair (and the layer it would matte) while no box
                 // is being dragged.
                 if ui.data(|d| d.get_temp::(egui::Id::unique("viewer-gesture"))).is_none() {
-                    super::object_matte_tool::hover(app, &painter, &map, &ectx, hp);
+                    super::object_matte_tool::hover(app, &painter, &map, ectx, hp);
                 }
                 egui::CursorIcon::None
             }
@@ -689,7 +920,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             _ => {
                 if super::tracker::hit_at(&track_hits, hp).is_some() || key_hits.iter().any(|h| h.pos.distance(hp) < 6.0) {
                     egui::CursorIcon::Move
-                } else if let Some((_, vertical)) = vt::guide_at(app, &comp, &map, hp) {
+                } else if let Some((_, vertical)) = vt::guide_at(app, comp, &map, hp) {
                     if vertical { egui::CursorIcon::ResizeHorizontal } else { egui::CursorIcon::ResizeVertical }
                 } else if vertex_at(hp, true).or_else(|| vertex_at(hp, false)).is_some() {
                     // A path point under a box handle is grabbed first: no resize cursor.
@@ -719,6 +950,24 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     } else {
         app.pointer_comp = None;
     }
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.track_hits = track_hits;
+    v.pin_hits = pin_hits;
+    v.gid = gid;
+}
+
+fn phase_press(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let map = v.map;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
     app.ui.viewer.interacting = resp.dragged();
     let mods = ui.input(|i| i.modifiers);
     let space_pan = super::space_hand(&ctx);
@@ -730,7 +979,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         && ui.input(|i| i.pointer.primary_pressed())
         && let Some(pos) = ui.input(|i| i.pointer.press_origin())
     {
-        pen_press(app, ui, &ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
+        pen_press(app, ui, ectx, &map, &paths, ov::motion_frame_at(&motion_frames, &key_hits, pos), pos, mods);
     }
     // A pen click (press + release without a drag) ends its tangent gesture on release.
     if ui.input(|i| i.pointer.primary_released())
@@ -740,7 +989,45 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         ui.data_mut(|d| d.remove::(egui::Id::unique("viewer-gesture")));
     }
     // On-canvas text editing (Type tool, double-click a text layer) owns its clicks and drags.
-    let text_owns = super::viewer_text::hook(app, ui, &painter, &map, &ectx, &resp, space_pan);
+    let text_owns = super::viewer_text::hook(app, ui, &painter, &map, ectx, resp, space_pan);
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.mods = mods;
+    v.space_pan = space_pan;
+    v.text_owns = text_owns;
+}
+
+fn phase_dragstart(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let comp_rect = v.comp_rect;
+    let zoom = v.zoom;
+    let map = v.map;
+    let time = v.time;
+    let mods = v.mods;
+    let space_pan = v.space_pan;
+    let text_owns = v.text_owns;
+    let gid = v.gid;
+    let track_cur = v.track_cur;
+    let ft = std::mem::take(&mut v.ft);
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let axis_hits = std::mem::take(&mut v.axis_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let paths = std::mem::take(&mut v.paths);
+    let track_hits = std::mem::take(&mut v.track_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
+    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
+        if puppet_layers.is_empty() {
+            return pick(app, ectx, cpt, false);
+        }
+        super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
+    };
     if resp.drag_started()
         && !text_owns
         && let Some(pos) = resp.interact_pointer_pos()
@@ -760,8 +1047,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             Tool::Selection if key_hits.iter().any(|h| h.pos.distance(press) < 6.0) => {
                 ov::begin_key_drag(app, &key_hits, press, map.to_comp(press), mods.shift).map(Gesture::Overlay)
             }
-            Tool::Selection if vt::guide_at(app, &comp, &map, press).is_some() => {
-                vt::guide_at(app, &comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
+            Tool::Selection if vt::guide_at(app, comp, &map, press).is_some() => {
+                vt::guide_at(app, comp, &map, press).map(|(index, vertical)| Gesture::Overlay(ov::Drag::Guide { index, vertical }))
             }
             // Alt-drag a vertex with the Selection tool (or drag one with Convert Vertex): pull
             // new tangents out of it.
@@ -771,9 +1058,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     Gesture::Pen { layer: h.layer, mask: h.mask, index: h.index, vertex: h.vertex, inv, press }
                 })
             }
-            Tool::MaskFeather => ov::begin_feather(app, &ectx, &paths, &map, press).map(Gesture::Overlay),
-            Tool::Rotate => pick(app, &ectx, cpt, mods.shift).map(|l| {
-                let others = selected_others(app, &comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
+            Tool::MaskFeather => ov::begin_feather(app, ectx, &paths, &map, press).map(Gesture::Overlay),
+            Tool::Rotate => pick(app, ectx, cpt, mods.shift).map(|l| {
+                let others = selected_others(app, comp, l).into_iter().filter_map(|o| Some((o.id, ectx.f(o, o.transform()?, "rotation", 0.0)))).collect();
                 let layer = comp.layer(l).cloned();
                 let center = layer
                     .as_ref()
@@ -790,10 +1077,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     others,
                 }
             }),
-            Tool::PanBehind => pick(app, &ectx, cpt, false).and_then(|l| {
+            Tool::PanBehind => pick(app, ectx, cpt, false).and_then(|l| {
                 let layer = comp.layer(l)?;
                 let tr = layer.transform()?;
-                let (l2c, _) = l2c(&ectx, layer);
+                let (l2c, _) = l2c(ectx, layer);
                 let l2p = ectx.local_matrix(layer);
                 let l2p = Mat3([[l2p.0[0][0], l2p.0[0][1], 0.0], [l2p.0[1][0], l2p.0[1][1], 0.0], [0.0, 0.0, 1.0]]);
                 Some(Gesture::Anchor {
@@ -804,7 +1091,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     start: map.to_comp(press),
                     inv: l2c.inverse().unwrap_or(Mat3::IDENTITY),
                     l2p,
-                    own: effectcraft_engine::viewer::layer_targets(&ectx, layer, false, app.session.state.snap_features),
+                    own: effectcraft_engine::viewer::layer_targets(ectx, layer, false, app.session.state.snap_features),
                     anchor_only: mods.alt,
                 })
             }),
@@ -830,7 +1117,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     PinPart::Center => {}
                 }
                 let l = comp.layer(h.layer)?;
-                let inv = fx2c(&ectx, l).inverse()?;
+                let inv = fx2c(ectx, l).inverse()?;
                 if mods.command {
                     let lp = inv.apply(gv2(cpt[0], cpt[1]));
                     let began = ui.input(|i| i.time);
@@ -850,7 +1137,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let hit = super::tracker::hit_at(&track_hits, press);
                 track_cur.zip(hit).and_then(|((tl, tu), hit)| {
                     let layer = comp.layer(tl)?;
-                    let (m, _) = l2c(&ectx, layer);
+                    let (m, _) = l2c(ectx, layer);
                     super::tracker::begin_drag(layer, tu, time, &m, hit, map.to_comp(press)).map(|drag| Gesture::Track { layer: tl, tracker: tu, drag })
                 })
             }
@@ -890,11 +1177,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     let layer = comp.layer(lid).cloned();
                     layer.and_then(|layer| {
                         let tr = layer.transform()?;
-                        let (l2c, _) = l2c(&ectx, &layer);
+                        let (l2c, _) = l2c(ectx, &layer);
                         let inv = l2c.inverse()?;
                         let a = ectx.v3(&layer, tr, "anchor", [0.0; 3]);
                         let lp = inv.apply(gv2(cpt[0], cpt[1]));
-                        let hp = handle_point(effectcraft_engine::render::content_bounds(&ectx, &layer)?, hi);
+                        let hp = handle_point(effectcraft_engine::render::content_bounds(ectx, &layer)?, hi);
                         Some(Gesture::Scale {
                             layer: lid,
                             start_scale: ectx.v3(&layer, tr, "scale", [100.0; 3]),
@@ -904,13 +1191,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                             axes: handle_axes(hi),
                             l2c,
                             inv,
-                            others: selected_others(app, &comp, lid)
+                            others: selected_others(app, comp, lid)
                                 .into_iter()
                                 .filter_map(|o| Some((o.id, ectx.v3(o, o.transform()?, "scale", [100.0; 3]))))
                                 .collect(),
                         })
                     })
-                } else if let Some(l) = pick(app, &ectx, cpt, mods.shift) {
+                } else if let Some(l) = pick(app, ectx, cpt, mods.shift) {
                     let layers: Vec<(LayerId, [f64; 3], [f64; 3], [f64; 3])> = app
                         .session
                         .state
@@ -920,12 +1207,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                         .filter(|l| !l.switches.locked)
                         .filter_map(|l| {
                             let p0 = ectx.v3(l, l.transform()?, "position", [0.0; 3]);
-                            let (ax, ay) = drag_axes(&ectx, l);
+                            let (ax, ay) = drag_axes(ectx, l);
                             Some((l.id, p0, ax, ay))
                         })
                         .collect();
                     // Snap the grabbed layer's feature nearest the pointer (AE's snap handle).
-                    let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(&ectx, layer)).and_then(|f| {
+                    let snap_src = comp.layer(l).map(|layer| effectcraft_engine::viewer::layer_features(ectx, layer)).and_then(|f| {
                         let c = map.to_comp(press);
                         f.into_iter().min_by(|a, b| {
                             let da = (a[0] - c[0]).powi(2) + (a[1] - c[1]).powi(2);
@@ -954,6 +1241,25 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             ui.data_mut(|d| d.insert_temp(gid, g));
         }
     }
+    v.ft = ft;
+    v.handle_hits = handle_hits;
+    v.axis_hits = axis_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.paths = paths;
+    v.track_hits = track_hits;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+}
+
+fn phase_drag(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let t = v.t;
+    let zoom = v.zoom;
+    let map = v.map;
+    let mods = v.mods;
+    let gid = v.gid;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
     let gesture: Option = ui.data(|d| d.get_temp(gid));
     if let (Some(g), Some(pos)) = (gesture.clone(), resp.interact_pointer_pos()) {
         let cpt = map.to_comp(pos);
@@ -970,7 +1276,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let mut d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
                 if let Some(src) = snap_src {
                     let ids: Vec = layers.iter().map(|x| x.0).collect();
-                    let c = vt::snap(app, &ctx, &ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
+                    let c = vt::snap(app, &ctx, ectx, &map, &ids, &[[src[0] + d[0], src[1] + d[1]]], mods);
                     d = [d[0] + c[0], d[1] + c[1]];
                     if vt::snapping_on(app, mods) {
                         vt::draw_handle(&painter, map.to_screen([src[0] + d[0], src[1] + d[1]]));
@@ -987,7 +1293,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let f = drag_ratio(start_local, [lp.x - anchor[0], lp.y - anchor[1]], axes, uniform);
                 // The handle snaps (to the comp's and other layers' edges, corners and centres),
                 // so a layer scales exactly to them, e.g. to the comp's size.
-                let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, &ectx, &map, &[layer], &[p], mods));
+                let f = snap_handle(f, handle, anchor, &l2c, axes, uniform, |p| vt::snap(app, &ctx, ectx, &map, &[layer], &[p], mods));
                 for (lid, s0) in std::iter::once((layer, start_scale)).chain(others) {
                     let v = [s0[0] * f[0], s0[1] * f[1], s0[2]];
                     let _ = app.session.execute("prop.set", json!({"layer": lid.0, "path": "transform/scale", "value": v, "merge": merge}));
@@ -1013,7 +1319,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 let d = one_axis([cpt[0] - start[0], cpt[1] - start[1]], mods.shift);
                 let anchor_c = inv.inverse().map(|m| m.apply(gv2(start_anchor[0], start_anchor[1]))).unwrap_or(gv2(start[0], start[1]));
                 let want = [anchor_c.x + d[0], anchor_c.y + d[1]];
-                let sc = vt::snap_with(app, &ctx, &ectx, &map, &[layer], &[want], mods, &own);
+                let sc = vt::snap_with(app, &ctx, ectx, &map, &vt::SnapQuery { exclude: &[layer], sources: &[want], mods, extra: &own });
                 let a = inv.apply(gv2(start[0], start[1]));
                 let b = inv.apply(gv2(start[0] + d[0] + sc[0], start[1] + d[1] + sc[1]));
                 let dl = gv2(b.x - a.x, b.y - a.y);
@@ -1048,7 +1354,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             Gesture::Vertices { start, src, applied, inv, layer } => {
                 // The grabbed vertex snaps (to other layers' vertices and features, guides, grid).
                 let want = [cpt[0] - start[0], cpt[1] - start[1]];
-                let c = vt::snap(app, &ctx, &ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
+                let c = vt::snap(app, &ctx, ectx, &map, &[layer], &[[src[0] + want[0], src[1] + want[1]]], mods);
                 let total = [want[0] + c[0], want[1] + c[1]];
                 let d = inv.apply_vec(gv2(total[0] - applied[0], total[1] - applied[1]));
                 if d.x != 0.0 || d.y != 0.0 {
@@ -1067,7 +1373,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::WHITE), StrokeKind::Middle);
                 }
                 d => {
-                    let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, &ectx, &map, &[l], &[p], mods);
+                    let mut snapper = |a: &EffectcraftApp, p: [f64; 2], l: LayerId| vt::snap(a, &ctx, ectx, &map, &[l], &[p], mods);
                     if let Some(nd) = ov::update(app, &d, cpt, pos, mods, &merge, zoom, &mut snapper) {
                         ui.data_mut(|dd| dd.insert_temp(gid, Gesture::Overlay(nd)));
                     }
@@ -1148,6 +1454,20 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.gesture = gesture;
+}
+
+fn phase_stopped(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let comp_rect = v.comp_rect;
+    let map = v.map;
+    let time = v.time;
+    let mods = v.mods;
+    let gid = v.gid;
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let gesture = v.gesture.clone();
+    let comp = v.comp;
     if resp.drag_stopped() {
         if let (Some(g), Some(pos)) = (gesture, resp.interact_pointer_pos()) {
             match g {
@@ -1181,7 +1501,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                     }
                 }
                 Gesture::PuppetPin { layer, pin, .. } => super::puppet_tool::reveal_pin(app, layer, pin),
-                Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, &ectx, &map, start, Some(pos), mods.shift),
+                Gesture::ObjectMatteBox { start } => super::object_matte_tool::apply(app, ectx, &map, start, Some(pos), mods.shift),
                 Gesture::Create { tool, start } => {
                     let end = map.to_comp(pos);
                     create_shape(app, tool, start, end, mods);
@@ -1214,8 +1534,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 Gesture::Marquee { start } => {
                     let a = map.to_comp(start);
                     let b = map.to_comp(pos);
-                    let hits: Vec = selectable_layers(&comp, time)
-                        .filter(|l| layer_quad(&ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
+                    let hits: Vec = selectable_layers(comp, time)
+                        .filter(|l| layer_quad(ectx, l).is_some_and(|(_, q, _)| quad_intersects_box(&q, a, b)))
                         .map(|l| l.id.0)
                         .collect();
                     let _ = app.session.execute("layer.select", json!({"layers": hits, "toggle": mods.shift}));
@@ -1229,6 +1549,38 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             d.insert_temp(egui::Id::unique("viewer-drag-n"), n + 1);
         });
     }
+    v.vertex_hits = vertex_hits;
+    v.pin_hits = pin_hits;
+}
+
+fn phase_clicked(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let area = v.area;
+    let zoom = v.zoom;
+    let cw = v.cw;
+    let ch = v.ch;
+    let map = v.map;
+    let mods = v.mods;
+    let text_owns = v.text_owns;
+    let ctx = v.ctx.clone();
+    let ft = std::mem::take(&mut v.ft);
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let key_hits = std::mem::take(&mut v.key_hits);
+    let motion_frames = std::mem::take(&mut v.motion_frames);
+    let paths = std::mem::take(&mut v.paths);
+    let pin_hits = std::mem::take(&mut v.pin_hits);
+    let puppet_layers = std::mem::take(&mut v.puppet_layers);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
+    let puppet_layer = |app: &mut EffectcraftApp, cpt: [f64; 2]| -> Option {
+        if puppet_layers.is_empty() {
+            return pick(app, ectx, cpt, false);
+        }
+        super::puppet_tool::target(ectx, &puppet_layers, cpt, |l| layers_at(ectx, cpt).any(|h| h.id == l.id))
+    };
     if resp.clicked()
         && !text_owns
         && let Some(pos) = resp.interact_pointer_pos()
@@ -1285,13 +1637,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
                 // Clicking away from the free-transform box ends it.
                 ov::end_free_transform(&ctx);
             }
-            Tool::ObjectMatte => super::object_matte_tool::apply(app, &ectx, &map, pos, None, mods.shift),
+            Tool::ObjectMatte => super::object_matte_tool::apply(app, ectx, &map, pos, None, mods.shift),
             t if t.puppet_kind().is_some() => {
                 if let Some((h, _)) = super::puppet_tool::hit(&pin_hits, pos) {
                     // Click selects the pin; Shift-click adds or removes it.
                     let _ = app.session.execute("puppet.selectPins", json!({"layer": h.layer.0, "pins": [h.pin], "toggle": mods.shift}));
                 } else if let Some(l) = puppet_layer(app, cpt).and_then(|l| comp.layer(l))
-                    && let Some(inv) = fx2c(&ectx, l).inverse()
+                    && let Some(inv) = fx2c(ectx, l).inverse()
                 {
                     let lp = inv.apply(gv2(cpt[0], cpt[1]));
                     match app.session.execute("puppet.addPin", json!({"layer": l.id.0, "kind": t.puppet_kind(), "position": [lp.x, lp.y]})) {
@@ -1312,13 +1664,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
             // The Roto Brush paints in the Layer panel only: select as usual and say so.
             rt if rt.roto_kind(false).is_some() => {
-                if pick(app, &ectx, cpt, mods.shift).is_none() {
+                if pick(app, ectx, cpt, mods.shift).is_none() {
                     let _ = app.session.execute("edit.deselectAll", json!({}));
                 }
                 app.ui.status = crate::i18n::tr(ROTO_HINT).to_string();
             }
             _ => {
-                if pick(app, &ectx, cpt, mods.shift).is_none() {
+                if pick(app, ectx, cpt, mods.shift).is_none() {
                     let _ = app.session.execute("edit.deselectAll", json!({}));
                 } else if !mods.shift && !app.session.state.selected_vertices.is_empty() && ov::segment_at(&paths, &map, pos, 6.0).is_none() {
                     // A click away from the paths, even on their layer, deselects the mask
@@ -1328,6 +1680,28 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.ft = ft;
+    v.handle_hits = handle_hits;
+    v.vertex_hits = vertex_hits;
+    v.key_hits = key_hits;
+    v.motion_frames = motion_frames;
+    v.paths = paths;
+    v.pin_hits = pin_hits;
+    v.puppet_layers = puppet_layers;
+}
+
+fn phase_dblclick(app: &mut EffectcraftApp, ectx: &EvalCtx, resp: &egui::Response, v: &mut Vw<'_>) {
+    let map = v.map;
+    let text_owns = v.text_owns;
+    let ctx = v.ctx.clone();
+    let handle_hits = std::mem::take(&mut v.handle_hits);
+    let vertex_hits = std::mem::take(&mut v.vertex_hits);
+    let paths = std::mem::take(&mut v.paths);
+    let comp = v.comp;
+    let vertex_at = |pos: Pos2, tangents: bool| -> Option {
+        let reach = vertex_grab_radius(pos, handle_hits.iter().map(|(_, _, h, _)| *h));
+        vertex_hits.iter().rev().filter(|h| h.tangent.is_some() == tangents).find(|h| h.pos.distance(pos) < reach).copied()
+    };
     if resp.double_clicked()
         && !text_owns
         && app.ui.tool == Tool::Selection
@@ -1349,7 +1723,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
     {
         let cpt = map.to_comp(pos);
         let roto = app.ui.tool.roto_kind(false).is_some();
-        if let Some(l) = pick(app, &ectx, cpt, false).and_then(|l| comp.layer(l)) {
+        if let Some(l) = pick(app, ectx, cpt, false).and_then(|l| comp.layer(l)) {
             match &l.source {
                 effectcraft_engine::project::LayerSource::Comp { item } => app.session.open_comp(*item),
                 // With a Roto Brush tool, double-clicking a layer opens it in the Layer panel,
@@ -1365,15 +1739,34 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
             }
         }
     }
+    v.handle_hits = handle_hits;
+    v.vertex_hits = vertex_hits;
+    v.paths = paths;
+}
+
+fn phase_tail(app: &mut EffectcraftApp, ui: &mut egui::Ui, ectx: &EvalCtx, v: &mut Vw<'_>) {
+    let t = v.t;
+    let cid = v.cid;
+    let bar = v.bar;
+    let err_bar = v.err_bar;
+    let err_h = v.err_h;
+    let area = v.area;
+    let outer = v.outer;
+    let zoom = v.zoom;
+    let map = v.map;
+    let time = v.time;
+    let ctx = v.ctx.clone();
+    let painter = v.painter.clone();
+    let comp = v.comp;
     // 3D Camera Tracker points, targets and their menu (on top of the viewer's gestures while
     // the effect is selected), and its analysis banner.
-    super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    super::camera_tracker_ui::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Effect point controls and crosshair/eyedropper picks (on top of the viewer's gestures).
-    crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::effect_controls::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Selected shape gradients' start and end points (#612).
-    crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::gradient_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
     // Selected text Range Selectors' Start and End bars (#1011).
-    crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, &ectx, &|c, l| l2c(c, l).0);
+    crate::panels::text_range_handles::viewer_hook(app, ui, &painter, &map, ectx, &|c, l| l2c(c, l).0);
 
     // Status chips (render time / caching).
     if app.playback.playing && app.playback.waiting {
@@ -1388,14 +1781,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
         ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("viewer-video-off-chip"), r));
     }
 
-    vt::draw_snap(&ctx, &painter, &map, &ectx);
+    vt::draw_snap(&ctx, &painter, &map, ectx);
     vt::rulers(app, ui, &map, outer, area);
-    vt::bottom_bar(app, ui, bar, zoom, time, &comp);
+    vt::bottom_bar(app, ui, bar, zoom, time, comp);
     if err_h > 0.0 {
         super::expr_bar::draw(app, ui, err_bar, cid);
     }
 }
-
 pub(crate) use crate::widgets::hex_rgb;
 
 mod frame;
diff --git a/crates/ui-egui/src/panels/viewer/frame.rs b/crates/ui-egui/src/panels/viewer/frame.rs
index 215a067ac..ac467ecd5 100644
--- a/crates/ui-egui/src/panels/viewer/frame.rs
+++ b/crates/ui-egui/src/panels/viewer/frame.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{Align2, EffectcraftApp, Pos2, Rect, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, vec2, vt, widgets};
 
 /// Draw the no-composition viewer with New Composition and New Composition From Footage buttons, accepting Project
 /// items dropped on it to create the first composition.
diff --git a/crates/ui-egui/src/panels/viewer/geometry.rs b/crates/ui-egui/src/panels/viewer/geometry.rs
index 9c42cc5ef..847332bcf 100644
--- a/crates/ui-egui/src/panels/viewer/geometry.rs
+++ b/crates/ui-egui/src/panels/viewer/geometry.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{CameraState, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, VIEW_CAM, gv2, three_d, vec2};
 
 /// The camera the viewer shows (view camera or the comp's active camera).
 pub(super) fn cam_state(ctx: &EvalCtx) -> CameraState {
diff --git a/crates/ui-egui/src/panels/viewer/gesture.rs b/crates/ui-egui/src/panels/viewer/gesture.rs
index 72f6d14b5..db024e720 100644
--- a/crates/ui-egui/src/panels/viewer/gesture.rs
+++ b/crates/ui-egui/src/panels/viewer/gesture.rs
@@ -1,4 +1,4 @@
-use super::*;
+use super::{EffectcraftApp, LayerId, Mat3, Pos2, Rect, Tick, Tool, ViewerMap, ov, vec2};
 
 /// What a drag in the viewer is doing.
 #[derive(Clone, Debug)]
diff --git a/crates/ui-egui/src/panels/viewer/shapes.rs b/crates/ui-egui/src/panels/viewer/shapes.rs
index 0cf72bb52..395219a05 100644
--- a/crates/ui-egui/src/panels/viewer/shapes.rs
+++ b/crates/ui-egui/src/panels/viewer/shapes.rs
@@ -1,5 +1,8 @@
 use super::geometry::{l2c, layer_quad, point_in_quad};
-use super::*;
+use super::{
+    Color32, Comp, EffectcraftApp, EvalCtx, Layer, LayerId, LayerSource, Mat3, PaintKind, Pos2, Rect, Stroke, Tick, Tokens, Tool, ViewerMap, gv2, json, ov,
+    pos2,
+};
 
 /// Visible, unlocked layers eligible for viewer hit tests, in front-to-back order.
 /// Solo scope matches the renderer: any soloed AV layer activates it at every time (as in After
diff --git a/crates/ui-egui/src/panels/viewer/tools.rs b/crates/ui-egui/src/panels/viewer/tools.rs
index 3703d999d..8a3fbf0f3 100644
--- a/crates/ui-egui/src/panels/viewer/tools.rs
+++ b/crates/ui-egui/src/panels/viewer/tools.rs
@@ -1,6 +1,6 @@
 use super::geometry::{cam_state, l2c, parent_inverse};
 use super::gesture::{Gesture, pen_id};
-use super::*;
+use super::{CameraState, Color32, EffectcraftApp, EvalCtx, Layer, LayerId, Mat3, Pos2, Rect, Stroke, Tool, ViewerMap, gv2, json, ov, three_d, vec2, vt};
 
 /// The position change per comp pixel of drag (x, y) that moves a layer by `dir` when the
 /// pointer covers the axis' on-screen vector `s` (comp px); drag across the axis is ignored.
diff --git a/crates/ui-egui/src/panels/viewer/view.rs b/crates/ui-egui/src/panels/viewer/view.rs
index 1b8ef2292..30b3f4d00 100644
--- a/crates/ui-egui/src/panels/viewer/view.rs
+++ b/crates/ui-egui/src/panels/viewer/view.rs
@@ -1,5 +1,5 @@
 use super::tools::draw_rigs;
-use super::*;
+use super::{Align2, Color32, EffectcraftApp, EvalCtx, Rect, Sense, Stroke, StrokeKind, Tokens, View3D, ViewerMap, json, pos2, three_d, vec2, widgets};
 
 /// Draw the extra views of a 2- or 4-view layout and return the main view's rectangle.
 pub(super) fn aux_views(
diff --git a/crates/ui-egui/src/panels/viewer_text.rs b/crates/ui-egui/src/panels/viewer_text.rs
index f2becd9b1..ce387f6ca 100644
--- a/crates/ui-egui/src/panels/viewer_text.rs
+++ b/crates/ui-egui/src/panels/viewer_text.rs
@@ -810,6 +810,91 @@ fn typing_key(ui: &egui::Ui, bump: bool) -> String {
     format!("text-typing-{n}")
 }
 
+/// A press inside the edited text layer: grab a box handle, place the caret (shift extends from
+/// `shift_anchor`), or, outside the text, commit the edit. Returns the drag it starts, if any.
+fn press_on_edited(app: &mut EffectcraftApp, ui: &egui::Ui, map: &ViewerMap, e: &Edited, pos: Pos2, shift_anchor: Option) -> Option {
+    if let Some(h) = e.handles().iter().position(|h| e.to_screen(map, *h).distance(pos) < 7.0) {
+        let b = e.doc.box_size.unwrap_or([0.0; 2]);
+        return Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
+    }
+    if e.contains(map, pos) {
+        let ci = e.hit(map, pos);
+        exec(app, "text.setSelection", json!({"anchor": shift_anchor.unwrap_or(ci), "caret": ci}));
+        typing_key(ui, true);
+        return Some(Drag::Select { layer: e.layer.id });
+    }
+    // Clicking elsewhere commits the edit.
+    exec(app, "text.endEdit", json!({}));
+    None
+}
+
+/// A press with the Type tool that missed the edited text: edit the text layer under it, or
+/// start creating a new one.
+fn press_type_tool(app: &mut EffectcraftApp, ectx: &EvalCtx, map: &ViewerMap, pos: Pos2, tool: Tool) -> Drag {
+    if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
+        let ci = e.hit(map, pos);
+        exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
+        Drag::Select { layer: e.layer.id }
+    } else {
+        Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical }
+    }
+}
+
+/// The paragraph box after dragging `handle` by (`dx`, `dy`) in layer space.
+fn resized_box(start_box: [f64; 4], handle: usize, dx: f64, dy: f64) -> [f64; 4] {
+    let [mut x0, mut y0, w, h] = start_box;
+    let (mut x1, mut y1) = (x0 + w, y0 + h);
+    if matches!(handle, 0 | 6 | 7) {
+        x0 += dx;
+    }
+    if matches!(handle, 2..=4) {
+        x1 += dx;
+    }
+    if matches!(handle, 0..=2) {
+        y0 += dy;
+    }
+    if matches!(handle, 4..=6) {
+        y1 += dy;
+    }
+    [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)]
+}
+
+/// Keyboard routing for the text editor: while a session is live, keep the focus on it and feed
+/// it this frame's keys; otherwise release the IME and the focus.
+fn keyboard_focus(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
+    if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
+        // Register after pointer handling so new edits take focus in this frame. An inactive
+        // editor must not take Tab focus and immediately surrender it before viewer controls.
+        ui.interact(Rect::from_min_size(rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
+        ui.memory_mut(|m| {
+            m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
+        });
+        // A session started elsewhere (menu, agent, Properties) takes the keyboard once…
+        let seen = ime_seen_id(ui.ctx().viewport_id());
+        let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
+        // …and gets it back when a click in the viewer or a formatting panel left nothing holding
+        // it; not merely because nothing has the focus (#831).
+        let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
+        if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain {
+            ui.data_mut(|d| d.insert_temp(seen, key));
+            ui.memory_mut(|m| m.request_focus(focus_id()));
+        }
+        if ui.memory(|m| m.has_focus(focus_id())) {
+            keyboard(app, ui, rect);
+        }
+    } else {
+        // Resolve the viewport before taking the data lock: `viewport_id()`
+        // acquires the same context lock that `data_mut` holds.
+        let viewport = ui.ctx().viewport_id();
+        ui.data_mut(|d| d.insert_temp::>(ime_seen_id(viewport), None));
+        invalidate_ime_composition(ui, app);
+        clear_preedit(ui);
+        if ui.memory(|m| m.has_focus(focus_id())) {
+            ui.memory_mut(|m| m.surrender_focus(focus_id()));
+        }
+    }
+}
+
 /// Viewer hook: draws the edit overlay and handles text pointer and keyboard input. Returns
 /// true when it owns this frame's pointer interaction (the viewer then skips its own gestures).
 pub fn hook(
@@ -858,7 +943,6 @@ pub fn hook(
         }
     });
     let pointer = ui.input(|i| i.pointer.interact_pos());
-    let handle_hit = |e: &Edited, s: Pos2| e.handles().iter().position(|h| e.to_screen(map, *h).distance(s) < 7.0);
 
     // ---- press
     if pressed
@@ -870,30 +954,14 @@ pub fn hook(
     {
         let mut handled = false;
         if let Some(e) = &cur {
-            if let Some(h) = handle_hit(e, pos) {
-                let b = e.doc.box_size.unwrap_or([0.0; 2]);
-                drag = Some(Drag::Resize { layer: e.layer.id, handle: h, start_box: [e.doc.box_pos[0], e.doc.box_pos[1], b[0], b[1]] });
+            let shift_anchor = if mods.shift { Some(editing.as_ref().map_or_else(|| e.hit(map, pos), |sel| sel.anchor)) } else { None };
+            if let Some(d) = press_on_edited(app, ui, map, e, pos, shift_anchor) {
+                drag = Some(d);
                 handled = true;
-            } else if e.contains(map, pos) {
-                let ci = e.hit(map, pos);
-                let anchor = if mods.shift { editing.as_ref().map_or(ci, |sel| sel.anchor) } else { ci };
-                exec(app, "text.setSelection", json!({"anchor": anchor, "caret": ci}));
-                typing_key(ui, true);
-                drag = Some(Drag::Select { layer: e.layer.id });
-                handled = true;
-            } else {
-                // Clicking elsewhere commits the edit.
-                exec(app, "text.endEdit", json!({}));
             }
         }
         if !handled && type_tool {
-            if let Some(e) = text_layer_at(app, ectx, map, pos, true) {
-                let ci = e.hit(map, pos);
-                exec(app, "text.edit", json!({"layer": e.layer.id.0, "caret": ci}));
-                drag = Some(Drag::Select { layer: e.layer.id });
-            } else {
-                drag = Some(Drag::Create { start: map.to_comp(pos), vertical: tool == Tool::TypeVertical });
-            }
+            drag = Some(press_type_tool(app, ectx, map, pos, tool));
             handled = true;
         }
         if handled {
@@ -948,22 +1016,7 @@ pub fn hook(
             {
                 let (px, py) = e.to_layer(map, p);
                 let (qx, qy) = e.to_layer(map, q);
-                let (dx, dy) = (px - qx, py - qy);
-                let [mut x0, mut y0, w, h] = start_box;
-                let (mut x1, mut y1) = (x0 + w, y0 + h);
-                if matches!(handle, 0 | 6 | 7) {
-                    x0 += dx;
-                }
-                if matches!(handle, 2..=4) {
-                    x1 += dx;
-                }
-                if matches!(handle, 0..=2) {
-                    y0 += dy;
-                }
-                if matches!(handle, 4..=6) {
-                    y1 += dy;
-                }
-                let bx = [x0.min(x1), y0.min(y1), (x1 - x0).abs().max(4.0), (y1 - y0).abs().max(4.0)];
+                let bx = resized_box(start_box, handle, px - qx, py - qy);
                 exec(app, "layer.setText", json!({"layer": layer.0, "box": bx, "merge": format!("text-box-{}", layer.0)}));
             }
         }
@@ -1009,37 +1062,7 @@ pub fn hook(
     // composition's identity as a stale guard before this pass's IME events are read.
     sync_ime_target(ui, app);
     sync_ime_selection(ui, app);
-    if app.session.state.text_edit.is_some() && app.dialog.is_none() && ui.is_enabled() {
-        // Register after pointer handling so new edits take focus in this frame. An inactive
-        // editor must not take Tab focus and immediately surrender it before viewer controls.
-        ui.interact(Rect::from_min_size(resp.rect.min, vec2(1.0, 1.0)), focus_id(), egui::Sense::focusable_noninteractive());
-        ui.memory_mut(|m| {
-            m.set_focus_lock_filter(focus_id(), egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true });
-        });
-        // A session started elsewhere (menu, agent, Properties) takes the keyboard once…
-        let seen = ime_seen_id(ui.ctx().viewport_id());
-        let key = app.session.state.text_edit.as_ref().map(|e| (e.layer.0, e.session));
-        // …and gets it back when a click in the viewer or a formatting panel left nothing holding
-        // it; not merely because nothing has the focus (#831).
-        let regain = ui.memory(|m| m.focused().is_none()) && keeps_text_edit(app.ui.focused);
-        if ui.data(|d| d.get_temp::>(seen)).flatten() != key || regain {
-            ui.data_mut(|d| d.insert_temp(seen, key));
-            ui.memory_mut(|m| m.request_focus(focus_id()));
-        }
-        if ui.memory(|m| m.has_focus(focus_id())) {
-            keyboard(app, ui, resp.rect);
-        }
-    } else {
-        // Resolve the viewport before taking the data lock: `viewport_id()`
-        // acquires the same context lock that `data_mut` holds.
-        let viewport = ui.ctx().viewport_id();
-        ui.data_mut(|d| d.insert_temp::>(ime_seen_id(viewport), None));
-        invalidate_ime_composition(ui, app);
-        clear_preedit(ui);
-        if ui.memory(|m| m.has_focus(focus_id())) {
-            ui.memory_mut(|m| m.surrender_focus(focus_id()));
-        }
-    }
+    keyboard_focus(app, ui, resp.rect);
 
     // ---- overlay
     if let Some(s) = app.session.state.text_edit.clone()
@@ -1219,111 +1242,7 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
                 }
                 typing_key(ui, true);
             }
-            egui::Event::Key { key, pressed: true, modifiers: m, .. } => {
-                use egui::Key;
-                let cmd = m.command;
-                let mv = |app: &mut EffectcraftApp, to: &str| {
-                    exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
-                };
-                match key {
-                    Key::ArrowLeft | Key::ArrowRight if m.alt => {
-                        // Kerning at the caret, tracking over a selection.
-                        let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
-                        let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
-                        if sel.is_empty() {
-                            if sel.caret < doc.char_len() {
-                                let cur = match doc.style_at(sel.caret).kerning {
-                                    Kerning::Manual(v) => v,
-                                    _ => 0.0,
-                                };
-                                exec(
-                                    app,
-                                    "layer.setText",
-                                    json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
-                                );
-                            }
-                        } else {
-                            let r = sel.range();
-                            let cur = doc.style_at(r.start).tracking;
-                            exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
-                        }
-                    }
-                    Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
-                    Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
-                    Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
-                    Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
-                    Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
-                    Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
-                    Key::Backspace => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Delete => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Enter if cmd => {
-                        exec(app, "text.endEdit", json!({}));
-                    }
-                    Key::Enter => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Tab => {
-                        let k = typing_key(ui, false);
-                        let before = ime_stamp(app, &ctx);
-                        if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Escape => {
-                        exec(app, "text.endEdit", json!({}));
-                    }
-                    // The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
-                    // without Shift (Undo, Redo). Other modifiers on those keys aren't editing
-                    // chords, so they fall through to the binding match below: Shift+A is
-                    // Deselect All's default, and Alt variants are free for a rebound one.
-                    Key::A if cmd && !m.shift && !m.alt => {
-                        exec(app, "text.setSelection", json!({"select": "all"}));
-                    }
-                    Key::Z if cmd && !m.alt => {
-                        let before = ime_stamp(app, &ctx);
-                        if crate::menus::invoke(app, &ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
-                            note_ime_application_edit(app, &ctx, before);
-                        }
-                    }
-                    Key::Comma | Key::Period if cmd && m.shift => {
-                        let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
-                        let r = sel.range();
-                        let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
-                        let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
-                        exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
-                    }
-                    // The app-wide shortcut dispatcher is skipped while this editor holds the
-                    // keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
-                    // must be matched here and run through the command dispatcher like undo
-                    // above. Matching the command's current binding, not a fixed key, keeps
-                    // rebound shortcuts working and gives every route one final state; the
-                    // forwarding test also refuses chords a focused editor must never surrender
-                    // (see `forwardable_during_text_edit`).
-                    _ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
-                        let _ = crate::menus::invoke(app, &ctx, "edit.deselectAll", json!({}));
-                    }
-                    _ => {}
-                }
-                if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
-                    typing_key(ui, true);
-                }
-            }
+            egui::Event::Key { key, pressed: true, modifiers: m, .. } => key_event(app, ui, &ctx, &sel, key, m),
             _ => {}
         }
         let route_ended = app.session.state.text_edit.is_none() || app.dialog.is_some();
@@ -1344,6 +1263,121 @@ fn keyboard(app: &mut EffectcraftApp, ui: &mut egui::Ui, viewer: Rect) {
     }
 }
 
+/// An application key press in the edited text: caret moves, deletes, insertions, formatting and
+/// the editor's own chords.
+fn key_event(
+    app: &mut EffectcraftApp,
+    ui: &egui::Ui,
+    ctx: &egui::Context,
+    sel: &effectcraft_engine::commands::text_edit::TextEdit,
+    key: egui::Key,
+    m: egui::Modifiers,
+) {
+    use egui::Key;
+    let cmd = m.command;
+    let mv = |app: &mut EffectcraftApp, to: &str| {
+        exec(app, "text.moveCaret", json!({"to": to, "extend": m.shift}));
+    };
+    match key {
+        Key::ArrowLeft | Key::ArrowRight if m.alt => {
+            // Kerning at the caret, tracking over a selection.
+            let step = if cmd { 100.0 } else { 20.0 } * if key == Key::ArrowLeft { -1.0 } else { 1.0 };
+            let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
+            if sel.is_empty() {
+                if sel.caret < doc.char_len() {
+                    let cur = match doc.style_at(sel.caret).kerning {
+                        Kerning::Manual(v) => v,
+                        _ => 0.0,
+                    };
+                    exec(
+                        app,
+                        "layer.setText",
+                        json!({"layer": sel.layer.0, "range": [sel.caret, sel.caret + 1], "kerning": cur + step, "merge": format!("text-kern-{}", sel.caret)}),
+                    );
+                }
+            } else {
+                let r = sel.range();
+                let cur = doc.style_at(r.start).tracking;
+                exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "tracking": cur + step, "merge": "text-track"}));
+            }
+        }
+        Key::ArrowLeft => mv(app, if cmd { "wordLeft" } else { "left" }),
+        Key::ArrowRight => mv(app, if cmd { "wordRight" } else { "right" }),
+        Key::ArrowUp => mv(app, if cmd { "paraUp" } else { "up" }),
+        Key::ArrowDown => mv(app, if cmd { "paraDown" } else { "down" }),
+        Key::Home => mv(app, if cmd { "start" } else { "lineStart" }),
+        Key::End => mv(app, if cmd { "end" } else { "lineEnd" }),
+        Key::Backspace => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.delete", json!({"word": cmd || m.alt, "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Delete => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.delete", json!({"direction": "forward", "word": cmd || m.alt, "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Enter if cmd => {
+            exec(app, "text.endEdit", json!({}));
+        }
+        Key::Enter => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.insert", json!({"text": "\n", "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Tab => {
+            let k = typing_key(ui, false);
+            let before = ime_stamp(app, ctx);
+            if exec(app, "text.insert", json!({"text": "\t", "merge": k})).is_some() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Escape => {
+            exec(app, "text.endEdit", json!({}));
+        }
+        // The editor owns exactly Cmd/Ctrl+A (Select All) and Cmd/Ctrl+Z with or
+        // without Shift (Undo, Redo). Other modifiers on those keys aren't editing
+        // chords, so they fall through to the binding match below: Shift+A is
+        // Deselect All's default, and Alt variants are free for a rebound one.
+        Key::A if cmd && !m.shift && !m.alt => {
+            exec(app, "text.setSelection", json!({"select": "all"}));
+        }
+        Key::Z if cmd && !m.alt => {
+            let before = ime_stamp(app, ctx);
+            if crate::menus::invoke(app, ctx, if m.shift { "edit.redo" } else { "edit.undo" }, json!({})).is_ok() {
+                note_ime_application_edit(app, ctx, before);
+            }
+        }
+        Key::Comma | Key::Period if cmd && m.shift => {
+            let doc = layer_doc(&app.session, sel.layer).unwrap_or_default();
+            let r = sel.range();
+            let size = sel.pending.as_ref().map_or_else(|| doc.style_at(r.start).size, |p| p.size);
+            let ns = (size + if key == Key::Period { 2.0 } else { -2.0 }).max(1.0);
+            exec(app, "layer.setText", json!({"layer": sel.layer.0, "range": [r.start, r.end], "size": ns}));
+        }
+        // The app-wide shortcut dispatcher is skipped while this editor holds the
+        // keyboard, so the Deselect All chord (Cmd/Ctrl+Shift+A or F2 by default)
+        // must be matched here and run through the command dispatcher like undo
+        // above. Matching the command's current binding, not a fixed key, keeps
+        // rebound shortcuts working and gives every route one final state; the
+        // forwarding test also refuses chords a focused editor must never surrender
+        // (see `forwardable_during_text_edit`).
+        _ if crate::menus::chord_matches(&app.session, "edit.deselectAll", key, m) && crate::menus::forwardable_during_text_edit(m, key) => {
+            let _ = crate::menus::invoke(app, ctx, "edit.deselectAll", json!({}));
+        }
+        _ => {}
+    }
+    if !matches!(key, Key::Backspace | Key::Delete | Key::Enter | Key::Tab) {
+        typing_key(ui, true);
+    }
+}
+
 /// Delete the text an IME marks for replacement around the editor's selection.
 ///
 /// `before`/`after` count UTF-8 bytes (`DeleteSurroundingBytes`, what winit reports) or
diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs
index 238a48c37..f86a7bfee 100644
--- a/crates/ui-egui/src/panels/viewer_tools.rs
+++ b/crates/ui-egui/src/panels/viewer_tools.rs
@@ -48,20 +48,21 @@ pub(crate) fn snap(
     sources: &[[f64; 2]],
     mods: egui::Modifiers,
 ) -> [f64; 2] {
-    snap_with(app, ctx, ectx, map, exclude, sources, mods, &[])
+    snap_with(app, ctx, ectx, map, &SnapQuery { exclude, sources, mods, extra: &[] })
 }
 
-/// [`snap`] with `extra` layer targets (the Pan Behind layer's own box).
-pub(crate) fn snap_with(
-    app: &EffectcraftApp,
-    ctx: &egui::Context,
-    ectx: &EvalCtx,
-    map: &ViewerMap,
-    exclude: &[LayerId],
-    sources: &[[f64; 2]],
-    mods: egui::Modifiers,
-    extra: &[vw::SnapTarget],
-) -> [f64; 2] {
+/// What [`snap_with`] snaps: the dragged features and the layers to ignore, plus `extra` layer
+/// targets (the Pan Behind layer's own box).
+pub(crate) struct SnapQuery<'a> {
+    pub exclude: &'a [LayerId],
+    pub sources: &'a [[f64; 2]],
+    pub mods: egui::Modifiers,
+    pub extra: &'a [vw::SnapTarget],
+}
+
+/// [`snap`] with extra layer targets.
+pub(crate) fn snap_with(app: &EffectcraftApp, ctx: &egui::Context, ectx: &EvalCtx, map: &ViewerMap, q: &SnapQuery) -> [f64; 2] {
+    let SnapQuery { exclude, sources, mods, extra } = *q;
     let layers = snapping_on(app, mods);
     let v = &app.ui.viewer;
     let guides = v.snap_guides && v.guides;
@@ -620,6 +621,26 @@ pub(crate) fn bottom_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect,
     });
 }
 
+/// The exposure drag field (stops) at `er`; a change runs `view.exposure`.
+fn exposure_field(app: &mut EffectcraftApp, ui: &mut egui::Ui, er: Rect, stops: f64) {
+    let mut ev = stops;
+    let resp = ui.put(
+        er,
+        crate::widgets::drag_value(
+            egui::DragValue::new(&mut ev)
+                .speed(0.05)
+                .range(-40.0..=40.0)
+                .fixed_decimals(1)
+                .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
+        ),
+    );
+    let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
+    app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
+    if resp.changed() {
+        let _ = app.session.execute("view.exposure", json!({"stops": ev}));
+    }
+}
+
 /// Lay out the viewer's control row from `bar.min.x`: magnification, resolution, transparency, mask, ROI, grid/guide,
 /// channel/colour management, exposure, snapshot, fast previews, 3D renderer/view and Extended Viewer, timecode and render-time
 /// indicators, running commands or toggling viewer UI state. Returns the width the row
@@ -743,22 +764,7 @@ fn bottom_bar_contents(app: &mut EffectcraftApp, ui: &mut egui::Ui, bar: Rect, z
         let _ = app.session.execute("view.resetExposure", json!({}));
     }
     let er = Rect::from_min_size(pos2(x, cy - 10.0), vec2(42.0, 20.0));
-    let mut ev = vo.exposure as f64;
-    let resp = ui.put(
-        er,
-        crate::widgets::drag_value(
-            egui::DragValue::new(&mut ev)
-                .speed(0.05)
-                .range(-40.0..=40.0)
-                .fixed_decimals(1)
-                .custom_formatter(|v, _| if v == 0.0 { "0.0".into() } else { format!("{v:+.1}") }),
-        ),
-    );
-    let resp = resp.on_hover_text(crate::i18n::tr("Adjust Exposure (stops)"));
-    app.auto.add_response("viewer.exposure", &resp, "Adjust Exposure");
-    if resp.changed() {
-        let _ = app.session.execute("view.exposure", json!({"stops": ev}));
-    }
+    exposure_field(app, ui, er, vo.exposure as f64);
     x = er.max.x + 4.0;
     // Snapshots.
     if tog(ui, &mut app.auto, &mut x, Icon::Snapshot, false, "snapshot", "Take Snapshot").0 {
diff --git a/crates/ui-egui/src/pointer_wrap/tests.rs b/crates/ui-egui/src/pointer_wrap/tests.rs
index 3843db110..314f7ad3d 100644
--- a/crates/ui-egui/src/pointer_wrap/tests.rs
+++ b/crates/ui-egui/src/pointer_wrap/tests.rs
@@ -67,10 +67,7 @@ impl Scrub {
                     pos2(100.0, 100.0),
                     Id::unique("scrub"),
                     self.value,
-                    1.0,
-                    (-1e9, 1e9),
-                    1,
-                    "",
+                    &crate::widgets::HotSpec { speed: 1.0, range: (-1e9, 1e9), decimals: 1, suffix: "" },
                     &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
                 );
                 if let Some(v) = value {
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
index c870a96db..58383fe43 100644
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -51,20 +51,21 @@ fn tab_stop(ui: &Ui, id: egui::Id) -> bool {
     })
 }
 
+/// How a hot-text field scrubs and prints: `speed` value units per pointer pixel, clamped to
+/// `range`, shown with `decimals` places followed by `suffix`.
+#[derive(Clone, Copy)]
+pub struct HotSpec<'a> {
+    pub speed: f64,
+    pub range: (f64, f64),
+    pub decimals: usize,
+    pub suffix: &'a str,
+}
+
 /// "Hot text": a blue number you drag horizontally to scrub, click to type. Tab / Shift+Tab
 /// while typing commits and types in the panel's next / previous one, as in After Effects.
 /// Returns (response, Some(new value) when changed, drag finished).
-pub fn hot_number_at(
-    ui: &mut Ui,
-    rect_min: egui::Pos2,
-    id: egui::Id,
-    value: f64,
-    speed: f64,
-    range: (f64, f64),
-    decimals: usize,
-    suffix: &str,
-    t: &Tokens,
-) -> (Rect, Option, bool) {
+pub fn hot_number_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: f64, spec: &HotSpec, t: &Tokens) -> (Rect, Option, bool) {
+    let HotSpec { speed, range, decimals, suffix } = *spec;
     let editing_id = id.with("editing");
     let mut editing: Option = ui.data(|d| d.get_temp(editing_id));
     if tab_stop(ui, id) && editing.is_none() {
@@ -163,7 +164,7 @@ pub fn slider_scrub_speed(ui: &ParamUi) -> f64 {
 /// `1 / speed` pixels however slowly the pointer moves (the fractions add up between frames),
 /// and a typed value is truncated. Returns (rect, Some(new value) when changed, drag finished).
 pub fn hot_int_at(ui: &mut Ui, rect_min: egui::Pos2, id: egui::Id, value: i64, speed: f64, suffix: &str, t: &Tokens) -> (Rect, Option, bool) {
-    let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, speed, (-1e6, 1e6), 0, suffix, t);
+    let (rect, nv, done) = hot_number_at(ui, rect_min, id, value as f64, &HotSpec { speed, range: (-1e6, 1e6), decimals: 0, suffix }, t);
     let acc_id = id.with("acc");
     let acc = ui.data(|d| d.get_temp::(acc_id)).unwrap_or(0.0) + nv.map_or(0.0, |n| n - value as f64);
     // (Ten 0.1 steps add up to just under 1.)

From 6194a157d724fb223a7aa39197d992280a0216f7 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor" 
Date: Wed, 28 Oct 2026 06:09:21 +0000
Subject: [PATCH 32/38] Refactor automation tests for strict Clippy checks

---
 crates/automation/tests/qa/compositing.rs | 51 +++++++++++++++++------
 1 file changed, 39 insertions(+), 12 deletions(-)

diff --git a/crates/automation/tests/qa/compositing.rs b/crates/automation/tests/qa/compositing.rs
index 52d5e4fb2..da78ae116 100644
--- a/crates/automation/tests/qa/compositing.rs
+++ b/crates/automation/tests/qa/compositing.rs
@@ -7,6 +7,10 @@ use serde_json::{Value, json};
 
 use crate::harness::{Img, Qa, coverage, luma, mean_diff};
 
+fn lum(i: &Img, x: u32, y: u32) -> f64 {
+    luma(i.get_pixel(x, y))
+}
+
 fn px(img: &Img, x: u32, y: u32) -> [u8; 4] {
     img.get_pixel(x, y).0
 }
@@ -25,13 +29,12 @@ fn write_sequence(qa: &Qa) -> String {
     qa.path("seq/ball_000.png")
 }
 
-#[test]
-fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
-    let mut qa = Qa::new("compositing");
-
+/// Import the sequence (reinterpreted at 12 fps) and a PSD written by the app; returns the
+/// sequence item and the PSD's composition.
+fn import_footage(qa: &mut Qa) -> (Value, Value) {
     // Footage: the sequence, and a layered PSD written by the app itself (Save Frame As ▸
     // Photoshop Layers) from a small comp.
-    let seq = write_sequence(&qa);
+    let seq = write_sequence(qa);
     let imp = qa.exec("file.import", json!({"paths": [seq]}));
     let seq_item = imp["items"][0].clone();
     let project = qa.tool("get_project", json!({}));
@@ -55,10 +58,14 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
     let pc = qa.tool("get_comp", json!({"comp": psd_comp.clone()}));
     let names: Vec<&str> = pc["layers"].as_array().unwrap().iter().map(|l| l["name"].as_str().unwrap()).collect();
     assert_eq!(names, ["Bar", "Gray"], "the PSD's layers, top first: {pc}");
+    (seq_item, psd_comp)
+}
 
+/// The main comp: the sequence plays, then a Screen-blended PSD; returns the Normal-mode frame.
+fn main_comp_and_blend(qa: &mut Qa, seq_item: &Value, psd_comp: &Value) -> Img {
     // The main comp.
-    let main = qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
-    qa.exec("layer.addItem", json!({"item": seq_item}));
+    qa.exec("comp.new", json!({"name": "Main", "width": 160, "height": 90, "frameRate": 12, "duration": 1}));
+    qa.exec("layer.addItem", json!({"item": seq_item.clone()}));
     let a = qa.frame(None, 0.0);
     let b = qa.frame(None, 0.5);
     assert!(mean_diff(&a, &b) > 1.0, "the sequence plays");
@@ -75,7 +82,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
     for c in 0..3 {
         assert!(s[c] > n[c] && s[c] > [32, 64, 96][c], "Screen brightens channel {c}: {s:?}");
     }
+    normal
+}
 
+fn track_matte(qa: &mut Qa) {
     // Track matte: a magenta solid seen only through text.
     qa.exec("layer.newText", json!({"text": "MATTE", "name": "Matte", "size": 40, "fill": "#ffffff"}));
     qa.exec("layer.newSolid", json!({"name": "Fill", "color": "#ff00ff", "width": 160, "height": 90}));
@@ -91,7 +101,9 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
     let fill = info["layers"].as_array().unwrap().iter().find(|l| l["name"] == "Fill").unwrap().clone();
     assert_eq!(fill["trackMatte"]["kind"], "Alpha Matte");
     qa.exec("layer.setSwitch", json!({"layers": ["Fill", "Matte"], "switch": "video", "value": false}));
+}
 
+fn adjustment_layer(qa: &mut Qa, normal: &Img) {
     // Adjustment layer: Lumetri exposure, inside a feathered, expanded elliptical mask; then Glow.
     qa.exec("layer.newAdjustment", json!({"name": "Grade"}));
     let fx = qa.exec("effect.apply", json!({"effect": "Lumetri Color", "layers": ["Grade"]}));
@@ -103,7 +115,6 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
     let mk = qa.exec("layer.addMask", json!({"layer": "Grade", "shape": "ellipse", "rect": [40, 20, 80, 50]}));
     assert!(mk["mask"].is_u64(), "{mk}");
     let hard = qa.frame(None, 0.5);
-    let lum = |i: &Img, x, y| luma(i.get_pixel(x, y));
     assert!(lum(&hard, 80, 45) > lum(&before, 80, 45) - 1.0 && lum(&hard, 5, 5) < lum(&before, 5, 5) - 10.0, "the mask limits the grade to the ellipse");
     qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "expansion", "value": 8}));
     let grown = qa.frame(None, 0.5);
@@ -116,7 +127,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
         }
         n
     };
-    assert!(bright(&grown, &normal) > bright(&hard, &normal) + 100, "expansion grows the graded area");
+    assert!(bright(&grown, normal) > bright(&hard, normal) + 100, "expansion grows the graded area");
     qa.exec("layer.mask.set", json!({"layer": "Grade", "field": "feather", "value": 20}));
     let soft = qa.frame(None, 0.5);
     // At the ellipse's edge (x = 40 − 8): feathered is in between none and full.
@@ -125,7 +136,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
     qa.exec("effect.apply", json!({"effect": "Glow", "layers": ["Grade"]}));
     let glow = qa.frame(None, 0.5);
     assert!(mean_diff(&soft, &glow) > 0.5, "Glow changes the frame");
+}
 
+/// The 3D stage with depth of field and a light; returns the stage comp.
+fn stage_3d(qa: &mut Qa) -> Value {
     // 3D stage: a near and a far card, a camera with depth of field, a light.
     let stage = qa.exec("comp.new", json!({"name": "Stage", "width": 160, "height": 90, "frameRate": 12, "duration": 1}))["comp"].clone();
     qa.exec("layer.newSolid", json!({"name": "Near", "color": "#ff4040", "width": 40, "height": 40}));
@@ -155,7 +169,10 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
     let advanced = qa.frame(None, 0.0);
     assert!(mean_diff(&classic, &advanced) > 0.2, "Advanced 3D renders the stage its own way");
     assert!(lum(&advanced, 50, 45) > 60.0, "Advanced 3D still lights the card");
+    stage
+}
 
+fn alpha_renders(qa: &mut Qa, stage: &Value) {
     // Renders with alpha: the stage has no background layer, so most of it is transparent.
     let mov = qa.path("stage.mov");
     let webm = qa.path("stage.webm");
@@ -165,7 +182,7 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
     assert!(done["items"].as_array().unwrap().iter().all(|i| i["status"] == "Done"), "{done}");
     let reference = qa.tool("render_frame", json!({"comp": stage.clone(), "time": 0, "transparent": true, "inline": false}));
     assert_eq!(reference["width"], 160);
-    let refa = alpha_frame(&mut qa, stage.clone());
+    let refa = alpha_frame(qa, stage.clone());
     assert!(px(&refa, 2, 2)[3] < 40 && px(&refa, 50, 45)[3] == 255, "render_frame transparent keeps alpha: {:?} {:?}", px(&refa, 2, 2), px(&refa, 50, 45));
     // ProRes 4444 carries alpha in the frame; WebM VP9 as a second VP9 stream in BlockAdditions
     // (AlphaMode 1), which FilmCraft's Matroska reader decodes into the frame's alpha plane.
@@ -174,14 +191,24 @@ fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
         assert_eq!(r["errors"], json!([]));
         assert_eq!(r["unlabeledAlpha"], r["items"], "{path}: the importer sees an alpha channel");
         let c = qa.exec("file.newCompFromSelection", json!({}))["comps"][0].clone();
-        let f = alpha_frame(&mut qa, c.clone());
+        let f = alpha_frame(qa, c.clone());
         // The opaque card decodes to the stage's colours (lossy codecs, so loosely).
         let (got, want) = (px(&f, 50, 45), px(&refa, 50, 45));
         assert!((0..3).all(|c| got[c].abs_diff(want[c]) < 12), "{path} decodes to the stage: {got:?} vs {want:?}");
         assert!(mean_diff(&qa.frame(Some(c.clone()), 0.0), &qa.frame(Some(stage.clone()), 0.0)) < 8.0, "{path} composites like the stage");
         assert!(px(&f, 2, 2)[3] < 40 && px(&f, 50, 45)[3] > 250, "{path}: transparent corners, opaque card: {:?} {:?}", px(&f, 2, 2), px(&f, 50, 45));
     }
-    let _ = main;
+}
+
+#[test]
+fn compositing_matte_blend_adjustment_3d_and_alpha_renders() {
+    let mut qa = Qa::new("compositing");
+    let (seq_item, psd_comp) = import_footage(&mut qa);
+    let normal = main_comp_and_blend(&mut qa, &seq_item, &psd_comp);
+    track_matte(&mut qa);
+    adjustment_layer(&mut qa, &normal);
+    let stage = stage_3d(&mut qa);
+    alpha_renders(&mut qa, &stage);
 }
 
 fn alpha_frame(qa: &mut Qa, comp: Value) -> Img {

From 5fa72d83ded84c40a923143b0defe3f034d73159 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor" 
Date: Wed, 28 Oct 2026 06:09:21 +0000
Subject: [PATCH 33/38] Refactor the effectcraft apps for strict Clippy checks

---
 apps/effectcraft-cli/tests/it/qa_template.rs | 29 ++++++++++++++------
 apps/effectcraft-web/src/frames.rs           |  5 ++--
 apps/effectcraft/src/main.rs                 |  2 +-
 apps/effectcraft/tests/args.rs               |  2 --
 4 files changed, 24 insertions(+), 14 deletions(-)

diff --git a/apps/effectcraft-cli/tests/it/qa_template.rs b/apps/effectcraft-cli/tests/it/qa_template.rs
index 8cb533fda..ffbdb94e9 100644
--- a/apps/effectcraft-cli/tests/it/qa_template.rs
+++ b/apps/effectcraft-cli/tests/it/qa_template.rs
@@ -152,6 +152,13 @@ fn essential_graphics_template_via_script_and_commands() {
     assert_eq!(image::open(&frames[0]).unwrap().to_rgba8(), edit);
     assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
 
+    invalid_template_values_fail_cleanly(&dir);
+    template_usage_errors(&dir);
+    rational_template_keeps_exact_rate(&dir, &original_template);
+    let _ = std::fs::remove_dir_all(&dir);
+}
+
+fn invalid_template_values_fail_cleanly(dir: &PathBuf) {
     // Invalid values fail before the queue creates an output directory, or frame rendering
     // overwrites an existing output. A JSON object and existing control names are required.
     for (name, values, expected) in [
@@ -164,25 +171,27 @@ fn essential_graphics_template_via_script_and_commands() {
         std::fs::write(dir.join(&values_file), values).unwrap();
         let output_dir = dir.join(format!("failed-{name}"));
         let out = output_dir.join("frame_[#####].png");
-        let (code, error) = cli(&dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
+        let (code, error) = cli(dir, &["render", "--template", "lt.ectemplate", "--values", &values_file, "--out", out.to_str().unwrap()]);
         assert_eq!(code, 1, "{name}: {error}");
         assert!(error["error"].as_str().unwrap().contains(expected), "{name}: {error}");
         assert!(!output_dir.exists(), "{name}: invalid input created render output");
         std::fs::write(dir.join("untouched.png"), b"existing output").unwrap();
-        let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
+        let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", &values_file, "--out", "untouched.png"]);
         assert_eq!(code, 1, "{name}: {error}");
         assert_eq!(std::fs::read(dir.join("untouched.png")).unwrap(), b"existing output");
     }
-    let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
+    let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "missing-values.json", "--out", "missing.png"]);
     assert_eq!(code, 1, "{error}");
     assert!(error["error"].as_str().unwrap().contains("cannot read values"), "{error}");
     assert!(!dir.join("missing.png").exists());
     std::fs::write(dir.join("large-values.json"), vec![b' '; 1024 * 1024 + 1]).unwrap();
-    let (code, error) = cli(&dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
+    let (code, error) = cli(dir, &["render-frame", "--template", "lt.ectemplate", "--values", "large-values.json", "--out", "large.png"]);
     assert_eq!(code, 1, "{error}");
     assert!(error["error"].as_str().unwrap().contains("exceeds 1 MiB"), "{error}");
     assert!(!dir.join("large.png").exists());
+}
 
+fn template_usage_errors(dir: &PathBuf) {
     for args in [
         vec!["render-frame", "--values", "values.json"],
         vec!["info", "--template", "lt.ectemplate"],
@@ -191,10 +200,12 @@ fn essential_graphics_template_via_script_and_commands() {
         vec!["render", "--template", "lt.ectemplate", "--queue"],
         vec!["render-frame", "--template", "lt.ectemplate", "--bridge", "1"],
     ] {
-        let output = Command::new(crate::binary()).current_dir(&dir).args(&args).output().unwrap();
+        let output = Command::new(crate::binary()).current_dir(dir).args(&args).output().unwrap();
         assert_eq!(output.status.code(), Some(2), "{args:?}: {}", String::from_utf8_lossy(&output.stderr));
     }
+}
 
+fn rational_template_keeps_exact_rate(dir: &PathBuf, original_template: &[u8]) {
     // File-loaded templates can use exact rational rates that comp.new's float parser rounds.
     // Preserve that rate, Tick duration, work area, background and locked source layers.
     let mut project = effectcraft_engine::project::Project::from_json(&std::fs::read_to_string(dir.join("t.ecproj")).unwrap()).unwrap();
@@ -207,13 +218,13 @@ fn essential_graphics_template_via_script_and_commands() {
     comp.background = [0.08, 0.16, 0.24];
     comp.layers.first_mut().unwrap().switches.locked = true;
     std::fs::write(dir.join("rational.ecproj"), project.to_json()).unwrap();
-    ok(&dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
+    ok(dir, &["exec", "essential.exportTemplate", r#"{"comp":"Lower Third","path":"rational.ectemplate"}"#, "rational.ecproj"]);
     let rational_template = std::fs::read(dir.join("rational.ectemplate")).unwrap();
-    let rational = ok(&dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
+    let rational = ok(dir, &["render-frame", "--template", "rational.ectemplate", "--frame", "5", "--out", "rational.png"]);
     assert!((rational["time"].as_f64().unwrap() - 5.0 / rate.as_f64()).abs() < 1e-9, "{rational}");
     assert_eq!((rational["width"].as_u64().unwrap(), rational["height"].as_u64().unwrap()), (320, 90));
     let output = dir.join("rational-work-area/frame_[#####].png");
-    ok(&dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
+    ok(dir, &["render", "--template", "rational.ectemplate", "--work-area", "--out", output.to_str().unwrap(), "--format", "png", "--resolution", "0.05"]);
     let count = std::fs::read_dir(dir.join("rational-work-area"))
         .unwrap()
         .filter_map(Result::ok)
@@ -222,8 +233,8 @@ fn essential_graphics_template_via_script_and_commands() {
     assert_eq!(count, 4, "the template's exact work area contains frames 3 through 6");
     assert_eq!(std::fs::read(dir.join("rational.ectemplate")).unwrap(), rational_template);
     assert_eq!(std::fs::read(dir.join("lt.ectemplate")).unwrap(), original_template);
-    let _ = std::fs::remove_dir_all(&dir);
 }
+
 fn template_output_alias_fixture(case: &str) -> PathBuf {
     let dir = std::env::temp_dir().join(format!("ec-qa-template-output-{case}-{}", std::process::id()));
     let _ = std::fs::remove_dir_all(&dir);
diff --git a/apps/effectcraft-web/src/frames.rs b/apps/effectcraft-web/src/frames.rs
index c7f350526..69089e308 100644
--- a/apps/effectcraft-web/src/frames.rs
+++ b/apps/effectcraft-web/src/frames.rs
@@ -311,10 +311,11 @@ pub fn stats() -> Value {
     })
 }
 
+type PendingFrameCheck = (js_sys::Function, js_sys::Function, (u32, u32, Vec), f64);
+
 thread_local! {
     /// `workerFrameCheck` promises waiting for their frame: id → (resolve, reject, local frame).
-    #[allow(clippy::type_complexity)]
-    static CHECKS: RefCell), f64)>> = RefCell::new(Default::default());
+    static CHECKS: RefCell> = RefCell::new(Default::default());
 }
 
 /// `effectcraft.workerFrameCheck({time?, scale?, backend?})`: render the active comp's frame in
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
index 6df015290..17bbb7e1b 100644
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -9,8 +9,8 @@
 //!
 //! `--control ` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
 //! see `effectcraft_ui_egui::control` for the methods.
-#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
 
 // Built everywhere so its tests run on every platform; only Linux AppImages use it.
 mod appearance;
diff --git a/apps/effectcraft/tests/args.rs b/apps/effectcraft/tests/args.rs
index 41281eb23..62c591b45 100644
--- a/apps/effectcraft/tests/args.rs
+++ b/apps/effectcraft/tests/args.rs
@@ -1,6 +1,4 @@
 //! The desktop binary rejects a bad command line before it starts anything (#997).
-// Integration tests: unwrapping and panicking on failure is fine here.
-#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
 
 use std::path::PathBuf;
 use std::process::{Command, Stdio};

From ba30fb6c3884dcdf066e61ea856670ad3c2e6635 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor" 
Date: Wed, 28 Oct 2026 06:09:21 +0000
Subject: [PATCH 34/38] Group benchmark measurement inputs

---
 xtask/src/bench.rs | 12 +++++++++---
 1 file changed, 9 insertions(+), 3 deletions(-)

diff --git a/xtask/src/bench.rs b/xtask/src/bench.rs
index 55bf4ee40..96d171d2f 100644
--- a/xtask/src/bench.rs
+++ b/xtask/src/bench.rs
@@ -155,7 +155,7 @@ pub fn run(root: &Path, args: &[String]) -> Result<(), String> {
                 } else {
                     eprintln!("bench: {} {kind}", sc.name);
                 }
-                let row = measure(&bin, &dir, sc, kind, frames, &o.format, timer, &ctx)?;
+                let row = measure(&bin, &dir, sc, &Measurement { kind, frames, format: &o.format }, timer, &ctx)?;
                 append_line(&results_path, &row)?;
                 rows.push(row);
             }
@@ -679,8 +679,14 @@ fn utc(t: u64) -> String {
     format!("{y:04}-{}", dt.format("%m-%d %H:%M UTC"))
 }
 
-#[allow(clippy::too_many_arguments)]
-fn measure(bin: &Path, dir: &Path, sc: &Scenario, kind: &str, frames: u32, format: &str, timer: TimeTool, ctx: &RunContext) -> Result {
+struct Measurement<'a> {
+    kind: &'a str,
+    frames: u32,
+    format: &'a str,
+}
+
+fn measure(bin: &Path, dir: &Path, sc: &Scenario, measurement: &Measurement<'_>, timer: TimeTool, ctx: &RunContext) -> Result {
+    let Measurement { kind, frames, format } = *measurement;
     let out_dir = dir.join("out").join(format!("{}-{kind}", sc.name.replace(['/', '\\', ' '], "_")));
     let _ = std::fs::remove_dir_all(&out_dir);
     std::fs::create_dir_all(&out_dir).map_err(|e| format!("{}: {e}", out_dir.display()))?;

From 51a22210deef26edccefecfb59887b82351fd836 Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor" 
Date: Wed, 28 Oct 2026 06:09:21 +0000
Subject: [PATCH 35/38] Enforce workspace Clippy standards and document lint
 policy

---
 CLAUDE.md        | 1 +
 Cargo.toml       | 4 +++-
 clippy.toml      | 1 +
 xtask/Cargo.toml | 3 +++
 4 files changed, 8 insertions(+), 1 deletion(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index e52386942..21f2df36b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -22,6 +22,7 @@ EffectCraft is a clean-room, open-source, pure-Rust motion graphics and visual e
 - **Everything is agent-drivable:** every interactive widget registers an automation id; UI state is serde.
 - **Community links** (Help menu, About, header Discord button): https://discord.gg/artcraft, https://getartcraft.com, https://getartcraft.com/apps/effectcraft, https://github.com/storytold/effectcraft.
 - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, assets, wasm).
+- **New workspace crates** must include `[lints] workspace = true` in `Cargo.toml` to inherit `[workspace.lints]`. Production crate roots must declare the required crate-level lint attributes before modules or other crate items; crate-level `//!` documentation may precede them.
 - **Commits:** one task id per commit (`M6.3: trim paths`). Only green states.
 
 ## Running and looking at the app
diff --git a/Cargo.toml b/Cargo.toml
index 79ec24655..8c82c7281 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -135,9 +135,11 @@ sys-locale = "0.3"
 unsafe_code = "forbid"
 
 [workspace.lints.clippy]
+cognitive_complexity = "deny"
+too_many_arguments = "deny"
+wildcard_imports = "deny"
 dbg_macro = "warn"
 todo = "warn"
-too_many_arguments = "allow"
 needless_range_loop = "allow"
 type_complexity = "allow"
 
diff --git a/clippy.toml b/clippy.toml
index 287e14edc..3522a15b6 100644
--- a/clippy.toml
+++ b/clippy.toml
@@ -5,3 +5,4 @@ allow-indexing-slicing-in-tests = true
 disallowed-types = [
     { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" },
 ]
+cognitive-complexity-threshold = 30
diff --git a/xtask/Cargo.toml b/xtask/Cargo.toml
index b9c781bce..afc767de2 100644
--- a/xtask/Cargo.toml
+++ b/xtask/Cargo.toml
@@ -5,6 +5,9 @@ edition.workspace = true
 license.workspace = true
 publish = false
 
+[lints]
+workspace = true
+
 [dependencies]
 serde_json = { workspace = true }
 

From 960620d607a865e4d7c6989d8654df993da06dad Mon Sep 17 00:00:00 2001
From: Brandon Thomas 
Date: Wed, 28 Oct 2026 12:18:40 -0400
Subject: [PATCH 36/38] macOS packaging: retry stapler when Apple's CloudKit
 drops the connection

Co-Authored-By: Claude Opus 5.5 
---
 packaging/macos/package.sh | 23 +++++++++++++++++++----
 1 file changed, 19 insertions(+), 4 deletions(-)

diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh
index 6afc9a328..be0c03aae 100755
--- a/packaging/macos/package.sh
+++ b/packaging/macos/package.sh
@@ -80,6 +80,21 @@ sign() {
   codesign --force --sign "$IDENTITY" ${kc[@]+"${kc[@]}"} "${ts[@]}" "$@"
 }
 
+# `stapler staple` and `stapler validate` both fetch the ticket from Apple's CloudKit, and a dropped
+# connection there (NSURLErrorDomain -1005, "Error 68") fails them although notarization worked.
+# Both are idempotent, so retry with a pause; a missing or bad ticket still fails every try.
+stapler_retry() {
+  local try
+  for try in 1 2 3 4; do
+    xcrun stapler "$1" "$2" && return 0
+    [[ "$try" = 4 ]] && break
+    echo "stapler $1 failed (try $try of 4); retrying in $((try * 20)) s" >&2
+    sleep $((try * 20))
+  done
+  echo "error: stapler $1 $(basename "$2") failed 4 times" >&2
+  exit 1
+}
+
 notarize() {
   local file="$1" out id status
   echo "==> notarizing $(basename "$file") (this can take a few minutes)"
@@ -120,8 +135,8 @@ codesign --verify --strict --deep --verbose=2 "$APP"
 if [[ "$NOTARIZE" = 1 ]]; then
   ditto -c -k --keepParent "$APP" "$WORK/EffectCraft-notarize.zip"
   notarize "$WORK/EffectCraft-notarize.zip"
-  xcrun stapler staple "$APP"
-  xcrun stapler validate "$APP"
+  stapler_retry staple "$APP"
+  stapler_retry validate "$APP"
   spctl --assess --type execute -vvv "$APP"
 fi
 
@@ -182,8 +197,8 @@ fi
 hdiutil_retry detach detach "$MOUNT" >/dev/null
 if [[ "$NOTARIZE" = 1 ]]; then
   notarize "$DMG"
-  xcrun stapler staple "$DMG"
-  xcrun stapler validate "$DMG"
+  stapler_retry staple "$DMG"
+  stapler_retry validate "$DMG"
   spctl --assess --type open --context context:primary-signature -vvv "$DMG"
 fi
 

From c3600f9b59b109da97871a43a6898683a0163cb1 Mon Sep 17 00:00:00 2001
From: Brandon Thomas 
Date: Wed, 28 Oct 2026 12:18:42 -0400
Subject: [PATCH 37/38] Release 0.18.0

Co-Authored-By: Claude Opus 5.5 
---
 Cargo.lock | 72 +++++++++++++++++++++++++++---------------------------
 Cargo.toml |  2 +-
 2 files changed, 37 insertions(+), 37 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 4a4043140..3ff1c844a 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1329,7 +1329,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "cpal",
  "craft-update",
@@ -1355,7 +1355,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-automation"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-engine",
  "effectcraft-host",
@@ -1367,14 +1367,14 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-av1enc"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "filmcraft-av1",
 ]
 
 [[package]]
 name = "effectcraft-cli"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "cpu-time",
  "effectcraft-automation",
@@ -1390,7 +1390,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-color"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "proptest",
  "serde",
@@ -1398,7 +1398,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-effects"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "chrono",
  "effectcraft-color",
@@ -1416,7 +1416,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-engine"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "chrono",
  "craft-docpath",
@@ -1451,7 +1451,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-export"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-av1enc",
  "effectcraft-color",
@@ -1481,7 +1481,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-expr"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "boa_engine",
  "effectcraft-color",
@@ -1498,7 +1498,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-geom"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "proptest",
  "serde",
@@ -1506,7 +1506,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-gpu"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "bytemuck",
  "effectcraft-color",
@@ -1528,14 +1528,14 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-hevcenc"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "filmcraft-hevc",
 ]
 
 [[package]]
 name = "effectcraft-host"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-engine",
  "effectcraft-export",
@@ -1560,7 +1560,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-interchange"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-color",
  "effectcraft-keyframe",
@@ -1579,7 +1579,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-keyframe"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-color",
  "effectcraft-geom",
@@ -1591,7 +1591,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-lottie"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "craft-docpath",
  "effectcraft-color",
@@ -1609,7 +1609,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-media"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "craft-docpath",
  "effectcraft-model",
@@ -1638,7 +1638,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-model"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-geom",
  "image",
@@ -1650,14 +1650,14 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-opusenc"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "filmcraft-opus",
 ]
 
 [[package]]
 name = "effectcraft-path"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-geom",
  "effectcraft-keyframe",
@@ -1669,7 +1669,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-pdf"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "craft-pdf",
  "effectcraft-raster",
@@ -1685,7 +1685,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-plugin"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-effects",
  "effectcraft-keyframe",
@@ -1697,7 +1697,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-project"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-color",
  "effectcraft-geom",
@@ -1710,14 +1710,14 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-psd"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "miniz_oxide 0.9.1",
 ]
 
 [[package]]
 name = "effectcraft-raster"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-color",
  "effectcraft-geom",
@@ -1726,7 +1726,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-render"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-color",
  "effectcraft-effects",
@@ -1748,7 +1748,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-script"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "boa_engine",
  "effectcraft-engine",
@@ -1766,7 +1766,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-segment"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "miniz_oxide 0.9.1",
  "ndarray",
@@ -1775,7 +1775,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-svg"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-geom",
  "effectcraft-path",
@@ -1787,7 +1787,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-text"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-keyframe",
  "harfrust",
@@ -1799,7 +1799,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-time"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "proptest",
  "serde",
@@ -1807,7 +1807,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-track"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-raster",
  "effectcraft-segment",
@@ -1819,7 +1819,7 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-ui-egui"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "chrono",
  "craft-ui",
@@ -1853,11 +1853,11 @@ dependencies = [
 
 [[package]]
 name = "effectcraft-vp9enc"
-version = "0.17.0"
+version = "0.18.0"
 
 [[package]]
 name = "effectcraft-web"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "effectcraft-engine",
  "effectcraft-export",
@@ -7183,7 +7183,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7"
 
 [[package]]
 name = "xtask"
-version = "0.17.0"
+version = "0.18.0"
 dependencies = [
  "chrono",
  "serde_json",
diff --git a/Cargo.toml b/Cargo.toml
index 8c82c7281..9c3676cd7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
 default-members = ["crates/*", "apps/*"]
 
 [workspace.package]
-version = "0.17.0"
+version = "0.18.0"
 edition = "2024"
 license = "MIT OR Apache-2.0"
 rust-version = "1.95"

From c99684e28e2242d81c3c3b960b88eb84fddd190c Mon Sep 17 00:00:00 2001
From: "Dylan M. Taylor" 
Date: Wed, 28 Oct 2026 15:48:35 -0400
Subject: [PATCH 38/38] Honor CARGO_BUILD_TARGET in FreeBSD packaging for
 aarch64 release builds (#2555)

* Honor CARGO_BUILD_TARGET in FreeBSD packaging for aarch64 release builds

The shared release workflow cross-compiles FreeBSD aarch64 in the x86_64 VM and sets
CARGO_BUILD_TARGET. package.sh ignored it: it built for the host, read binaries from
target/release, and took the arch from uname -m (amd64 on that VM), so the aarch64 build
would have produced an x86_64 archive with x86_64 binaries.

When CARGO_BUILD_TARGET is set, pass --target, read binaries from target//release,
name the archive from the target arch, and skip the --version smoke test (the binary cannot
run on the x86_64 host). Unset keeps the previous behavior.

* Reject unsupported CARGO_BUILD_TARGET and gate the smoke test on the host triple

Only x86_64-unknown-freebsd and aarch64-unknown-freebsd are accepted when CARGO_BUILD_TARGET
is set; anything else exits 2 instead of being labelled freebsd-x86_64.

The --version smoke test now runs when CARGO_BUILD_TARGET is unset or equals this host's
full FreeBSD triple, so a native explicit target is still checked.
---
 packaging/freebsd/package.sh | 49 ++++++++++++++++++++++++------------
 1 file changed, 33 insertions(+), 16 deletions(-)

diff --git a/packaging/freebsd/package.sh b/packaging/freebsd/package.sh
index b5d1dc447..053f8185c 100755
--- a/packaging/freebsd/package.sh
+++ b/packaging/freebsd/package.sh
@@ -1,12 +1,12 @@
 #!/usr/bin/env bash
 # Build and package EffectCraft for FreeBSD:
 #
-#   $DIST/effectcraft--freebsd-x86_64.tar.gz   a /usr/local-style tree:
-#       effectcraft--freebsd-x86_64/{bin, share/applications, share/icons, share/mime,
+#   $DIST/effectcraft--freebsd-.tar.gz   a /usr/local-style tree ( is x86_64 or aarch64):
+#       effectcraft--freebsd-/{bin, share/applications, share/icons, share/mime,
 #       share/metainfo, share/doc/effectcraft}
 #
 # Install by copying the tree's contents into /usr/local:
-#   tar -xzf effectcraft--freebsd-x86_64.tar.gz --strip-components 1 -C /usr/local
+#   tar -xzf effectcraft--freebsd-.tar.gz --strip-components 1 -C /usr/local
 #
 # Usage: packaging/freebsd/package.sh [--skip-build] [--dry-run]
 #   --dry-run   stage the tree from stub binaries and list it, without building (works on any OS)
@@ -29,13 +29,22 @@ while [[ $# -gt 0 ]]; do
   esac
 done
 
-# FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones.
-MACHINE="$(uname -m)"
-case "$MACHINE" in
-  amd64 | x86_64) ARCH=x86_64 ;;
-  arm64 | aarch64) ARCH=aarch64 ;;
-  *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;;
-esac
+# A cross build runs on the x86_64 VM, so the target names the arch, not uname.
+if [[ -n "${CARGO_BUILD_TARGET:-}" ]]; then
+  case "$CARGO_BUILD_TARGET" in
+    aarch64-unknown-freebsd) ARCH=aarch64 ;;
+    x86_64-unknown-freebsd) ARCH=x86_64 ;;
+    *) echo "unsupported CARGO_BUILD_TARGET $CARGO_BUILD_TARGET (use x86_64- or aarch64-unknown-freebsd)" >&2; exit 2 ;;
+  esac
+else
+  # FreeBSD's uname says amd64; release file names use x86_64 like the Linux and Windows ones.
+  MACHINE="$(uname -m)"
+  case "$MACHINE" in
+    amd64 | x86_64) ARCH=x86_64 ;;
+    arm64 | aarch64) ARCH=aarch64 ;;
+    *) echo "unsupported architecture $MACHINE" >&2; exit 2 ;;
+  esac
+fi
 if [[ "$DRY_RUN" = 0 ]] && [[ "$(uname -s)" != FreeBSD ]]; then
   echo "error: build on FreeBSD (or pass --dry-run to check the tree layout)" >&2
   exit 2
@@ -46,16 +55,15 @@ echo "==> EffectCraft $VERSION for FreeBSD $ARCH"
 
 # The release VM has 12 GB; full parallelism on the biggest crates runs it out of memory.
 export CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}"
-# The aarch64 release VM is emulated (no rustup toolchain for this tier-3 target, so it builds
-# natively under QEMU). With one codegen unit and thin LTO the largest crates each optimize on a
-# single emulated core and the job runs past its 5-hour limit, so trade a little runtime speed for
-# parallel codegen there. x86_64 keeps the workspace's release profile.
+# The aarch64 build once ran in an emulated VM: with one codegen unit and thin LTO the largest
+# crates each optimized on a single emulated core and the job ran past its 5-hour limit, so trade
+# a little runtime speed for parallel codegen there. x86_64 keeps the workspace's release profile.
 if [[ "$ARCH" = aarch64 ]]; then
   export CARGO_PROFILE_RELEASE_CODEGEN_UNITS="${CARGO_PROFILE_RELEASE_CODEGEN_UNITS:-16}"
   export CARGO_PROFILE_RELEASE_LTO="${CARGO_PROFILE_RELEASE_LTO:-off}"
 fi
 if [[ "$SKIP_BUILD" = 0 ]]; then
-  (cd "$ROOT" && cargo build --release --locked -p effectcraft -p effectcraft-cli)
+  (cd "$ROOT" && cargo build --release --locked ${CARGO_BUILD_TARGET:+--target "$CARGO_BUILD_TARGET"} -p effectcraft -p effectcraft-cli)
 fi
 WORK="$CARGO_TARGET_DIR/freebsd-package"
 STAGE="$WORK/$BASENAME"
@@ -63,6 +71,7 @@ rm -rf "$WORK"
 mkdir -p "$WORK"
 
 BIN="$CARGO_TARGET_DIR/release"
+if [[ -n "${CARGO_BUILD_TARGET:-}" ]]; then BIN="$CARGO_TARGET_DIR/$CARGO_BUILD_TARGET/release"; fi
 OUT_DIR="$DIST"
 if [[ "$DRY_RUN" = 1 ]]; then
   # Stub binaries, and the tarball stays in the work dir so dist/ only ever holds real builds.
@@ -103,7 +112,15 @@ mkdir -p "$OUT_DIR"
 tar -C "$WORK" -czf "$OUT_DIR/$BASENAME.tar.gz" "$BASENAME"
 echo "wrote $OUT_DIR/$BASENAME.tar.gz"
 
-"$STAGE/bin/effectcraft-cli" --version
+# Run the smoke test only when the binary's target is this host's (a cross build can't run here).
+case "$(uname -m)" in
+  amd64 | x86_64) HOST_TRIPLE=x86_64-unknown-freebsd ;;
+  arm64 | aarch64) HOST_TRIPLE=aarch64-unknown-freebsd ;;
+  *) HOST_TRIPLE= ;;
+esac
+if [[ -z "${CARGO_BUILD_TARGET:-}" || "$CARGO_BUILD_TARGET" = "$HOST_TRIPLE" ]]; then
+  "$STAGE/bin/effectcraft-cli" --version
+fi
 if [[ "$DRY_RUN" = 1 ]]; then
   echo "==> dry run: tarball contents"
   tar -tzvf "$OUT_DIR/$BASENAME.tar.gz"