Skip to content

Media: psav & avtui

psav is ffmpeg with the ceremony removed. Instead of assembling a filter graph, you ask for the artifact you actually want — a screenshot, a contact sheet, a demo GIF, a trimmed clip, a screen recording — and get back typed objects carrying the exact ffmpeg command that produced them.

Terminal window
psav shot demo.mp4 --at 1m30s # one still, straight out of the middle of a talk
psav gif demo.mp4 --from 12 --dur 6 # a palette-optimised GIF for a PR description
psav record --seconds 20 --out feature.mp4 # record the screen while you demo the feature
psav probe *.mp4 | Show-Styled # every clip's vitals, in a navigable styled viewer
avtui # the interactive gallery: browse, then s / g / c to capture

Like psgit, psav is deliberately not aliased over ffmpeg. The real binary stays exactly where it is, fully interactive, for everything this wrapper does not model. When you want it with your own arguments, psav raw hands them over untouched:

Terminal window
psav raw -- -i in.mov -vf "hue=s=0" -y grayscale.mp4

SubcommandProducesTypical use
probe FILE...PsBash.MediaInfoWhat is this file? How long, how big, what codec?
shot FILEPNG still(s)A screenshot for docs, a thumbnail, N stills across a clip
frames FILEnumbered PNGsEvery Nth second, for frame-by-frame inspection
sheet FILEone tiled PNGA contact sheet: the whole video at a glance
gif FILEanimated GIFA demo GIF for a PR, an issue, a README
clip FILEH.264 MP4A trimmed, browser-safe demo video
recordH.264 MP4Record your screen while you demo something
to FILE --out XanythingConvert / rescale by output extension
raw -- ARGSwhatever ffmpeg doesPassthrough
OptionApplies toMeaning
--out PATHall producersOutput file (or, for multi-file runs, the output directory)
--width PXshot, frames, sheet, gif, clip, toRescale to this width, height auto
--at TIMEshotGrab exactly here
--from TIMEframes, gif, clipStart of the window
--to TIMEframes, gif, clipEnd of the window
--dur SECONDSframes, gif, clipLength of the window (wins over --to)
--count NshotN stills, evenly spread across the clip
--fps Nframes, gif, recordFrames per second (gif default 12, record 30)
--tiles CxRsheetGrid shape, default 4x3
--crf Nclip, recordQuality: lower is better/bigger. Default 22 (clip), 20 (record)
--muteclipDrop the audio track
--seconds NrecordRecording length, default 10
--window TITLErecordRecord one window instead of the whole desktop (Windows)
--display SPECrecordX11 display (:0.0) or avfoundation device index
--no-loopgifPlay once instead of looping forever
--dry-runallPrint the ffmpeg command; run nothing

7 · 7.5 · 1:02 · 00:01:02.500 · 90s · 1m30s · 1h2m3s · 250ms

Everything is normalised to ffmpeg’s canonical HH:MM:SS.mmm before it reaches an argument.


Terminal window
psav probe demo.mp4
demo.mp4 12.5s h264 1920x1080 @29.97fps aac 2ch 48000Hz 1 MB

That line is the object’s BashText, so psav probe pipes like a native tool. The object itself carries the fields:

PropertyTypeNotes
Path / NamestringFull path / filename
FormatstringContainer (mov,mp4,m4a)
DurationdoubleSeconds
LengthstringHuman form (12.5s, 1:07)
Size / SizeTextlong / stringBytes / 1 MB
Bitratelongbits per second
Videostring?Video codec, null for audio-only
Width / HeightintPixels
FpsdoubleExact — 29.97, not 30 (ffprobe reports 30000/1001)
Audiostring?Audio codec
Channels / SampleRateint
classstringvideo · audio · image · unknown — the styling hook

Because they are objects, the usual pipeline works:

Terminal window
# Every clip over 100 MB, biggest first
psav probe *.mp4 | Where-Object Size -gt 100MB | Sort-Object Size -Descending
# Total runtime of a folder of recordings
psav probe *.mp4 | Measure-Object Duration -Sum
# Anything not 1080p
psav probe *.mp4 | Where-Object { $_.Height -ne 1080 } | Select-Object Name, Width, Height

Terminal window
psav shot demo.mp4 --at 1m30s # one frame at 1:30
psav shot demo.mp4 --at 12 --out cover.png # explicit name
psav shot demo.mp4 --count 6 --width 800 # six stills spread across the clip
psav shot demo.mp4 --count 6 --out ./stills # ...into a directory

Two details that matter:

  • The seek goes before the input. ffmpeg jumps to the nearest keyframe instead of decoding up to that point — a screenshot from an hour-long recording lands in a fraction of a second, not twenty.
  • --count needs the duration, which psav gets from ffprobe. If the file cannot be probed it says so and points you at --at, rather than quietly grabbing frame 0 six times.

Default names are derived from the source, so a run never overwrites its input: demo.mp4 → demo-shot-01-00-00-12.500.png.


frames and sheet — the whole clip at a glance

Section titled “frames and sheet — the whole clip at a glance”
Terminal window
psav frames demo.mp4 --fps 1 --out ./frames # one PNG per second
psav frames demo.mp4 --from 30 --dur 10 --fps 4 # 4 fps over a ten-second window
psav sheet demo.mp4 # 4x3 contact sheet
psav sheet demo.mp4 --tiles 6x4 --width 240 # denser grid, smaller tiles

sheet derives its sampling rate from the clip’s real duration, so the tiles span the whole video instead of the first few seconds. frames reports every file that landed, not just the pattern:

Terminal window
psav frames demo.mp4 --fps 1 | Measure-Object # how many frames did we get?

Terminal window
psav gif demo.mp4 # whole clip, 12 fps, 800px wide
psav gif demo.mp4 --from 12 --dur 6 --fps 15 # just the interesting six seconds
psav gif demo.mp4 --width 600 --out pr-demo.gif

psav builds GIFs the palette way: palettegen derives an optimal 256-colour table for this clip and paletteuse maps the frames onto it, in a single filter graph. Straight GIF encoding uses a fixed web palette and turns terminal gradients and antialiased text into mud. This is the difference between a demo GIF that looks like your terminal and one that does not.


Terminal window
psav clip demo.mp4 --from 1:05 --to 1:30 # trim
psav clip demo.mp4 --width 1280 --crf 24 --mute # rescale, compress, drop audio

The output is H.264 in yuv420p with +faststart — the combination that plays in a browser, in Slack, and in a GitHub PR body rather than only in VLC. Rescaling uses scale=W:-2, not -1, because libx264 rejects an odd height.


Terminal window
psav record --seconds 20 # 20s of the desktop
psav record --seconds 30 --fps 24 --out feature.mp4
psav record --seconds 15 --window "ps-bash" # one window (Windows)
psav record --seconds 15 --display :0.0 # a specific X display (Linux)

The grabber is chosen per-OS — gdigrab on Windows, x11grab on Linux, avfoundation on macOS — and the spawn budget is the recording length plus real headroom, so the bounded-wait watchdog can never truncate your recording.


Every producing subcommand returns PsBash.MediaArtifact rows:

PropertyNotes
Path / NameThe file that was written
Kindscreenshot · frames · sheet · gif · clip · recording · convert
AtSource timecode, when the artifact came from one
Size / SizeTextHow big it turned out
ElapsedSeconds the encode took
Statusok · failed · planned
Detailffmpeg’s first error line, on failure
CommandThe exact ffmpeg invocation
classSame as Status — the styling hook

Which makes ordinary pipeline work possible:

Terminal window
# Grab six stills, keep the ones big enough to be a real frame
psav shot demo.mp4 --count 6 | Where-Object Size -gt 50KB
# What did that actually run?
psav gif demo.mp4 --dry-run | Select-Object -ExpandProperty Command
# Batch a folder, then report only the failures
ls *.mov | ForEach-Object { psav clip $_.FullName --width 1280 } |
Where-Object Status -eq failed | Select-Object Name, Detail

Because the plan is built before anything is spawned, --dry-run prints the identical command that would have run — no ffmpeg required:

Terminal window
psav gif demo.mp4 --from 2 --dur 5 --fps 20 --dry-run |
Select-Object -ExpandProperty Command
ffmpeg -hide_banner -loglevel error -y -nostdin -ss 00:00:02.000 -t 00:00:05.000 -i demo.mp4
-filter_complex "fps=20,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=5"
-loop 0 demo-demo.gif

Copy it, tweak it, run it by hand. That is also how the wrapper is tested — see Build a structured CLI wrapper.


Media objects carry a class, so Format-Styled / Show-Styled colour them without any extra work. The av sheet is auto-selected for PsBash.MediaInfo and PsBash.MediaArtifact:

Terminal window
psav probe *.mp4 | Format-Styled # static, coloured by media kind
psav probe *.mp4 | Show-Styled # navigable; Enter expands a row
psav shot demo.mp4 --count 4 | Format-Styled av
ClassMeaningStyle
.videohas a video streambright cyan
.audioaudio onlybright magenta
.imagea still, not a zero-length videobright green
.unknownffprobe could not classify itgray
.okartifact writtenbright green
.failedencode failedbright red, bold
.planned--dry-run — nothing rangray italic

That last distinction is the point of a dry run: a plan reads as inert, never as success.

Override any of it by dropping your own av.pcss in ~/.config/ps-bash/styles/ — it is appended after the built-in, so later rules win:

~/.config/ps-bash/styles/av.pcss
.video { color: brightyellow }
.failed { color: brightred; text-decoration: underline }

Terminal window
avtui # the current directory
avtui ./recordings # somewhere else

Every media file in the directory, probed and listed, with capture on a single keystroke:

KeyAction
↑ ↓ / j kMove
Enter / SpaceExpand the row’s full probe detail
sScreenshot from the middle of the focused clip
gDemo GIF from the first 5 seconds
cBrowser-safe MP4 from the first 10 seconds
rRescan
q / EscQuit

New artifacts join the list as soon as they are written, so s then Enter shows you the still you just took. Audio-only rows refuse a capture with a reason rather than producing a black frame.


Terminal window
# Record the feature, trim to the good part, ship a GIF
psav record --seconds 30 --out raw.mp4
psav gif raw.mp4 --from 4 --dur 8 --width 700 --out pr-demo.gif
psav probe pr-demo.gif | Select-Object Name, SizeText

CodeMeaning
0Success
1Input missing / unreadable, or ffmpeg failed
2Usage error (unknown subcommand, option without a value, missing --out)
124The encode exceeded its wait budget and was killed (whole process tree)
127ffmpeg / ffprobe not on PATH

psav is ~600 lines and nothing in it is special-cased. The two how-to articles walk through building the same thing from scratch for any CLI tool you use: