diff --git a/promo/README.md b/promo/README.md new file mode 100644 index 0000000..edbb37f --- /dev/null +++ b/promo/README.md @@ -0,0 +1,130 @@ +# promo + +Assets for launch posts. `screencast.mp4` is the raw 89.6 s recording (2014×1510, +30 fps) — treat it as the master and never edit it in place. + +The upload file is built in two passes, each writing a new mp4: + +``` +screencast.mp4 --apply-captions.ps1--> screencast-captioned.mp4 + --apply-cards.ps1-----> screencast-promo.mp4 <- upload this +``` + +## Captions + +Two files drive the captioned cut: + +| File | What it is | +|------|-----------| +| [captions.srt](captions.srt) | The caption text and timings. **This is the only file you edit.** | +| [apply-captions.ps1](apply-captions.ps1) | Burns the SRT into `screencast-captioned.mp4`. | + +`captions.srt` is plain SubRip — a number, a `hh:mm:ss,mmm --> hh:mm:ss,mmm` +range, then one or two lines of text. Note the **comma** before the milliseconds, +not a period; ffmpeg rejects the file otherwise. Keep it to two lines per cue. + +### Fine-tuning loop + +Timings first, styling second, full render last: + +```powershell +# 1. Timings — no re-encode, ~2 s. Scrub screencast-captioned-soft.mp4 in VLC or +# mpv with subtitles on, note where a cue lands early or late, edit the SRT. +.\promo\apply-captions.ps1 -Soft + +# 2. Styling / copy in context — burns just a 12 s window to *-preview.mp4. +.\promo\apply-captions.ps1 -Preview 47 -Duration 12 + +# 3. Ship it. +.\promo\apply-captions.ps1 +``` + +`-Preview` uses an output-side seek, so cues land exactly where the SRT says — +the window you render is real timeline time, not an offset. + +### Knobs + +`-FontSize` and `-MarginV` are in **output pixels** (defaults scale with the +frame: 9.5% and 5% of height). Also `-Box` for an opaque backing panel instead of +outline + shadow, `-Height 1080` to downscale, `-Font`, `-TextColor`, +`-ShadeColor`, `-Crf`. `Get-Help .\promo\apply-captions.ps1 -Full` has the rest. + +The full render re-encodes at CRF 20, which takes the 189 MB master down to +~15 MB — comfortably inside every platform's upload limit. + +### Gotcha + +ffmpeg's SRT→ASS conversion hardcodes a 384×288 script resolution, so raw +`force_style` sizes are fractions of the frame, not pixels. The script converts +to ASS up front and rewrites `PlayResX/PlayResY` to the output size so the size +parameters mean what they say. It also reads that ASS back as UTF-8 explicitly — +Windows PowerShell would otherwise decode it as ANSI and turn every em dash into +mojibake. + +## Bookend cards + +[apply-cards.ps1](apply-cards.ps1) wraps the captioned cut in two animated +cards — a ~2.3 s title card in front and a ~3 s call to action at the end — and +writes `screencast-promo.mp4`: + +```powershell +.\promo\apply-cards.ps1 +``` + +| File | What it is | +| --- | --- | +| [card.css](card.css) | The shared skin: palette, 1007×755 layout box, hairline grid, loop-range motif, entrance keyframes. | +| [intro.html](intro.html) | Title card. Glyph, wordmark and tagline rise in on a stagger, then the loop range draws itself in from its left edge. | +| [outro.html](outro.html) | Closing card: *Free for your browser*, with a Chrome Web Store / Firefox Add-ons pill each. | + +Palette and motifs come from the store tile +([store/tools/store-promo.html](../store/tools/store-promo.html)) so the video +and the listing read as one piece of design, and the note glyph is inlined from +[src/assets/icon.svg](../src/assets/icon.svg) — the app's own vector, sharp at +any size. + +### How the animation gets into the video + +[capture-card.mjs](capture-card.mjs) opens a card in headless Chrome +(puppeteer-core and the Chrome for Testing under `.browsers`, same as +`scripts/generate-icons.mjs`), **pauses every animation on the document and +steps `currentTime` one frame at a time**, screenshotting each position. So the +capture is deterministic — no real-time recording, no dropped frames, no +dependence on what the machine was doing. The CSS timeline in the card *is* the +video timeline. + +Only the animated part is captured (`-Settle`, default 1.35 s = 41 frames, +~20 s of wall clock per card). ffmpeg's `tpad` clones the last frame for the +hold and the fade-out, so the still tail costs nothing. `-FadeIn` (0.35 s) +overlaps the entrances, so a card arrives rather than appearing and then +animating. `-Hold` (0.55 s) is the intro's pause; `-OutroHold` (1.2 s) is the +outro's, longer because that card has to be read. + +The splice is a **stream copy**: the captioned footage is never re-encoded and +caption timings stay relative to their own footage, which is why the cards are a +separate pass rather than part of the caption render. They are therefore encoded +to match the screencast (probed fps, yuv420p, silent 48 kHz stereo AAC), and the +script re-probes the result and warns if the duration is not input + cards — the +usual symptom of a codec mismatch. One `Non-monotonic DTS` line at a junction is +expected and harmless. + +Iterating on the art needs no video at all: + +```powershell +.\promo\apply-cards.ps1 -CardsOnly # ~6 s, writes promo/intro.png and promo/outro.png +``` + +Those PNGs are the settled cards, kept around as post thumbnails. Opening +either HTML file in a browser plays it at full speed. + +### Gotcha + +The concat list is written with `[IO.File]::WriteAllText`, not `Set-Content`: +Windows PowerShell's UTF8 encoding emits a BOM and ffmpeg reads it as part of +the first keyword (`unknown keyword 'file'`). + +## Reddit + +Post copy lives in [reddit/](reddit/). Upload `screencast-promo.mp4` as **native +Reddit video** — a YouTube link gets a fraction of the plays. Keep the original +audio audible; the pitch and speed changes are the demo. diff --git a/promo/apply-captions.ps1 b/promo/apply-captions.ps1 new file mode 100644 index 0000000..303682a --- /dev/null +++ b/promo/apply-captions.ps1 @@ -0,0 +1,203 @@ +<# +.SYNOPSIS + Burns promo/captions.srt into the screencast and writes a NEW mp4. + The source recording is never modified. + +.DESCRIPTION + Edit the text and timings in captions.srt, then re-run this script. Nothing + else needs to change — styling, font size and margins are all parameters + here, and the caption content lives entirely in the .srt. + + Fine-tuning workflow: + 1. -Soft mux the subs as a switchable track, no re-encode (~2 s). + Scrub it in VLC/mpv to check timings fast. + 2. -Preview 47 -Duration 10 + burn just that window so you can eyeball styling. + 3. (no flags) full burn-in for upload. + +.PARAMETER Soft + Mux the captions as a soft (toggleable) mov_text track instead of burning + them into the pixels. Stream-copies, so it is near-instant — the fastest way + to check whether your timestamps line up. Not suitable for Reddit/X upload, + where captions must be burned in. + +.PARAMETER Preview + Start time (seconds, or hh:mm:ss) of a short test render. Writes to + -preview.mp4 so it never clobbers the real render. + +.PARAMETER Duration + Length in seconds of the -Preview render. Default 10. + +.PARAMETER Height + Scale the output to this height, preserving aspect (e.g. 1080). 0 = keep the + native 1510 px. Captions are rendered after scaling, so they stay crisp. + +.PARAMETER FontSize + Caption height in output pixels. 0 = auto (9.5% of the frame height, ~143 px + at the native 1510) - sized to stay readable in a phone-sized feed. + +.PARAMETER MarginV + Distance from the bottom edge, same units. 0 = auto (5% of height). + +.PARAMETER Box + Draw captions on an opaque rounded box instead of outline + drop shadow. + Easier to read over busy footage; heavier looking. + +.EXAMPLE + .\apply-captions.ps1 -Soft + Quick timing check — no re-encode. + +.EXAMPLE + .\apply-captions.ps1 -Preview 47 -Duration 12 + Render just the looper section to check the caption copy in context. + +.EXAMPLE + .\apply-captions.ps1 -Height 1080 + Final upload render, downscaled to 1080p. +#> +[CmdletBinding()] +param( + [string] $InputVideo = "$PSScriptRoot\screencast.mp4", + [string] $Captions = "$PSScriptRoot\captions.srt", + [string] $OutputVideo = "$PSScriptRoot\screencast-captioned.mp4", + + [switch] $Soft, + [string] $Preview, + [double] $Duration = 10, + + [int] $Height = 0, + [int] $FontSize = 0, + [int] $MarginV = 0, + [switch] $Box, + + [string] $Font = 'Segoe UI', + [string] $TextColor = '#f0e9db', + [string] $ShadeColor = '#161310', + [int] $Crf = 20, + [string] $Preset = 'medium' +) + +$ErrorActionPreference = 'Stop' + +function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 } + +foreach ($exe in 'ffmpeg', 'ffprobe') { + if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { + Fail "$exe is not on PATH. Install it (choco install ffmpeg) and retry." + } +} +if (-not (Test-Path -LiteralPath $InputVideo)) { Fail "No such video: $InputVideo" } +if (-not (Test-Path -LiteralPath $Captions)) { Fail "No such captions file: $Captions" } + +$InputVideo = (Resolve-Path -LiteralPath $InputVideo).Path +$Captions = (Resolve-Path -LiteralPath $Captions).Path + +# --- Soft-sub path: stream copy, no filtering, no styling. ------------------- +if ($Soft) { + $softOut = [IO.Path]::ChangeExtension($OutputVideo, $null).TrimEnd('.') + '-soft.mp4' + Write-Host "Muxing soft subtitles -> $softOut" -ForegroundColor Cyan + & ffmpeg -hide_banner -loglevel warning -stats -y ` + -i $InputVideo -i $Captions ` + -map 0 -map 1 -c copy -c:s mov_text -metadata:s:s:0 language=eng ` + $softOut + if ($LASTEXITCODE -ne 0) { Fail "ffmpeg failed (exit $LASTEXITCODE)." } + Write-Host "Done. Open it in VLC or mpv and turn subtitles on." -ForegroundColor Green + exit 0 +} + +# --- Probe so font size / margins can scale with the output resolution. ------ +$dims = (& ffprobe -v error -select_streams v:0 -show_entries stream=width,height ` + -of csv=s=x:p=0 $InputVideo).Trim() +$srcW, $srcH = $dims -split 'x' | ForEach-Object { [int]$_ } + +if ($Height -gt 0) { + $outH = $Height + # -2 keeps the aspect ratio and forces an even width (libx264 requires it). + $outW = [math]::Round($srcW * ($outH / $srcH)) +} else { + $outH = $srcH + $outW = $srcW +} +if ($FontSize -le 0) { $FontSize = [math]::Round($outH * 0.095) } +if ($MarginV -le 0) { $MarginV = [math]::Round($outH * 0.05) } +$marginH = [math]::Round($outW * 0.04) + +# ASS colours are &HAABBGGRR — alpha first, then blue/green/red (not RGB). +function ConvertTo-AssColor([string] $hex, [int] $alpha = 0) { + $h = $hex.TrimStart('#') + if ($h.Length -ne 6) { Fail "Colour must be #rrggbb, got '$hex'." } + '&H{0:X2}{1}{2}{3}' -f $alpha, $h.Substring(4, 2), $h.Substring(2, 2), $h.Substring(0, 2) +} + +$primary = ConvertTo-AssColor $TextColor +if ($Box) { + # BorderStyle=3 fills the text box with BackColour; Outline is the padding. + $style = "BorderStyle=3,Outline=8,Shadow=0,BackColour=$(ConvertTo-AssColor $ShadeColor 40)" +} else { + $style = "BorderStyle=1,Outline=3,Shadow=2," + + "OutlineColour=$(ConvertTo-AssColor $ShadeColor)," + + "BackColour=$(ConvertTo-AssColor '#000000' 128)" +} + +$forceStyle = "FontName=$Font,FontSize=$FontSize,Bold=1,PrimaryColour=$primary,$style," + + "Alignment=2,MarginV=$MarginV,MarginL=$marginH,MarginR=$marginH" + +# The filtergraph parser chokes on Windows drive letters and backslashes, so run +# ffmpeg from a temp dir holding the subtitles under a plain relative name. +# Input/output paths are ordinary args and need no escaping. +$work = Join-Path ([IO.Path]::GetTempPath()) ('nbn-captions-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) +New-Item -ItemType Directory -Path $work | Out-Null +$assPath = Join-Path $work 'subs.ass' + +# ffmpeg's SRT->ASS conversion hardcodes a 384x288 script resolution, which would +# make every size above a fraction of the frame rather than a pixel count. +# Convert up front and rewrite PlayRes to the output size, so -FontSize/-MarginV +# are honest output pixels. +& ffmpeg -hide_banner -loglevel error -y -i $Captions $assPath +if ($LASTEXITCODE -ne 0) { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue + Fail "Could not parse $Captions - check the SRT timestamp format (hh:mm:ss,mmm --> hh:mm:ss,mmm)." +} +# -Encoding UTF8 on the read is load-bearing: ffmpeg emits UTF-8 without a BOM, +# and Windows PowerShell would otherwise decode it as ANSI and turn every em dash +# into mojibake. +(Get-Content -LiteralPath $assPath -Encoding UTF8) ` + -replace '^PlayResX:.*', "PlayResX: $outW" ` + -replace '^PlayResY:.*', "PlayResY: $outH" | + Set-Content -LiteralPath $assPath -Encoding UTF8 + +$filters = @() +if ($Height -gt 0) { $filters += "scale=-2:$outH" } # scale first: crisper text +$filters += "subtitles=subs.ass:force_style='$forceStyle'" +$vf = $filters -join ',' + +$target = $OutputVideo +$trim = @() +if ($Preview) { + $target = [IO.Path]::ChangeExtension($OutputVideo, $null).TrimEnd('.') + '-preview.mp4' + # -ss/-t AFTER -i is an output seek: frames still reach the filter graph with + # their original timestamps, so captions land where the .srt says they do. + $trim = @('-ss', $Preview, '-t', $Duration) +} + +Write-Host "Burning captions -> $target" -ForegroundColor Cyan +Write-Host " $outW x $outH, font $FontSize pt, margin $MarginV" -ForegroundColor DarkGray + +Push-Location $work +try { + & ffmpeg -hide_banner -loglevel warning -stats -y ` + -i $InputVideo @trim ` + -vf $vf ` + -c:v libx264 -crf $Crf -preset $Preset -pix_fmt yuv420p ` + -movflags +faststart ` + -c:a aac -b:a 192k ` + $target + $code = $LASTEXITCODE +} finally { + Pop-Location + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} +if ($code -ne 0) { Fail "ffmpeg failed (exit $code)." } + +$mb = [math]::Round((Get-Item -LiteralPath $target).Length / 1MB, 1) +Write-Host "Done: $target ($mb MB)" -ForegroundColor Green diff --git a/promo/apply-cards.ps1 b/promo/apply-cards.ps1 new file mode 100644 index 0000000..259eade --- /dev/null +++ b/promo/apply-cards.ps1 @@ -0,0 +1,184 @@ +<# +.SYNOPSIS + Wraps the captioned screencast in its animated bookend cards — intro.html + in front, outro.html at the end. Neither input is modified. + +.DESCRIPTION + Per card: + + 1. capture-card.mjs steps the card's CSS animations frame by frame in + headless Chrome and writes a PNG per frame (~1.3 s of card, ~20 s of + wall clock each). It also refreshes the settled stills, intro.png and + outro.png. + 2. ffmpeg encodes those frames, holds the last one, and fades the card up + from black and back down. + + Then all three parts are concatenated with a stream copy, so the screencast + itself is never re-encoded and caption timings stay relative to their own + footage. + + Run it after apply-captions.ps1. The result, screencast-promo.mp4, is the + file to upload. + +.PARAMETER Settle + Seconds of animation captured per card — long enough for the last entrance + to finish. Default 1.35; both cards settle at ~1.25 s. + +.PARAMETER Hold + Seconds the settled intro sits still before it starts fading. Default 0.55. + +.PARAMETER OutroHold + Same for the outro, which carries the call to action and so needs long + enough to read. Default 1.2. + +.PARAMETER FadeIn + Fade-up from black, in seconds. Default 0.35 — it overlaps the entrances, + so a card arrives rather than appearing and then animating. + +.PARAMETER FadeOut + Fade-down to black, in seconds. Default 0.4. Intro = Settle + Hold + + FadeOut (~2.3 s); outro = Settle + OutroHold + FadeOut (~3 s). + +.PARAMETER CardsOnly + Refresh intro.png and outro.png (the settled stills, useful as thumbnails) + and stop. The fast loop while iterating on the card art. + +.PARAMETER ChromePath + Chrome to render with. Default: the Chrome for Testing under .browsers, + which capture-card.mjs finds on its own. + +.EXAMPLE + .\apply-cards.ps1 -CardsOnly + Redraw both stills to check the art without touching video. + +.EXAMPLE + .\apply-cards.ps1 + Build screencast-promo.mp4 = intro + captioned screencast + outro. +#> +[CmdletBinding()] +param( + [string] $InputVideo = "$PSScriptRoot\screencast-captioned.mp4", + [string] $OutputVideo = "$PSScriptRoot\screencast-promo.mp4", + + [switch] $CardsOnly, + + [double] $Settle = 1.35, + [double] $Hold = 0.55, + [double] $OutroHold = 1.2, + [double] $FadeIn = 0.35, + [double] $FadeOut = 0.4, + + [string] $ChromePath, + [int] $Crf = 20, + [string] $Preset = 'medium' +) + +$ErrorActionPreference = 'Stop' + +function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 } + +$capture = Join-Path $PSScriptRoot 'capture-card.mjs' +if (-not (Test-Path -LiteralPath $capture)) { Fail "Missing $capture" } +if (-not (Get-Command node -ErrorAction SilentlyContinue)) { Fail 'node is not on PATH.' } +if ($ChromePath) { $env:CHROME_PATH = $ChromePath } + +# --- Stills: the settled cards, kept around as thumbnails. ------------------- +foreach ($card in 'intro', 'outro') { + $png = Join-Path $PSScriptRoot "$card.png" + Write-Host "Rendering still -> $png" -ForegroundColor Cyan + & node $capture --card $card --still $png | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "capture-card.mjs failed on $card (exit $LASTEXITCODE)." } +} + +if ($CardsOnly) { Write-Host 'Done (stills only).' -ForegroundColor Green; exit 0 } + +# --- Probe the screencast so the cards are encoded to match. ----------------- +foreach ($exe in 'ffmpeg', 'ffprobe') { + if (-not (Get-Command $exe -ErrorAction SilentlyContinue)) { + Fail "$exe is not on PATH. Install it (choco install ffmpeg) and retry." + } +} +if (-not (Test-Path -LiteralPath $InputVideo)) { + Fail "No such video: $InputVideo (run apply-captions.ps1 first)." +} +$InputVideo = (Resolve-Path -LiteralPath $InputVideo).Path + +$probe = (& ffprobe -v error -select_streams v:0 ` + -show_entries stream=width,height,r_frame_rate -of csv=s=,:p=0 $InputVideo).Trim() +$w, $h, $rate = $probe -split ',' +$num, $den = $rate -split '/' +$fps = [math]::Round([double]$num / [double]$den, 3) + +$frames = [int][math]::Ceiling($Settle * $fps) + +$work = Join-Path ([IO.Path]::GetTempPath()) ('nbn-cards-' + [guid]::NewGuid().ToString('n').Substring(0, 8)) +New-Item -ItemType Directory -Path $work -Force | Out-Null +$listFile = Join-Path $work 'list.txt' + +# Captures `card`, encodes it to match the screencast, returns its duration. +function Build-Card([string] $card, [double] $hold, [string] $target) { + $frameDir = Join-Path $work "$card-frames" + New-Item -ItemType Directory -Path $frameDir -Force | Out-Null + + Write-Host "Capturing $frames frames of $card animation" -ForegroundColor Cyan + & node $capture --card $card --out $frameDir --frames $frames --fps $fps | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "capture-card.mjs failed on $card (exit $LASTEXITCODE)." } + + $tail = $hold + $FadeOut # held on the last captured frame + $total = $frames / $fps + $tail + $outAt = $total - $FadeOut + + # tpad clones the last frame for the hold and the fade-out, so those seconds + # cost nothing to capture. Letterboxing (rather than stretching) in the + # card's own background colour keeps a differently sized recording clean. + # anullsrc gives the card a silent track: concat needs every part to carry + # the same streams, and a video-only segment drops the audio. + $vf = "tpad=stop_mode=clone:stop_duration=$tail," + + "scale=$($w):$($h):force_original_aspect_ratio=decrease," + + "pad=$($w):$($h):(ow-iw)/2:(oh-ih)/2:color=0x0e0c09," + + "fade=t=in:st=0:d=$FadeIn,fade=t=out:st=$outAt`:d=$FadeOut,format=yuv420p" + + Write-Host "Encoding $([math]::Round($total, 2)) s $card card ($w x $h, $fps fps)" -ForegroundColor Cyan + & ffmpeg -hide_banner -loglevel warning -stats -y ` + -framerate $fps -start_number 0 -i (Join-Path $frameDir '%03d.png') ` + -f lavfi -t $total -i anullsrc=channel_layout=stereo:sample_rate=48000 ` + -vf $vf -r $fps ` + -c:v libx264 -crf $Crf -preset $Preset -pix_fmt yuv420p ` + -c:a aac -b:a 192k -shortest ` + $target + if ($LASTEXITCODE -ne 0) { Fail "ffmpeg failed encoding the $card card (exit $LASTEXITCODE)." } + + return $total +} + +try { + $introClip = Join-Path $work 'intro.mp4' + $outroClip = Join-Path $work 'outro.mp4' + $added = Build-Card 'intro' $Hold $introClip + $added += Build-Card 'outro' $OutroHold $outroClip + + # Written through .NET rather than Set-Content: Windows PowerShell's UTF8 + # encoding emits a BOM, and ffmpeg reads it as part of the first keyword. + $q = "'" + $list = ($introClip, $InputVideo, $outroClip | ForEach-Object { "file $q$_$q" }) -join "`n" + [IO.File]::WriteAllText($listFile, $list, (New-Object Text.UTF8Encoding $false)) + + Write-Host "Splicing -> $OutputVideo" -ForegroundColor Cyan + & ffmpeg -hide_banner -loglevel warning -stats -y ` + -f concat -safe 0 -i $listFile -c copy -movflags +faststart ` + $OutputVideo + if ($LASTEXITCODE -ne 0) { Fail "ffmpeg failed concatenating (exit $LASTEXITCODE)." } +} finally { + Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue +} + +# A stream copy only works if the parts really do agree on codec settings; a +# mismatch usually shows up as a truncated result rather than an error. +$srcLen = [double](& ffprobe -v error -show_entries format=duration -of csv=p=0 $InputVideo) +$outLen = [double](& ffprobe -v error -show_entries format=duration -of csv=p=0 $OutputVideo) +if ([math]::Abs($outLen - ($srcLen + $added)) -gt 0.5) { + Write-Host "WARNING: expected $([math]::Round($srcLen + $added, 2)) s, got $([math]::Round($outLen, 2)) s - check the splice." -ForegroundColor Yellow +} + +$mb = [math]::Round((Get-Item -LiteralPath $OutputVideo).Length / 1MB, 1) +Write-Host "Done: $OutputVideo ($([math]::Round($outLen, 1)) s, $mb MB)" -ForegroundColor Green diff --git a/promo/captions.srt b/promo/captions.srt new file mode 100644 index 0000000..2fdb427 --- /dev/null +++ b/promo/captions.srt @@ -0,0 +1,53 @@ +1 +00:00:00,300 --> 00:00:04,000 +Practice any music in your browser + +2 +00:00:04,300 --> 00:00:09,500 +Transpose, pitch and speed +all live, all independent + +3 +00:00:09,800 --> 00:00:15,500 +Nudge the pitch in cents. +The tempo never moves. + +4 +00:00:15,800 --> 00:00:22,500 +Drag the speed anywhere. + +5 +00:00:27,600 --> 00:00:33,000 +Isolate the vocal + +6 +00:00:33,300 --> 00:00:38,500 +Or push it down and play over the band + +7 +00:00:38,800 --> 00:00:46,500 +10-band EQ with instrument presets + +8 +00:00:47,500 --> 00:00:54,000 +Drop a marker on every section + +9 +00:00:54,300 --> 00:01:00,500 +Click one and that section loops + +10 +00:01:00,800 --> 00:01:07,000 +Drag across markers for a longer loop + +11 +00:01:07,300 --> 00:01:14,500 +Save any loop as a snippet + +12 +00:01:14,800 --> 00:01:22,000 +Chain snippets into a practice routine + +13 +00:01:22,300 --> 00:01:29,500 +Chord detection \ No newline at end of file diff --git a/promo/capture-card.mjs b/promo/capture-card.mjs new file mode 100644 index 0000000..a3ba569 --- /dev/null +++ b/promo/capture-card.mjs @@ -0,0 +1,89 @@ +/** + * Renders one of the bookend cards — promo/intro.html or promo/outro.html — to + * a numbered PNG sequence (or a single still), one file per video frame. + * + * A card's entrances are ordinary CSS animations. Rather than record in real + * time — which drops frames and bakes in whatever the machine was doing — this + * pauses every animation on the document and steps `currentTime` frame by + * frame, screenshotting each position. The output is deterministic: the same + * HTML always yields the same frames. + * + * Usage (apply-cards.ps1 drives it; run it by hand while iterating): + * node promo/capture-card.mjs --card outro --out --frames 41 --fps 30 + * node promo/capture-card.mjs --card intro --still promo/intro.png + */ +import { globSync, mkdirSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer-core'; + +const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); + +// The card is laid out in a fixed CSS box; scale 2 lands it on the +// screencast's native 2014x1510. +const CARD = { width: 1007, height: 755, deviceScaleFactor: 2 }; +// Past the last animation's end, so the still shows everything settled. +const SETTLED_MS = 5000; + +const argv = process.argv.slice(2); +const flag = (name, fallback) => { + const i = argv.indexOf(`--${name}`); + return i === -1 ? fallback : argv[i + 1]; +}; + +const card = flag('card', 'intro'); +const still = flag('still'); +const outDir = flag('out'); +const frames = Number(flag('frames', 41)); +const fps = Number(flag('fps', 30)); + +if (!['intro', 'outro'].includes(card)) throw new Error(`Unknown card: ${card}`); +if (!still && !outDir) throw new Error('Pass --out or --still .'); +if (outDir) mkdirSync(outDir, { recursive: true }); + +const cardUrl = 'file:///' + resolve(root, 'promo', `${card}.html`).replace(/\\/g, '/'); + +// Same Chrome for Testing install the e2e harness and the icon generator use +// (install: pnpm dlx @puppeteer/browsers install chrome@stable --path ./.browsers), +// falling back to whatever puppeteer-core can find on PATH. +const chromePath = + globSync(resolve(root, '.browsers', 'chrome', '*', 'chrome-win64', 'chrome.exe'))[0] ?? + process.env.CHROME_PATH; +if (!chromePath) { + throw new Error( + 'No Chrome found. Run: pnpm dlx @puppeteer/browsers install chrome@stable --path ./.browsers', + ); +} + +/** Freeze the document's animations at `ms` on their own timeline. */ +const seek = (ms) => + document.getAnimations().forEach((a) => { + a.pause(); + a.currentTime = ms; + }); + +const browser = await puppeteer.launch({ executablePath: chromePath, headless: true }); +try { + const page = await browser.newPage(); + await page.setViewport(CARD); + await page.goto(cardUrl, { waitUntil: 'load' }); + await page.evaluate(async () => { + await document.fonts.ready; + }); + + if (still) { + await page.evaluate(seek, SETTLED_MS); + await page.screenshot({ path: resolve(root, still) }); + console.log(still); + } else { + // Fixed three-digit names starting at 000: apply-cards.ps1 feeds the + // sequence to ffmpeg as %03d.png with -start_number 0. + for (let i = 0; i < frames; i++) { + await page.evaluate(seek, (i / fps) * 1000); + await page.screenshot({ path: resolve(outDir, `${String(i).padStart(3, '0')}.png`) }); + } + console.log(`${card}: ${frames} frames -> ${outDir}`); + } +} finally { + await browser.close(); +} diff --git a/promo/card.css b/promo/card.css new file mode 100644 index 0000000..4be4172 --- /dev/null +++ b/promo/card.css @@ -0,0 +1,129 @@ +/* + Shared skin for the screencast's bookend cards (intro.html, outro.html). + + Palette, hairline grid and loop-range motif are lifted from + store/tools/store-promo.html, so the video and the store listing read as the + same piece of design. + + Every entrance is a plain CSS animation with `both` fill, because + capture-card.mjs scrubs them: it pauses every animation on the document and + steps currentTime one frame at a time. So the timeline here IS the video + timeline — no JS, no rAF, nothing that depends on wall-clock time. Cards set + their own animation-name/-delay per element; the shared defaults below just + fix the duration and easing. +*/ +:root { + --text: #f0e9db; + --muted: #a79d8a; + --faint: #6e6656; + --accent: #e5a83e; + --accent-ink: #f3c577; + --font: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --mono: 'SF Mono', 'Roboto Mono', ui-monospace, Consolas, monospace; +} +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; background: #000; } + +/* 1007x755 is the capture viewport; at deviceScaleFactor 2 that is the + screencast's native 2014x1510. */ +.card { + position: relative; + width: 1007px; + height: 755px; + overflow: hidden; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + font-family: var(--font); + background: + radial-gradient(760px 620px at 50% 34%, rgba(229, 168, 62, 0.20), transparent 66%), + radial-gradient(620px 520px at 96% 108%, rgba(229, 168, 62, 0.09), transparent 62%), + linear-gradient(155deg, #211c15 0%, #14110c 60%, #0e0c09 100%); +} +/* Faint hairline grid, faded out toward the edges — same motif as the tile. */ +.card::before { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(238, 228, 208, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(238, 228, 208, 0.035) 1px, transparent 1px); + background-size: 74px 74px; + mask-image: radial-gradient(640px 540px at 50% 42%, #000 0%, transparent 76%); +} +.card > * { position: relative; } + +/* currentColor on the icon paths, so the accent lives in one place. */ +.glyph { display: block; color: var(--accent); } + +h1 { + margin: 0; + font-weight: 700; + letter-spacing: -0.025em; + line-height: 1; + color: var(--text); + white-space: nowrap; +} +/* Matches HeaderBar.svelte: only "by" takes the accent ink. */ +h1 em { font-style: normal; color: var(--accent-ink); } + +p { + margin: 0; + line-height: 1.38; + color: var(--muted); +} + +.caption { + font-family: var(--mono); + font-size: 17px; + letter-spacing: 0.14em; + color: var(--faint); +} + +/* Loop-range accent: the product's own visual idea, in one line. */ +.lane { + width: 430px; + height: 10px; + border-radius: 999px; + background: #3a332a; +} +.lane span { + position: absolute; + left: 30%; + width: 34%; + top: -4px; + bottom: -4px; + border-radius: 999px; + background: linear-gradient(90deg, var(--accent), rgba(229, 168, 62, 0.6)); + box-shadow: 0 0 0 1.5px rgba(243, 197, 119, 0.45), 0 8px 28px rgba(229, 168, 62, 0.25); +} + +/* ── Entrances ────────────────────────────────────────────── + Staggered, all short, all easing out: a card should feel like it settles + rather than performs. */ +@keyframes rise { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: none; } +} +@keyframes lift { + from { opacity: 0; transform: translateY(18px) scale(0.94); } + to { opacity: 1; transform: none; } +} +@keyframes sweep { + from { transform: scaleX(0); opacity: 0.4; } + to { transform: scaleX(1); opacity: 1; } +} +@keyframes appear { + from { opacity: 0; } + to { opacity: 1; } +} +/* Applied to everything on a card: elements without an animation-name simply + ignore it, and this way a nested bit like .lane span still inherits the + `both` fill the capture relies on. */ +.card * { + animation-duration: 0.55s; + animation-timing-function: cubic-bezier(0.22, 0.8, 0.28, 1); + animation-fill-mode: both; +} diff --git a/promo/intro.html b/promo/intro.html new file mode 100644 index 0000000..8ba1924 --- /dev/null +++ b/promo/intro.html @@ -0,0 +1,52 @@ + + + + +Note by Note — screencast intro card + + + + + + + + diff --git a/promo/intro.png b/promo/intro.png new file mode 100644 index 0000000..b67b92b Binary files /dev/null and b/promo/intro.png differ diff --git a/promo/outro.html b/promo/outro.html new file mode 100644 index 0000000..a9f4b7e --- /dev/null +++ b/promo/outro.html @@ -0,0 +1,60 @@ + + + + +Note by Note — screencast outro card + + + + + + + + diff --git a/promo/outro.png b/promo/outro.png new file mode 100644 index 0000000..9b4fefa Binary files /dev/null and b/promo/outro.png differ diff --git a/promo/reddit/01-main-guitarlessons.md b/promo/reddit/01-main-guitarlessons.md new file mode 100644 index 0000000..92ff613 --- /dev/null +++ b/promo/reddit/01-main-guitarlessons.md @@ -0,0 +1,55 @@ +# Main post — r/guitarlessons (Wave 2, day 3–5) + +> Verify the sidebar rules and flair on posting day; modmail first if unsure. +> Replace `[CHROME_WEB_STORE_LINK]` and attach the screencast as a **native Reddit video**. + +## Title (pick one — short personal ones first) + +Short & personal (preferred): + +- A year of evenings later: my YouTube practice tool, free and open source +- I made the practice tool I didn't want to rent +- Loop four bars until they stick — free, open source, no catch +- My €5/month loop button is now free for everyone +- Free practice tool that also reads the chords off any YouTube video (beta) + +Longer personal (the "I got tired of X, so I built Y" template is itself a Reddit cliché by now): + +- My entire practice routine is looping four bars of a YouTube lesson until they stick. I spent a year building a free tool around exactly that habit. +- A year of evenings fighting WebAssembly later, the practice tool I always wanted for YouTube lessons exists — and it's free and GPL, forever +- I put every paid feature of my old practice extension into a free open-source one. Here's 75 seconds of it slowing down a solo. + +Formula fallbacks: + +- I got tired of paying a subscription for practice markers on YouTube videos, so I built a free open-source alternative +- The practice features I needed in Transpose were Pro-only, so I built a free open-source alternative + +## Body — short version (use this one; the video carries the post) + +For years my practice setup was YouTube + the Transpose extension. The basics are free there, but the actual practice features — markers, saved setups, sequences, vocal reducer, EQ — are €4.99/month. Fair enough, it's their product. But it bugged me enough that I spent the past year building my own, and I made it free and open source (GPL), permanently — nobody can ever put the loop button behind a paywall again. + +The screencast shows most of it: transpose into your key, half speed without the chipmunk effect, markers and loops with a count-in, snippet chains (solo 4× at 50%, then 75%, then full speed, hands-free), vocal reducer, and EQ. And one thing my old tool doesn't do at any price: chord recognition (beta) — an ML model listens to the audio on your machine and draws the chord chart under the timeline. It's not always right yet, but it's often enough to get you playing along. Everything is saved per video. No accounts, no telemetry, no ads — audio never leaves your device. + +Chrome-only for now (Firefox in review). Feedback from people who practice with YouTube lessons is exactly what I'm here for. + +Chrome Web Store: [CHROME_WEB_STORE_LINK] · Source: https://github.com/patrickiel/note-by-note + +## Body — extended version (fallback, if the sub skews text-heavy) + +For a few years my practice setup was YouTube + the Transpose extension: drop a lesson into my key, slow the solo down, loop the hard part. The basics are free there, but everything that makes it a *practice* tool — markers, saved setups, clip sequences, vocal reducer, EQ — sits behind a €4.99/month subscription. Fair enough, it's their product. But setting markers on a YouTube video didn't feel like it should be a monthly bill. + +So I built my own. It took a lot longer than I expected (real-time pitch shifting in a browser is... a rabbit hole), and at some point I decided that if I'm doing this, it should be free for everyone and open source, permanently — it's GPL, so nobody can take it and put the loop button behind a paywall again. + +What it does, in one screencast: [video above] + +- **Pitch & speed, independently** — transpose ±12 semitones (±36 if you're weird like that), speed 25–200%, no chipmunk. The pitch engine is Rubber Band, the same library desktop DAWs use, compiled to WebAssembly. +- **Loops, markers, and "snippets"** — drop markers as you listen, loop between any two, add a count-in. Save a loop as a snippet and chain them: solo 4× at 50%, then 3× at 75%, then full speed, hands-free. +- **Vocal reducer & 10-band EQ** — push the vocal down so the band comes forward, or lean the mix toward the guitar. +- **Chord detection** — an ML model runs over the audio *on your machine* and draws a chord chart under the timeline. +- **It remembers** — markers, loops and settings are saved per video, so reopening a lesson brings your setup back. + +Privacy stuff, because extensions have a reputation: no accounts, no telemetry, no ads, audio never leaves your device. The whole thing including the audio engine is on GitHub. + +It's Chrome-only for now (Firefox is in review). Would genuinely love feedback from people who practice with YouTube lessons — what's missing, what's broken, what's confusing. + +Chrome Web Store: [CHROME_WEB_STORE_LINK] · Source: https://github.com/patrickiel/note-by-note diff --git a/promo/reddit/03-chrome-extensions.md b/promo/reddit/03-chrome-extensions.md new file mode 100644 index 0000000..df642f9 --- /dev/null +++ b/promo/reddit/03-chrome-extensions.md @@ -0,0 +1,35 @@ +# Technical post — r/chrome_extensions (Wave 1, day 1) + +> Use the "Self Promotion" flair. Replace `[CHROME_WEB_STORE_LINK]`. + +## Title (short personal options first) + +Short & personal (preferred): + +- The MV3 CSP/AudioWorklet dance cost me weeks — notes from shipping +- Real-time pitch shifting in an MV3 side panel, free and GPL + +Longer personal: + +- The CSP/AudioWorklet dance cost me weeks: notes from shipping real-time pitch shifting in an MV3 side panel (free, GPL) +- Things MV3 taught me the hard way while pitch-shifting YouTube in real time: no Blob worklets, one MediaElementSource per element, ever + +Fallback: + +- I built an MV3 side-panel extension that pitch-shifts any page's audio in real time (Rubber Band → WASM AudioWorklet). Free and GPL. + +## Body + +Note by Note is a music-practice extension: open the side panel on a YouTube lesson, transpose it into your key, slow it to half speed without the chipmunk effect, loop sections, chain practice snippets at increasing speeds. Free, open source, no accounts or telemetry. + +Some of the MV3 problems that turned out to be interesting, in case anyone's building in this space: + +- **The engine lives in the content script, not the panel.** The side panel is a thin mirror over a typed `chrome.runtime` Port, so closing the panel doesn't stop a running practice sequence. +- **AudioWorklets vs CSP.** Blob-URL worklets are blocked by extension CSP and by many sites, so the worklet processors ship as static files loaded from `chrome-extension://` URLs, and the WASM binary is fetched on the main thread and handed to the worklet via `processorOptions` — no fetch/eval inside the worklet. +- **Fallback chain.** Direct Web Audio attachment where possible; when the media element is CORS-tainted or DRM'd, it falls back to `tabCapture` processed in an offscreen document; local files get their own extension-page player where nothing is restricted. +- **Permissions.** Nothing at install; one optional-host-permission prompt from a user gesture on first Connect, revocable in settings. + +Store: [CHROME_WEB_STORE_LINK] +Source: https://github.com/patrickiel/note-by-note + +Happy to answer questions about any of it — the CSP/worklet dance especially cost me weeks. diff --git a/promo/reddit/04-instrument-subs.md b/promo/reddit/04-instrument-subs.md new file mode 100644 index 0000000..81665cb --- /dev/null +++ b/promo/reddit/04-instrument-subs.md @@ -0,0 +1,38 @@ +# Short variant — small instrument subs (Wave 3, week 2) + +> Targets: r/Saxophonics, r/trumpet, r/ukulele, r/Bass (verify r/Bass sidebar first). +> Lead with the screencast; keep it short. Swap the angle line per sub. + +## Title (short personal options first) + +Short & personal (preferred): + +- Sax/trumpet: The YouTube video transposes to Bb/Eb now, not my head +- Bass: Loop eight bars at 60%, duck the vocals — free tool I made +- Ukulele: Slow down, change key, loop the hard bar — free & open source + +Longer personal: + +- Sax/trumpet: Transposing in my head while sight-reading along to YouTube finally broke me — now the video shifts to Bb/Eb instead (free tool, open source) +- Bass: I made the tool for how I actually transcribe: loop eight bars at 60%, duck the vocals, EQ the low end forward. Free and open source. +- Ukulele: My practice tool for YouTube play-alongs is done and free for everyone: slow down, change key, loop the hard bar + +Fallbacks: + +- Sax/trumpet: I made a free extension that transposes any YouTube video in real time — play along in concert pitch or let it come to you +- Ukulele: Free open-source extension: slow down, transpose, and loop any YouTube video for practice +- Bass: Free open-source extension: loop any YouTube section, drop the speed, and EQ the mix toward the bass + +## Body + +I built this for my own practice and made it free and open source: a Chrome extension that processes any page's audio in real time — transpose up/down (Eb and Bb players: shift the *video* instead of transposing in your head), speed 25–200% without pitch change, loop any section with markers and a count-in, and chain snippets so a hard passage plays 4× at 50%, then 3× at 75%, then full speed, hands-free. + +Angle lines (pick per sub): +- **Bass:** There's also a vocal reducer and a 10-band EQ with a bass-forward preset, so the line you're transcribing actually sits on top. +- **Singing-adjacent:** The vocal reducer can also be inverted to isolate the vocal. + +No accounts, no ads, no telemetry; audio never leaves your machine. Everything is saved per video, so your loops come back when you reopen a lesson. + +Chrome Web Store: [CHROME_WEB_STORE_LINK] · Source: https://github.com/patrickiel/note-by-note + +Would love to hear what's missing for [instrument] practice specifically. diff --git a/promo/screencast-captioned.mp4 b/promo/screencast-captioned.mp4 new file mode 100644 index 0000000..842ccb9 Binary files /dev/null and b/promo/screencast-captioned.mp4 differ diff --git a/promo/screencast-promo.mp4 b/promo/screencast-promo.mp4 new file mode 100644 index 0000000..8e82475 Binary files /dev/null and b/promo/screencast-promo.mp4 differ diff --git a/promo/screencast.mp4 b/promo/screencast.mp4 new file mode 100644 index 0000000..4c566df Binary files /dev/null and b/promo/screencast.mp4 differ diff --git a/src/core/model/defaults.ts b/src/core/model/defaults.ts index 5f2a155..909d818 100644 --- a/src/core/model/defaults.ts +++ b/src/core/model/defaults.ts @@ -61,6 +61,12 @@ export const DEFAULT_KEYMAP: Record = { toggleLoop: 'l', rangeSelect: 'r', addSnippet: 'c', + // The obvious zoom keys are unavailable: -/= are speed, and Ctrl +/- is the + // browser's own page zoom inside the side panel. + zoomIn: 'z', + zoomOut: 'Shift+z', + zoomFit: '0', + toggleFollow: 'f', power: 'p', }; @@ -81,6 +87,10 @@ export const ACTION_LABELS: Record = { toggleLoop: 'Toggle loop', rangeSelect: 'Loop current section', addSnippet: 'Add snippet', + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + zoomFit: 'Zoom to fit / loop', + toggleFollow: 'Auto-follow playhead', power: 'Power', }; @@ -160,6 +170,9 @@ export const DEFAULT_UI_PREFS: UiPrefs = { snippets: false, }, markerView: 'blocks', + // On by default: follow does nothing until you zoom in, so this only means + // the first zoom during playback doesn't lose the playhead off the edge. + timelineFollow: true, favoritesSort: 'lastAccessed', libraryTab: 'recent', accentHue: 200, diff --git a/src/core/model/types.ts b/src/core/model/types.ts index 8721b2c..9b61d6b 100644 --- a/src/core/model/types.ts +++ b/src/core/model/types.ts @@ -237,6 +237,10 @@ export type ActionId = | 'toggleLoop' | 'rangeSelect' | 'addSnippet' + | 'zoomIn' + | 'zoomOut' + | 'zoomFit' + | 'toggleFollow' | 'power'; /** User preferences. Persisted in storage.local. */ @@ -283,6 +287,9 @@ export interface UiPrefs { collapsed: Record; collapsedSections: Record; markerView: MarkerView; + /** Page the zoomed timeline forward when the playhead leaves the visible + * window. Inert at full-track view, where the playhead is always on screen. */ + timelineFollow: boolean; favoritesSort: FavoritesSort; libraryTab: 'recent' | 'favorites'; /** Accent hue in HSL degrees (0–360), driving the themed accent colors. */ diff --git a/src/dev/mock.ts b/src/dev/mock.ts index 4fb00f4..fbd22b0 100644 --- a/src/dev/mock.ts +++ b/src/dev/mock.ts @@ -100,3 +100,19 @@ export function installMockState() { computedAt: Date.now(), }); } + +/** Opt-in playhead motion for previewing anything time-driven (timeline + * auto-follow, the chord strip) without an engine. Kept out of + * `installMockState` on purpose: the E2E export and the store screenshot tools + * render that state and have to stay deterministic. */ +export function installMockTicker() { + const STEP_MS = 33; + session.playing = true; + setInterval(() => { + const loop = session.loop.mode; + const from = loop?.kind === 'range' ? loop.startT : 0; + const to = loop?.kind === 'range' ? loop.endT : session.duration; + const next = session.t + (STEP_MS / 1000) * session.params.speed; + session.t = next >= to ? from : next; + }, STEP_MS); +} diff --git a/src/entrypoints/sidepanel/App.svelte b/src/entrypoints/sidepanel/App.svelte index 3926646..de29da3 100644 --- a/src/entrypoints/sidepanel/App.svelte +++ b/src/entrypoints/sidepanel/App.svelte @@ -6,7 +6,7 @@ import SettingsView from '@/features/settings/panel/SettingsView.svelte'; import TooltipLayer from '@/ui/shared/TooltipLayer.svelte'; import { sendMessage } from '@/core/messaging/rpc'; - import { installMockState } from '@/dev/mock'; + import { installMockState, installMockTicker } from '@/dev/mock'; import { connection } from '@/core/state/connect.svelte'; import { CAN_CAPTURE_TAB } from '@/core/platform'; import { features } from '@/core/features'; @@ -17,7 +17,10 @@ import { view } from '@/core/state/view.svelte'; import { sync } from '@/features/sync/panel/sync.svelte'; - const mock = new URLSearchParams(location.search).has('mock'); + const params = new URLSearchParams(location.search); + const mock = params.has('mock'); + // ?mock=1&play=1 also runs the playhead, for previewing time-driven UI. + const mockPlay = mock && params.has('play'); // Each panel feature loads its own storage concurrently (see core/features.ts). const ready = Promise.all(features.map((f) => f.init?.())).then( async () => { @@ -42,8 +45,10 @@ installShortcuts(); // Fire-and-forget: opening the panel must not wait on the network. void sync.init(); - if (mock) installMockState(); - else await connection.init(); + if (mock) { + installMockState(); + if (mockPlay) installMockTicker(); + } else await connection.init(); }, ); diff --git a/src/features/settings/panel/settings.svelte.ts b/src/features/settings/panel/settings.svelte.ts index eb6e9c3..4d63ad4 100644 --- a/src/features/settings/panel/settings.svelte.ts +++ b/src/features/settings/panel/settings.svelte.ts @@ -1,10 +1,10 @@ -import { DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../../../core/model/defaults'; +import { DEFAULT_KEYMAP, DEFAULT_SETTINGS, DEFAULT_UI_PREFS } from '../../../core/model/defaults'; import type { PanelId, SectionId, Settings, UiPrefs } from '../../../core/model/types'; import { settingsItem, uiPrefsItem } from '../../../core/persist/storage'; /** Settings synced two-way with storage.local. Components mutate via `update`. */ class SettingsStore { - current = $state({ ...DEFAULT_SETTINGS }); + current = $state(structuredClone(DEFAULT_SETTINGS)); loaded = $state(false); #writing = false; @@ -17,7 +17,15 @@ class SettingsStore { * a missing key never reaches the engine as `undefined` (which the port drops, * e.g. a NaN count-in duration that never elapses). */ #withDefaults(value: Settings | null): Settings { - return { ...DEFAULT_SETTINGS, ...value }; + return { + ...structuredClone(DEFAULT_SETTINGS), + ...value, + // Merged one level deeper: a keymap stored before an action existed would + // otherwise leave that action `undefined`, which the dispatcher can never + // match (the hotkey silently does nothing) and the Help sheet — which + // reads the keymap unconditionally — renders as a blank row. + keymap: { ...DEFAULT_KEYMAP, ...value?.keymap }, + }; } async init() { @@ -51,7 +59,7 @@ class SettingsStore { } async reset() { - this.current = { ...DEFAULT_SETTINGS }; + this.current = structuredClone(DEFAULT_SETTINGS); this.onChange?.(this.current); await settingsItem.setValue($state.snapshot(this.current) as Settings); } @@ -98,6 +106,11 @@ class UiPrefsStore { void this.#save(); } + setTimelineFollow(on: boolean) { + this.current.timelineFollow = on; + void this.#save(); + } + setFavoritesSort(sort: UiPrefs['favoritesSort']) { this.current.favoritesSort = sort; void this.#save(); diff --git a/src/features/shortcuts/panel/shortcuts.ts b/src/features/shortcuts/panel/shortcuts.ts index a9588fd..7f49dbd 100644 --- a/src/features/shortcuts/panel/shortcuts.ts +++ b/src/features/shortcuts/panel/shortcuts.ts @@ -7,8 +7,9 @@ import type { ActionId } from '../../../core/model/types'; import { snippets } from '../../snippets/panel/snippets.svelte'; import { markers } from '../../markers/panel/markers.svelte'; import { session } from '../../../core/state/session.svelte'; -import { settings } from '../../settings/panel/settings.svelte'; +import { settings, uiPrefs } from '../../settings/panel/settings.svelte'; import { view } from '../../../core/state/view.svelte'; +import { timelineView } from '../../../ui/timeline/timeline-view.svelte'; /** Builds the combo string a keydown event represents ("Shift+ArrowLeft"). */ export function comboFromEvent(event: KeyboardEvent): string { @@ -35,6 +36,17 @@ function nudgeTranspose(direction: -1 | 1): number { return Math.max(-limit, Math.min(limit, session.params.transpose + direction)); } +/** The span a range action works on: the panel's marker selection, else a range + * loop restored from the engine. */ +function activeRange(): { startT: number; endT: number } | null { + return ( + markers.range ?? + (session.loop.mode?.kind === 'range' + ? { startT: session.loop.mode.startT, endT: session.loop.mode.endT } + : null) + ); +} + function runAction(action: ActionId) { const seek = settings.current.seekInterval; switch (action) { @@ -94,14 +106,30 @@ function runAction(action: ActionId) { markers.selectCurrentSection(); break; case 'addSnippet': { - const range = - markers.range ?? - (session.loop.mode?.kind === 'range' - ? { startT: session.loop.mode.startT, endT: session.loop.mode.endT } - : null); + const range = activeRange(); if (range) snippets.addFromRange(range.startT, range.endT); break; } + case 'zoomIn': + timelineView.zoomStep(1); + break; + case 'zoomOut': + timelineView.zoomStep(-1); + break; + case 'zoomFit': { + // One key for "get me oriented": lost in a zoomed view → back to the whole + // track; already looking at the whole track → frame what's being practised. + if (!timelineView.atFit) { + timelineView.zoomToFit(); + break; + } + const range = activeRange(); + if (range) timelineView.zoomToRange(range.startT, range.endT); + break; + } + case 'toggleFollow': + uiPrefs.setTimelineFollow(!uiPrefs.current.timelineFollow); + break; case 'power': session.togglePower(); break; diff --git a/src/ui/Workspace.svelte b/src/ui/Workspace.svelte index c7ba192..f808d83 100644 --- a/src/ui/Workspace.svelte +++ b/src/ui/Workspace.svelte @@ -1,5 +1,6 @@ -
- -
+
+ +
+
-
-
- {#if loopRegion}
{ + if (e.pointerType === 'mouse') timelineDrag.hoverHint = gestureHint; + }} + onpointerleave={() => (timelineDrag.hoverHint = null)} + > +
+
+ {#if loopRegion} +
+ {/if} +
+ + {#if session.duration > 0} + {@const startId = markers.boundaryId('start')} + {}} + onjump={() => session.playFrom(0)} + /> + {/if} + + {#each markers.list as marker (marker.id)} + {@const i = markers.indexOf(marker.id)} + markers.move(marker.id, t)} + onjump={() => markers.playFrom(marker.id)} + ondragstart={() => (timelineDrag.active = true)} + ondragend={() => (timelineDrag.active = false)} + /> + {/each} +
+ + {#if !timelineView.atFit} +
+
+
{/if} -
- - {#if session.duration > 0} - {@const startId = markers.boundaryId('start')} - {}} - onjump={() => session.playFrom(0)} - /> - {/if} - - {#each markers.list as marker (marker.id)} - {@const i = markers.indexOf(marker.id)} - markers.move(marker.id, t)} - onjump={() => markers.playFrom(marker.id)} - ondragstart={() => (timelineDrag.active = true)} - ondragend={() => (timelineDrag.active = false)} - /> - {/each}