@omelhorsite/video-cli
v0.2.0
Published
omsv - command line editor for .omsv video projects, built on @omelhorsite/video-sdk: create, inspect, edit, transcribe, caption and render from the terminal, and keep projects in the omelhorsite cloud.
Maintainers
Readme
@omelhorsite/video-cli
omsv: create, inspect, edit, transcribe, caption and render .omsv video
projects from the terminal, and keep them in the omelhorsite cloud. Built for
agents as much as for people: you never need to watch the video, because the
CLI draws frames and contact sheets as PNG.
bun add -g @omelhorsite/video-cli
omsv new p.omsv --width 1080 --height 1920 --fps 30 --name "Promo"
omsv add-asset p.omsv clip1.mov clip2.mp4 music.m4a logo.png
omsv grid p.omsv sheet.png # look at the timeline
omsv render p.omsv out.mp4 --finalNeeds Bun and ffmpeg in PATH. frame, grid, wave
and text rendering rasterise through qlmanage, so image output is macOS
only. Local transcription uses whisper-cli (brew install whisper-cpp) and
a model in ~/.cache/whisper-models/; without them, the cloud transcribes.
The project file
A project is one .omsv file: a ZIP with the timeline and all of its media
inside, portable and self-contained.
project.omsv
├── manifest.json { "format": "omsv", "schema": 1, "savedAt": ... }
├── timeline.json the Project (asset paths relative: assets/...)
└── assets/<id>-<name> embedded media (stored uncompressed)unzip works, but prefer the CLI: it extracts media to a content-addressed
workspace, resolves paths, validates and packs again on save. Plain JSON
projects from before the format existed are accepted by every command.
Commands
new <out.omsv> [--width 1080] [--height 1920] [--fps 30] [--name s]
add-asset <p.omsv> <media...> embed media files in the project
add-color <p.omsv> <name> <RRGGBB> solid-colour image asset (flashes, backgrounds)
add-flash <p.omsv> --at 8.0 flash transition; --at is the CUT, not the start
[--duration 0.5] [--sound <assetId>] [--sound-volume 1] [--punch] [--wave]
asset-grid <p.omsv> <assetId> <out.png> contact sheet of a RAW asset [--every s] [--from --to]
inspect <p.omsv> [--json] summary; --json prints the raw Project
validate <p.omsv> [--json] structural checks; exit 1 on errors
transcribe <p.omsv> <assetId> words into the project, in source time
[--from s] [--to s] [--lang pt] [--model f.bin] [--at "23.4,28.6"]
[--cloud] [--exact] the omelhorsite transcriber instead of whisper
captions <p.omsv> <out.mp4> --cloud omelhorsite karaoke captions over the render
apply <p.omsv> --ops '{"ops":[...]}' declarative edits by id
exec <p.omsv> <script.ts> [--args '{...}'] arbitrary TypeScript with save()
render <p.omsv> <out.mp4> [--final] [--hw] [--from s] [--to s] [--preset] [--crf]
frame <p.omsv> <out.png> --t 3.2 one composed frame at t seconds
grid <p.omsv> <out.png> [--every s] [--cols n] [--width px]
wave <p.omsv> <out.png> waveform of the final mix
open <p.omsv> open in the desktop app
pack <legacy.json> <out.omsv> embed referenced media into a zip
cloud login | logout | whoami | ls | push <p.omsv> | pull <id|name>Every command takes paths, never stdin, and writes where you point it.
Seeing
frame, grid and wave composite the real timeline (layers, transforms,
opacity, fades, text, effects) through a cached low-resolution preview, so
they are cheap to call while you iterate: edit, grid, look at the PNG,
repeat. Grid labels are index assetName tSECONDS. Before choosing in-points
on a raw clip, asset-grid shows one frame per second of it.
Editing
Declarative, by id (get ids from inspect --json):
omsv apply p.omsv --ops '{"ops":[
{"op":"set-clip","id":"CLIP_ID","patch":{"start":2.5,"duration":3}},
{"op":"split-clip","id":"CLIP_ID","at":3.2},
{"op":"set-project","patch":{"name":"new name"}}
]}'Ops: set-project, add-track (video|audio|text), remove-track,
add-clip, set-clip, split-clip (linked audio splits too), remove-clip.
A set-clip patch merges transform field by field; other fields replace.
Or with a script, for anything the ops cannot say:
import type { Project } from "@omelhorsite/video-sdk";
export default async ({ project, core, save, args }: {
project: Project;
core: typeof import("@omelhorsite/video-sdk");
save: () => Promise<void>;
args: unknown;
}) => {
const clip = project.tracks.flatMap((t) => t.clips).find((c) => c.kind === "text");
if (clip && clip.kind === "text") clip.text = "new text";
await save();
};Nothing is saved unless the script calls save().
Placement
A visual clip's transform is { x, y, scale, opacity }: x/y are the
centre in normalised coordinates (0..1) and scale multiplies the size the
asset gets when fitted inside the frame. To fill the frame, scale =
max(projW/fitW, projH/fitH); a 16:9 source in a 1080x1920 project fits to
1080x607, so filling the height is scale ~= 3.16, then pan with x. Check
with frame; never leave a face cut off.
blurBehind and shadow on a clip take a fraction of the project width, so
they survive the low-resolution preview.
The flash
The one full-screen effect: a pale glow grows from the lower left, blows the
frame out to white for an instant and recedes, while the image takes a zoom
punch. --at is the cut; the transition wraps around it and the shutter
sound fires exactly there. Fades on the clips either side should be zero.
Captions
Captions are not burned into a finished mp4. Transcribe the raw material
once; the words live in the .omsv in source time and the render burns
them in the same pass. Cutting, trimming or reordering never invalidates
them: words follow their clips, and words in a piece that was cut disappear.
omsv transcribe p.omsv <assetId> --lang pt # whisper.cpp, offline
omsv transcribe p.omsv <assetId> --cloud --lang pt # omelhorsite transcriber
omsv transcribe p.omsv <assetId> --cloud --exact # per-word timings (video asset)
omsv render p.omsv out.mp4 # comes out captionedSpeech models place word boundaries approximately; cutting where the model
says a word ends clips its tail. --at "23.43,28.62" crosses the transcript
with the measured silence map and says where to cut instead. Use it before
choosing a cut.
captions --cloud renders the project without captions, sends it to the
omelhorsite captions renderer with the timeline's words and the project's
caption style, and downloads the captioned mp4. With an OAuth sign-in the
render has to stay under 64 MiB; raise --crf if it does not.
To edit the text without transcribing again, change
project.captions[].words with exec; the timings stay.
Cloud
omsv cloud login # device flow: shows a code and a link, opens the browser
omsv cloud ls # projects in the account's oms-video/ folder
omsv cloud push p.omsv # the whole file; replaces one with the same name
omsv cloud pull <id|name> # the whole file back
omsv cloud whoami | logoutCredentials live in ~/.config/oms-video/credentials.json with mode 0600.
--client-id and --base-url (or OMS_VIDEO_CLIENT_ID, OMS_VIDEO_BASE_URL)
point at another registration or another server. Do not run two cloud
commands at once: the refresh token rotates, and the second process is left
holding the old one.
Speed
A render exists so someone can look at it. In order of impact, measured on
an 84 s 1080x1920 project: --from/--to renders only the window that changed;
plain render is fast (veryfast, crf 23) and --final is delivery quality
(slow, crf 17); --hw uses the machine's hardware encoder. The bottleneck
is the filter graph, not the encoder: measure before optimising.
Gotchas
- A render writes to
<dest>.part-<pid>and renames at the end. Never point two renders at the same destination. - The preview cache lives in the OS temp dir keyed by the timeline hash, so
editing the project invalidates it. The first
frame/grid/waveafter an edit pays a low-resolution render; repeats are instant. - Assets inside the zip are stored uncompressed on purpose: saving is disk-copy fast and re-zipping never degrades anything.
- Homebrew's
ffmpeglacksdrawtext; that is fine, text is rasterised to PNG overlays.
