| name | demo-recording |
| description | Record a video of SLICC UI changes for a pull request. Two modes: a fast
CDP screencast of the *running* dev/e2e harness (to review what changed
frame-by-frame, issue #1264), and a polished cursor-animated MP4
(playwright-cli + ffmpeg) for showcase demos. Upload the result to the PR
with `gh image`. Use when asked to record a UI demo, capture a before/after,
showcase a feature, or attach a screencast to a pull request.
|
| globs | packages/webcomponents/**,packages/webapp/src/ui/** |
Demo Recording
Record a video of a SLICC UI change for a pull request, then upload it with
gh image (see § Embedding in GitHub PRs). Pick the mode that fits.
Two capture modes
- CDP screencast (§ CDP screencast) — attaches over Chrome DevTools
Protocol to the leader tab the dev/e2e harness already drives, streams
Page.startScreencast frames to disk, and optionally stitches a webm. Fast,
no separate browser, source-of-truth frames for "what changed". Use to review
UI changes frame-by-frame or capture a before/after for a PR.
- Polished cursor-animated MP4 (§ Polished MP4 with playwright-cli) —
playwright-cli video-start/stop with an injected visible cursor and chapter
markers, post-processed into a GitHub-embeddable MP4. Use for showcase demos.
CDP screencast (frame capture of the running harness)
packages/dev-tools/tools/slicc-screencast.mjs attaches to Chrome's remote
debugging port, locks onto the SLICC leader page target, and streams
Page.startScreencast frames to disk as frame-000001.jpeg … plus a
manifest.json (real capture timestamps). It records the same Chrome the
harness already uses — so you review exactly what the harness rendered. It is a
host-side node tool (like slicc-debug.mjs), not a SLICC shell command.
Quick start (against the deterministic fake-LLM e2e harness)
The e2e harness (packages/webapp/tests/e2e/playwright.config.ts) boots wrangler
(UI on :8787), the node-server thin-bridge, and the fake LLM, and launches
Chrome with --remote-debugging-port=9222. Reuse it to get a reproducible run:
npx playwright install chromium && npx playwright install-deps chromium
node packages/dev-tools/tools/slicc-screencast.mjs \
--port 9222 --url localhost:8787 --out /tmp/shot --video &
kill -INT %1
Frames + manifest.json land in --out. Review them directly (open a few
frame-*.jpeg), or watch screencast.webm.
Driving the UI while recording
Pick whichever fits — the recorder just captures whatever the tab shows:
slicc-cdp + slicc-debug.mjs (host, over the same CDP port) — drive the
agent with .agents/skills/cdp-smoke-test/scripts/slicc-cdp prompt "open the reference page"
or the shell with node packages/dev-tools/tools/slicc-debug.mjs shell "playwright-cli ...".
- A Playwright e2e test — the deterministic path. Boot the leader with the
harness helpers (
seedLocalLlmProvider → gotoLeader → waitForSW), then
submitUserMessage(page, …) + waitForTurnComplete(page) per phase. Spawn the
recorder as a child against :9222 for a fully reproducible capture.
- By hand — just interact in the visible Chrome window.
Live dev harness (non-e2e)
The recorder works against any SLICC Chrome with remote debugging — e.g.
npm run dev or npm run dev:standalone:fresh (wrangler :8787 + thin-bridge
:5710, Chrome CDP :9222). Same command; --port/SLICC_CDP_PORT and
--url/SLICC_TARGET_URL select the port and page target.
Options
| Flag | Default | Purpose |
|---|
--out <dir> | /tmp/slicc-screencast/<stamp> | Frame output directory |
--port <n> | SLICC_CDP_PORT else 9222/9223 | Chrome CDP port |
--url <substr> / --url-pattern <re> | SLICC_TARGET_URL | Pick the page target (errors if it matches nothing; with no filter, prefers the leader origin :8787/:57xx) |
--duration <sec> | until SIGINT | Auto-stop after N seconds |
--format jpeg|png | jpeg | Frame image format |
--quality <0-100> | 80 | JPEG quality |
--max-width/--max-height <px> | 1280 / 800 | Frame bounds |
--every-nth <n> | 1 | Capture every Nth frame |
--video / --fps <n> | off / 10 | Assemble screencast.webm |
Video assembly (best-effort)
The frames + manifest.json are the source of truth; --video is a
convenience. slicc-screencast-video.mjs resolves ffmpeg from PATH, falling
back to Playwright's bundled ffmpeg. That bundled build is stripped — it has no
image2 demuxer and only the VP8 encoder — so assembly feeds frames via
image2pipe on stdin (-c:v mjpeg -i pipe:0) and writes VP8 webm. On a full
ffmpeg build it also produces mp4/gif. If assembly fails, the frames remain.
Polished MP4 with playwright-cli
Record short (15–30s) demo videos of SLICC UI features using
playwright-cli video-start/stop, an injected visible cursor, and
ffmpeg for post-processing. Produces GitHub-embeddable MP4s.
Quick Start (playwright-cli)
Verify the dev URL first. Don't assume the obvious port serves the UI —
in thin-bridge/single-page-app architectures the "app port" may serve no UI
at all (SLICC's node-server serves none; the webapp loads from a separate
UI-serving port and dials back over a bridge token in the query string, e.g.
http://localhost:8787/?bridge=ws://localhost:5710/cdp&bridgeToken=<uuid>).
curl the candidate port or check the dev script before wiring a recording
around it. See "Attaching to a Single-Leader-Tab App" below if the app only
accepts one connected browser session.
playwright-cli open http://localhost:PORT
playwright-cli resize 1280 720
playwright-cli eval '<CURSOR_SNIPPET>'
playwright-cli video-start /tmp/demo.webm
playwright-cli click e119
playwright-cli run-code --filename /tmp/demo-script.js
playwright-cli video-stop
ffmpeg -y -ss 2 -t 18 -i /tmp/demo.webm \
-c:v libx264 -preset fast -crf 20 \
-pix_fmt yuv420p -movflags +faststart \
/tmp/demo.mp4
Visible Cursor
Headless Chrome renders no mouse pointer. Inject a fake SVG cursor
that you position via window.__mc(x, y) during the demo:
playwright-cli eval '(() => { var c = document.createElement("div"); c.id = "fake-cursor"; c.style.cssText = "position:fixed;width:24px;height:24px;z-index:999999;pointer-events:none;display:none;"; c.innerHTML = "<svg width=\"24\" height=\"24\" viewBox=\"0 0 24 24\"><path d=\"M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 01.35-.15h6.87a.5.5 0 00.35-.85L6.35 2.86a.5.5 0 00-.85.35z\" fill=\"#111\" stroke=\"#fff\" stroke-width=\"1.5\"/></svg>"; document.body.appendChild(c); window.__mc = function(x, y) { c.style.left = x + "px"; c.style.top = y + "px"; c.style.display = "block"; }; window.__hc = function() { c.style.display = "none"; }; return "cursor ready"; })()'
window.__mc(x, y) — move cursor to position (display it)
window.__hc() — hide cursor
Attaching to a Single-Leader-Tab App
Some apps only support one connected browser session per backend instance
(e.g. SLICC's standalone thin-bridge: one Chrome tab is "the leader" for a
given bridge token). playwright-cli open <url> launches an independent
browser — pointing it at a URL someone else's session already owns creates
two competing clients on the same channel, not a second view.
Recording without disturbing a live session: spin up an isolated
instance of the app's own dev stack on different ports, then attach
playwright-cli to that instance's browser instead of opening a new one:
"$CHROME_BIN" --remote-debugging-port=9522 --no-first-run \
--user-data-dir="$(mktemp -d)" about:blank &
cat "$USER_DATA_DIR/DevToolsActivePort"
playwright-cli attach --cdp "ws://127.0.0.1:9522/devtools/browser/<uuid>"
playwright-cli goto "http://localhost:<app-ui-port>/?<app-specific-auth-params>"
This keeps the recording fully isolated — the original session's ports,
tabs, and state are never touched. Tear down the isolated Chrome/dev-server
processes when done (kill by the PIDs you started, or by the ports you
picked) — they're throwaway infrastructure, not the user's environment.
Clipboard Interactions
Reading a value with navigator.clipboard.readText() proves what's in the
clipboard, but inserting it with page.keyboard.type(value) is simulated
typing, not a paste — visually similar, technically a different action. If
the demo is specifically about a copy/paste feature, do a real paste:
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], {
origin: 'http://localhost:PORT',
});
await composer.click();
await page.keyboard.press('Meta+V');
Without grantPermissions, both readText() and a real Meta+V paste can
hang waiting on a permission prompt that never resolves headlessly.
Recording Script Pattern
For complex demos with animated drags or multi-step flows, write a
run-code script file. The function receives page (Playwright Page):
async (page) => {
const wait = (ms) => page.waitForTimeout(ms);
await page.getByRole('button', { name: 'Terminal' }).click();
await wait(1500);
await page.evaluate(([x, y]) => window.__mc(x, y), [400, 300]);
await wait(300);
for (let i = 0; i <= 20; i++) {
const x = 400 + (700 - 400) * (i / 20);
await page.evaluate(([px, py]) => window.__mc(px, py), [x, 300]);
await wait(25);
}
await page.evaluate(() => .());
};
Run it:
playwright-cli run-code --filename /tmp/demo-script.js
Key Gotchas
Scripts must be self-contained
Always inject the cursor inside the run-code script, not in a
prior playwright-cli eval. If the page URL changes (e.g., switching
workbench surfaces updates ?ws= via replaceState), window.__mc
survives. But a full reload wipes it. Putting injection at the top of
every script makes it idempotent:
async (page) => {
await page.evaluate(() => {
const old = document.getElementById('fake-cursor');
if (old) old.remove();
const c = document.createElement('div');
c.id = 'fake-cursor';
c.style.cssText =
'position:fixed;width:24px;height:24px;z-index:999999;pointer-events:none;display:none;';
c.innerHTML =
'<svg width="24" height="24" viewBox="0 0 24 24"><path d="M5.5 3.21V20.8c0 .45.54.67.85.35l4.86-4.86a.5.5 0 01.35-.15h6.87a.5.5 0 00.35-.85L6.35 2.86a.5.5 0 00-.85.35z" fill="#111" stroke="#fff" stroke-width="1.5"/></svg>';
document.body.appendChild(c);
window.__mc = (x, y) => {
c.style.left = x + 'px';
c.style.top = y + 'px';
c.style.display = 'block';
};
window.__hc = () => {
c.style.display = 'none';
};
});
};
Reset page/component state before recording
Component state persists between recording takes. If a previous run
expanded a tree node or left a file selected, the next run will start
in that state — shifting all coordinates and breaking the script. Reset
before recording:
await page.evaluate(() => {
const ft = document.querySelector('slicc-file-tree');
if (ft && ft.isDirOpen('/workspace/skills')) ft.toggleDir('/workspace/skills');
});
Always verify state with a measurement eval before starting the
recording, especially when re-taking a failed demo.
Combine page.mouse.move with window.__mc for hover events
The visible cursor and the real browser pointer are independent.
page.evaluate(() => window.__mc(x, y)) only moves the SVG — it does
NOT fire pointerover/pointermove events on page elements. Always
drive both together in the moveTo loop:
async function moveTo(tx, ty, steps = 14, delay = 18) {
const p = await page.evaluate(() => {
const c = document.getElementById('fake-cursor');
return { x: parseInt(c.style.left || '640'), y: parseInt(c.style.top || '360') };
});
for (let i = 1; i <= steps; i++) {
const nx = p.x + (tx - p.x) * (i / steps);
const ny = p.y + (ty - p.y) * (i / steps);
await page.evaluate((v) => window.__mc(v[0], v[1]), [nx, ny]);
await page.mouse.move(nx, ny);
await wait(delay);
}
}
Without page.mouse.move, hover-triggered UI (dropdown buttons,
tooltips, action overlays) will not appear on screen.
Don't also call .locator().hover() or .locator().click() alongside a
manual moveTo loop. Playwright's own actionability pre-checks (visible,
stable, "receives events") run independently of your animation and can fail
intermittently — "element is not visible" / "X intercepts pointer events" —
even though the element is plainly on screen. Once you're driving the cursor
yourself, commit to it: use page.mouse.click(x, y) at the coordinates you
already animated to, not the locator-based click/hover helpers.
Prefer CSS selectors over snapshot refs for apps with periodic re-renders
playwright-cli snapshot-derived ref=... IDs go stale the moment the
underlying DOM node is replaced. If the app rebuilds parts of its UI on a
timer (polling, live-refresh panels), a ref captured even a few seconds
earlier can point at a detached node, failing with "Ref not found in the
current page snapshot." A CSS/attribute selector (page.locator('[data-id="..."]'))
re-resolves against the live DOM on every call and doesn't have this problem
— use selectors for anything you'll interact with more than once per script.
Native dialogs (confirm/prompt) in attached CDP sessions
page.once('dialog', async (d) => await d.accept(...)) registered inside a
run-code script is NOT reliably intercepted when attached via
playwright-cli attach --cdp — the CLI's own dialog tracking can surface it
as a pending "Modal state" instead, and the triggering run-code call
returns without finishing. Don't chase this inside the script. Instead:
playwright-cli run-code --filename open-dialog-step.js
playwright-cli dialog-accept "typed value"
playwright-cli eval '...check the DOM reflects the action...'
If you see "Cannot accept dialog which is already handled" that's fine — it
means an in-script handler beat you to it. If a later command reports a
NEW pending "Modal state" you didn't expect, dialogs can queue; drain them
with repeated dialog-dismiss/dialog-accept calls before continuing.
video-start's live screencast can silently render panels as solid grey
The CDP screencast behind video-start is a different rendering path than
page.screenshot(), and can fail to composite certain panels — rendering
them as a flat color for the entire recording while the rest of the page
looks fine. This is easy to miss at a glance and will ruin a recording whose
whole point is that panel. Verify before trusting a video-start take:
extract one frame and compare it to a page.screenshot() taken at the same
moment; if the panel is blank/flat in the video frame but populated in the
screenshot, video-start isn't usable for this recording.
Fallback: screenshot-sequence recording. page.screenshot() always
renders correctly, so build the video from a sequence of screenshots
instead of a live capture:
let fc = 0;
async function snap(page, holdFrames = 1) {
const n = ++fc;
await page.screenshot({ path: `/tmp/frames/frame_${String(n).padStart(5, '0')}.png` });
fc += holdFrames - 1;
}
import os, re
d = '/tmp/frames'
entries = sorted((int(re.match(r'frame_(\d+)\.png', f).group(1)), f) for f in os.listdir(d))
BASE = 0.09
lines = []
for i, (n, f) in enumerate(entries):
gap = (entries[i + 1][0] - n) if i + 1 < len(entries) else 20
lines += [f"file '{d}/{f}'", f'duration {max(gap, 1) * BASE:.3f}']
lines.append(f"file '{d}/{entries[-1][1]}'")
open('/tmp/concat.txt', 'w').write('\n'.join(lines) + '\n')
ffmpeg -y -f concat -safe 0 -i /tmp/concat.txt \
-vsync vfr -pix_fmt yuv420p -vf "fps=30,scale=1280:800" \
-c:v libx264 -preset fast -crf 19 -movflags +faststart \
/tmp/demo.mp4
--size for video-start
playwright-cli video-start defaults to fitting the video within 800×800.
If your actual viewport is larger (e.g. 1280×800), the output is scaled down
and padded with a dead margin. Always pass --size matching the real
viewport: playwright-cli video-start out.webm --size "1280x800".
CDP mouse events vs Pointer Events
page.mouse.down() dispatches CDP-level mouse events. These do NOT
reliably engage setPointerCapture() — if the target uses Pointer
Events with capture (like a drag handle), the drag won't work.
Workaround: use page.evaluate() to dispatch PointerEvent
directly on the element for the functional behavior, and use
page.evaluate() with window.__mc() for the visual cursor:
await page.evaluate(
([cx, cy]) => {
const el = document.querySelector('.my-drag-handle');
el.dispatchEvent(
new PointerEvent('pointerdown', {
clientX: cx,
clientY: cy,
pointerId: 1,
button: 0,
bubbles: true,
})
);
},
[x, y]
);
await page.evaluate((v) => window.__mc(v[0], v[1]), [x, y]);
Timing estimates drift — verify with frame screenshots
Script timing is cumulative and unpredictable: page.evaluate
overhead, animation frames, and async VFS calls all add invisible
latency. Never trust timing math alone. The correct workflow:
- Record the raw webm
- Convert to mp4
- Extract frames at estimated timestamps:
for ts in 1.5 3.0 5.5 9.0; do
frame=$(echo "$ts * 25" | bc | cut -d. -f1)
ffmpeg -y -i demo.mp4 -vf "select=eq(n\,${frame})" -vframes 1 /tmp/check_${ts}.png -loglevel quiet
done
- Read each frame image — adjust timestamps based on what's actually visible
- Only then add overlays/toasts
Timing
page.waitForTimeout(ms) — use this in run-code scripts
(setTimeout is not available in Playwright's Node context)
- Keep pauses short (200–400ms between actions) for a snappy demo
- Add 1–1.5s holds after each major state change so viewers can see it
- Total target: 15–30s — shorter is better for PR embeds
Video format
playwright-cli video-start produces .webm (VP8)
- GitHub PR embeds require
.mp4 (H.264) — always convert with ffmpeg
- Use
-movflags +faststart for instant playback in browsers
- Crop banners/chrome:
-vf "crop=iw:ih-18:0:18"
Chapter Markers
Add chapter markers for longer recordings (visible in the Playwright
dashboard, useful for debugging timing):
playwright-cli video-chapter "Panel opened"
playwright-cli video-chapter "Resize complete"
Action Annotations
video-show-actions overlays a callout on each subsequent CLI action
with the action name and a pointer animation between targets:
playwright-cli video-show-actions --cursor pointer --duration 500
This is useful for simple click/type flows but does NOT show custom
page.evaluate() interactions. For complex demos (drags, animated
sequences), use the manual cursor approach above.
Post-Processing with ffmpeg
ffmpeg -y -i demo.webm -c:v libx264 -preset fast -crf 20 \
-pix_fmt yuv420p -movflags +faststart demo.mp4
ffmpeg -y -ss 5 -t 15 -i demo.webm ... demo.mp4
ffmpeg -y -i demo.webm -vf "crop=iw:ih-18:0:18" ... demo.mp4
ffmpeg -y -ss 5 -t 15 -i demo.webm \
-vf "crop=iw:ih-18:0:18" \
-c:v libx264 -preset fast -crf 20 \
-pix_fmt yuv420p -movflags +faststart \
demo.mp4
Text Overlays / Chapter Toasts
ffmpeg's drawtext filter requires freetype, which may not be
compiled in. Check first: ffmpeg -filters 2>/dev/null | grep drawtext.
If missing, use Python + OpenCV + Pillow instead.
Freeze-frame toast pattern (Python)
Insert a static freeze at key moments with a text overlay — much more
reliable than trying to match exact timestamps to fast-moving content.
The workflow:
- Extract verification frames at estimated timestamps (see timing section above)
- Identify the right freeze frame for each section by reading the PNGs
- Run the script to insert freezes and overlay text
import cv2, subprocess
import numpy as np
from PIL import Image, ImageDraw, ImageFont
INPUT = "/tmp/demo.mp4"
OUTPUT = "/tmp/demo-toasts.mp4"
FREEZE = [
(1.2, 1.5, "Files panel - folder icons, file sizes, navigation"),
(3.0, 1.5, "Hover a file - action buttons appear"),
(6.0, 2.0, "Click CAT - file opens in terminal"),
]
FONT_SIZE = 15; PAD_X = 16; PAD_Y = 9; MARGIN_BOT = 38; FADE = 0.20
cap = cv2.VideoCapture(INPUT)
FPS = cap.get(cv2.CAP_PROP_FPS)
W = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
H = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
try:
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", FONT_SIZE)
except Exception:
font = ImageFont.load_default()
frames = []
while True:
ok, f = cap.read()
if not ok: break
frames.append(f)
cap.release()
def draw_toast(frame_bgr, text, alpha):
img = Image.fromarray(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)).convert("RGBA")
ov = Image.new("RGBA", img.size, (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
bb = d.textbbox((0, ), text, font=font)
tw, th = bb[]-bb[], bb[]-bb[]
bx = (W - tw)// - PAD_X; by = H - MARGIN_BOT - th - PAD_Y*
d.rounded_rectangle([bx, by, bx+tw+PAD_X*, by+th+PAD_Y*], radius=,
fill=(, , , (*alpha)))
d.text((bx+PAD_X, by+PAD_Y), text, font=font, fill=(, , , (*alpha)))
cv2.cvtColor(np.array(Image.alpha_composite(img, ov).convert()), cv2.COLOR_RGB2BGR)
output_frames = []
freeze_specs = [((t * FPS), (dur * FPS), txt) t, dur, txt FREEZE]
prev_src =
(orig_idx, n_freeze, txt) freeze_specs:
i (prev_src, (orig_idx, (frames))):
output_frames.append((frames[i], , ))
ff = frames[(orig_idx, (frames)-)]
fade_f = (FADE * FPS)
j (n_freeze):
a = j/fade_f j < fade_f (n_freeze-j)/fade_f j > n_freeze-fade_f
output_frames.append((ff, txt, (, (, a))))
prev_src = orig_idx
i (prev_src, (frames)):
output_frames.append((frames[i], , ))
cmd = [, , , , , ,
, , , , , (FPS), , ,
, , , , , ,
, , , , OUTPUT]
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, stderr=subprocess.DEVNULL)
frame_bgr, txt, alpha output_frames:
out = draw_toast(frame_bgr, txt, alpha) txt alpha alpha > frame_bgr
proc.stdin.write(cv2.cvtColor(out, cv2.COLOR_BGR2RGB).tobytes())
proc.stdin.close(); proc.wait()
Important: Use ASCII-only text — Unicode arrows (→), dots (·),
and em-dashes (—) render as empty squares with Helvetica.ttc in PIL.
Use - and -> instead.
Embedding in GitHub PRs
Upload the recording with gh image from the sibling
ai-ecoverse/ai-aligned-gh
wrapper (its gh shim adds an image subcommand — plain gh cannot attach
media). It uploads the file to content-addressed storage and prints a stable,
embeddable URL, so agents get a programmatic upload path (no browser
drag-and-drop). Supported: mp4 mov webm (plus image types).
gh pr comment <pr> --body "UI change: $(gh image --markdown /tmp/shot/screencast.webm)"
URL="$(gh image /tmp/demo.mp4)"
gh pr edit <pr> --body "…$URL…"
Use --repo owner/repo outside a repo dir and --timeout <seconds> to adjust
the wait (default 180s). The URL is content-addressed and stable — re-uploading
the same file returns the same URL.
Full Example: Resize Demo
async (page) => {
const wait = (ms) => page.waitForTimeout(ms);
const dims = await page.evaluate(() => {
const s = document.querySelector('slicc-shell');
const b = s.getBoundingClientRect();
return { left: b.left, width: b.width, top: b.top, height: b.height };
});
const avail = dims.width - 48;
const midY = dims.top + dims.height / 2;
async function drag(fromFrac, toFrac) {
const sx = dims.left + avail * fromFrac;
const tx = dims.left + avail * toFrac;
for (let i = 0; i <= 6; i++) {
const x = sx - 30 + 30 * (i / );
page.( .(px - , py - ), [x, midY]);
();
}
();
page.(
{
d = .();
d.(
(, {
: cx,
: cy,
: ,
: ,
: ,
})
);
},
[sx, midY]
);
( i = ; i <= ; i++) {
x = sx + (tx - sx) * (i / );
page.( .(px - , py - ), [x, midY]);
page.(
{
d = .();
d.(
(, {
: cx,
: cy,
: ,
: ,
})
);
},
[x, midY]
);
();
}
page.(
{
d = .();
d.(
(, {
: cx,
: cy,
: ,
: ,
})
);
},
[tx, midY]
);
}
page.(, { : }).();
();
(, );
();
(, );
();
(, );
();
page.( {
.()
.( (, { : }));
});
();
page.( .());
};
Orchestration:
playwright-cli open http://localhost:PORT
playwright-cli resize 1280 720
playwright-cli eval '<CURSOR_SNIPPET>'
playwright-cli video-start /tmp/resize-demo.webm --size "1280x720"
playwright-cli run-code --filename /tmp/resize-demo.js
playwright-cli video-stop
ffmpeg -y -i /tmp/resize-demo.webm \
-c:v libx264 -preset fast -crf 20 \
-pix_fmt yuv420p -movflags +faststart \
/tmp/resize-demo.mp4