What it is
V2TSR (short for Vid2Text-SR) takes a podcast recording and makes a cleaner, sharper 4K version of it. It cleans up sensor noise, can correct exposure, applies a colour look, sharpens, can raise the frame rate, and encodes on the graphics card.
It comes in two layers. The pipeline is a command-line script built on FFmpeg, and it is the single source of truth for how a video gets rendered. The desktop app is a small window on top of it: pick videos, pick a look, press Start. The app builds the exact command you would type yourself, so anything the app makes, the command line can make too.
It runs on a Windows laptop with an RTX 4050 Laptop GPU, an 8-core Intel Core Ultra 7 and 16 GB of RAM. Every number on this page was measured on that machine.
The chain
Before anything renders, V2TSR reads the file (size, frame rate, duration, codec, rotation, so portrait stays portrait) and proves which encoder works by test-encoding 10 frames on each candidate: NVIDIA first, then Intel, then AMD, then the CPU. It does the same for GPU decoding and GPU scaling. Nothing is assumed.
Default order
- 1Format8-bit 4:2:0, so every filter after it sees the same kind of frame.
- 2Denoise
hqdn3d, which smooths noise within a frame and across neighbouring frames. - 3ExposureA brightness curve from a whole-clip measurement (only with
--grade auto). - 4LookThe colour grade you picked.
- 5Sharpen
unsharp, at the strength the detail preset sets. - 6Frame rateUp to 60 fps with RIFE or minterpolate, if the source is slower.
- 7UpscaleTo fit inside 3840×2160 (or 2160×3840 for portrait), on the GPU when it can.
Unprompted profile order (scale first)
- 1FormatSame as the default.
- 2Denoise at source size
hqdn3d=0.5:0.5:8:6: light within a frame, medium across frames. - 3Upscale to 4KCPU Lanczos, so the next two steps can still run after it.
- 4Grade
eq=contrast=1.04:saturation=1.05, a gentle lift. - 5Sharpen last
unsharp=5:5:0.6:5:5:0.0on the 4K frame. - 6Keep the source frame rateNo interpolation: 30 fps in, 30 fps out.
Why this order
- Clean before anything else. Denoising, exposure and sharpening all run at the source size in the default chain. That's far cheaper than at 4K, and the upscaler then fills in already-clean pixels instead of enlarging noise.
- Exposure before sharpening, so the sharpen amount applies to the final contrast, not to a frame that's about to get brighter.
- Frame rate before the upscale. Interpolating at 4K runs at about 0.35 fps. Moving it before the upscale took a 4-second test clip from 10:17 to 1:38, then to 1:04 once the upscale moved to the GPU.
- The GPU scaler has to be last. Once frames are on the graphics card, FFmpeg's other filters can't touch them. That's why the Unprompted profile, which grades and sharpens after scaling, uses the CPU Lanczos scaler instead.
Getting the video in
By default the source is streamed through a text (Base64) copy in 50 MB chunks and decoded back on the fly. Base64 is about 1.33x the size of the video. If the file's index sits at the start it's piped straight into FFmpeg; if not, it's decoded to a temporary file first. --direct-input skips the round trip and reads the file directly, which saves about 2.3x the source size on disk. The app always uses the round trip.
The exposure measurement (--grade auto)
It builds one brightness histogram for the whole clip from frames shrunk to 320 px wide, 4 per second. The black point is the darkest 0.5% and the white point the brightest 0.5%, aiming the middle brightness at 0.42 with the gain capped at 1.6x and the curve's gamma held between 0.60 and 1.15. Default strength is 0.8.
Guards keep it from doing harm. A clip with almost no tonal range (a title card or a fade) is left alone. The black point never goes past 0.25. The correction backs off if it would make the middle worse. A clip that's already well exposed isn't touched.
Looks
The look is the colour grade, set with --look (default Clean bright). There are 15. --look also accepts a path to a .cube LUT file. A LUT is global, so it can't brighten a face without also brightening the wall behind it.
| Name | What it does | Filter |
|---|---|---|
| natural | No grade at all. | none |
| camera_natural | The Unprompted look: a touch more contrast and colour, otherwise the camera as it is. | eq=contrast=1.04:saturation=1.05 |
| clean_bright | The default: brighter mids, slightly warm, a little more colour. | curves=master='0/0 0.25/0.27 0.5/0.55 0.75/0.80 1/1', colorbalance=rm=0.02:bm=-0.02, eq=saturation=1.08 |
| doc_neutral | Documentary neutral: lifted black, lowered white, colour down to 0.88. | curves + saturation 0.88 |
| punchy | S-curve contrast, colour up to 1.22. | curves + saturation 1.22 |
| vivid_pop | Built for YouTube thumbnails: strong colour, slightly brighter. | curve, saturation 1.35, brightness +0.02 |
| teal_orange | Teal shadows, orange highlights. | colorbalance + saturation 1.12 |
| cool_modern | Shifted blue, slightly muted. | blue shift, saturation 0.95 |
| film_warm | Warm, with lifted blacks like film. | per-channel curves, saturation 0.95 |
| matte_fade | Blacks lifted to 0.08, muted colour. | curves, saturation 0.85 |
| golden_soft | Warm mids. | curves, saturation 1.05 |
| vintage_70s | Per-channel curves for a faded 70s print. | curves, saturation 0.85 |
| bleach_bypass | Low colour, hard contrast. | saturation 0.55 + contrast curve |
| noir_cool | Crushed, blue-tinted, with a vignette. | curve, saturation 0.7, vignette=angle=PI/4.5 |
| bw_contrast | Black and white with contrast. | hue=s=0 + contrast curve |
Detail presets
A detail preset is a pair: how much noise to remove and how much to sharpen. Set it with --detail (default Crisp). --denoise and --sharpen replace either half on its own, or turn it off.
How to read hqdn3d=a:b:c:d: the first two numbers are cleanup within one frame (brightness, colour), the last two are cleanup across frames. Across-frame cleanup removes grain that flickers frame to frame without softening edges much, which is why the Unprompted preset leans on it.
| Name | Denoise | Sharpen |
|---|---|---|
| light | hqdn3d=1:1:3:3 | unsharp=5:5:0.4:5:5:0.0 |
| unprompted | hqdn3d=0.5:0.5:8:6 (medium, mostly across frames) | unsharp=5:5:0.6:5:5:0.0 |
| balanced | hqdn3d=1.5:1.5:6:6 | unsharp=5:5:0.7:5:5:0.0 |
| crisp | hqdn3d=1:1:4:4 | unsharp=5:5:1.1:5:5:0.0 |
| max_detail | none | cas=0.6, unsharp=5:5:0.5:5:5:0.0 |
| smooth_skin | hqdn3d=4:3:8:6 | unsharp=5:5:0.4:5:5:0.0 |
| film_grain | hqdn3d=1.5:1.5:6:6 | unsharp=5:5:0.6:5:5:0.0, then noise=alls=6:allf=t+u (adds grain back) |
The Unprompted profile
--profile unprompted is the setting every Unprompted video uses. It was picked from a side-by-side test on 5 November 2026: the darker, camera-natural version with medium across-frame cleanup won. The brighter, more heavily denoised 60 fps version lost. Since 6 November the same chain is used on every reel and every YouTube video, not only full episodes. Reels run it at 1080×1920 instead of 4K.
What it sets
- Look:
camera_natural - Detail:
unprompted - Exposure grade: off
- Separate denoise and sharpen: cleared (the detail preset decides)
- Order: scale first (CPU Lanczos)
- Quality: CQ 16, about 93 Mb/s
How it overrides other settings
- It's applied after everything else is read, so it wins over any
--look,--detail,--grade,--denoise,--sharpenor--qualityyou also pass. --codecstill counts: H.264 or HEVC is your choice.--fps,--interpand--gpu-scaledo nothing under it, because scale-first keeps the source frame rate and scales on the CPU.
The full chain it produces: format → hqdn3d=0.5:0.5:8:6 → Lanczos to 4K → eq=contrast=1.04:saturation=1.05 → unsharp=5:5:0.6:5:5:0.0 → yuv420p, at the source frame rate, CQ 16. A render from the profile matched the approved test sample at 44.9 dB PSNR; the remaining difference is encoding.
Frame rate
The default target is 60 fps (--fps 60). What happens depends on the source:
| Source | What V2TSR does |
|---|---|
| Exactly half the target (30 → 60, 29.97 → 59.94) | RIFE, a neural network on the GPU that draws the in-between frame. It only works when it can exactly double the rate. |
| Any other rate below 60 | minterpolate (FFmpeg's motion-compensated interpolation, mi_mode=mci:mc_mode=aobmc:me_mode=bidir:vsbmc=1). Correct, but slow on the CPU. |
| Within 2% of 60 (59.94 counts) | Nothing: the frames are used as they are. |
| Above 60 | Frames are dropped down to 60. |
--interp auto (default) uses RIFE when its environment works, and otherwise warns and falls back to minterpolate. Asking for --interp rife when it isn't available stops with an error instead of silently changing method. RIFE computes motion at half resolution by default (--rife-scale 0.5), which is about 3x faster at the same quality.
In the RIFE path, one FFmpeg decodes and cleans, RIFE generates the frames in between, and a second FFmpeg upscales and encodes. The audio is copied aside first and joined back during the encode.
RIFE is the RIFE v4 model (MIT licence) running in half precision on CUDA. There is no AI upscaler: scaling is Lanczos on the CPU or bicubic on the GPU. Neural upscaling was tried and is only practical on short clips with this GPU.
Encoding
Output is H.264 by default. HEVC is about 40% smaller, and AV1 is available on the command line. Quality is constant-quality (CQ): lower is better. The default is 19, and the Unprompted profile uses 16.
| Encoder | Settings |
|---|---|
| NVIDIA (NVENC) | -preset p5 -tune hq -rc vbr -cq Q -b:v 0 -spatial-aq 1 -temporal-aq 1 -rc-lookahead 32, plus -bf 3 -b_ref_mode middle except for AV1 |
| Intel (QSV) | -preset slower -global_quality Q -look_ahead_depth 40 |
| AMD (AMF) | Constant QP: I = Q, P = Q+2, B = Q+4 |
| CPU | libx264 / libx265 -preset medium -crf Q-1; libsvtav1 -preset 6 -crf Q+10 |
H.264 uses the High profile. HEVC is tagged hvc1 so Apple devices play it. Audio is copied as-is when it fits in an MP4, otherwise it's re-encoded to AAC at 192 kb/s. Files get +faststart so they play while they download. Renders are written to *.partial.mp4 and only renamed once they finish, so a half-written file never looks done.
The app
Opening it: the desktop or Start menu shortcut. Opening it twice brings the open window to the front, and videos dropped on the icon go into the open window's queue. When the app's own files change, it shows "V2TSR was updated" with a Restart now button and keeps your queue. Changes to the render engine need no restart at all. The title bar shows which version is running.
What's on screen
- Queue: Add videos (mp4, mov, mkv, m4v, avi, webm), Remove, and a table with length, estimated time and size, and status.
- Colour: every look. Default Clean bright.
- Detail: every detail preset. Default Crisp.
- Format: H.264 (plays everywhere) or HEVC (about 40% smaller).
- Save to: next to each video (default) or a folder you choose.
- Unprompted episode look: on by default. Turns off Colour and Detail and uses the profile.
- Move finished renders to Google Drive: on by default.
- Buttons: Preview 10 s, Start, Pause/Resume, Cancel, Open output folder.
While it runs
- Live metrics: progress, frames per second, speed against real time, time left, GPU temperature, load, power and clock, video memory, CPU, RAM, free disk, power source, elapsed time, hottest temperature, current stage.
- Stages: Checking the video, Preparing the video, Measuring brightness, Rendering, Checking the result.
- History: every past render, including whether it reached Drive. Double-click one that's only on Drive to open it there.
- Details: the raw log.
Where things go
- Finished render:
<name>_4k60.mp4in aremasterfolder next to the source, or in the folder you chose. The name says 4k60 even when the Unprompted profile keeps 30 fps. - Preview: 10 seconds starting at a third of the way in (at most 60 s; from the start for clips of 20 s or less). It opens in your video player.
- Drive move: uploads one at a time, in their own process so they keep going after the window closes, and keep the laptop awake. Each upload retries up to 8 times, 10 minutes apart. The local file is deleted only after the copy on Drive has the same checksum. Any failure keeps the local file.
Safety checks
- Disk space before starting. Output size is estimated from measured rates (16.5 MB/s for 4K60 H.264, 10 MB/s for HEVC or AV1, both with 10% headroom) plus 1 GB. The temporary working copies are counted too. If the working folder and the output share a drive, both are checked against that one drive. Not enough space stops the render before it starts. The app adds its own check (estimate × 1.3 + 5 GB) and asks before going ahead.
- No overwriting. The app asks before replacing a file, and the pipeline refuses unless told to.
- The finished file is checked for the right frame size, a frame rate within 0.5, a duration within 1 second, and audio present. It also needs at least 98% of the frames it should have. That last check exists because a damaged recording once came out with the right duration but thousands of frames missing.
- A watchdog on the RIFE path. If any step errors, or no new frame arrives for 3 minutes (5 for the first frame), it stops all of them instead of hanging. Paused or asleep time doesn't count.
- Retries in the app. A GPU failure is retried once after 10 seconds. Any other failure marks that video Failed and the queue carries on.
- Keeping the machine on. The laptop stays awake while rendering. The app warns about running on battery (the GPU drops to about 20 W and renders run 2-5x slower), another render already running, and uploads still in progress. Cancel cleans up the partial file.
Measured numbers
"Real time" means a 10-minute video takes 10 minutes. 0.5x real time means it takes 20.
| Job | Result |
|---|---|
| 1080p 60 fps → 4K60 H.264, 2:03 clip, no interpolation needed | 56.7 fps, 0.94x real time, 1.47 GB |
| Unprompted profile (1080p30 → 4K30, CQ 16) | 1.23x real time, about 11.6 MB/s of output |
| Real episode, 25.3 min, 4K60 H.264 | 22.8 GB (15.0 MB/s) |
| 1080p30 → 60 fps, RIFE (motion at half resolution) | 28.8 fps, 0.48x real time |
| Same, RIFE at full resolution | 9.3 fps |
| Same, minterpolate | 4.2 fps (RIFE is 6.9x faster) |
| Same, rife-ncnn-vulkan | 2.7-5.8 fps |
| 10-minute RIFE stress test, 1080p30 → 4K60 | 27.1 fps, 0.45x, 27:39 total, 6.82 GB. No slowdown over the run (27.3 fps first 10%, 29.2 fps last 10%). GPU peaked at 68 °C. |
| 30 fps stress test with minterpolate | 2.4 fps, 0.04x: 12:38 for 30 seconds. All 8 CPU cores at 90-100%, GPU mostly idle. A 60-minute video would take about 25 hours, which is why RIFE became the default. |
| 60-minute episode with RIFE (projected) | About 2 h 15 min, about 41 GB H.264 |
The app's estimates come from these: 0.45x real time for 30 fps sources, 0.94x for 60 fps sources, about 0.68 GB per minute for H.264 and 0.41 GB per minute for HEVC. Quality of RIFE's made-up frames against real 60 fps frames: 44.6 dB PSNR (minterpolate 45.8 dB). Both are close to the real thing.
Limits and decisions
Limits
- RIFE only doubles the frame rate. Any other rate falls back to minterpolate, which is slow.
- The GPU scaler uses bicubic, not Lanczos. FFmpeg's GPU Lanczos wraps its overshoot around (pure white turns black), which left dotted outlines on hard edges: 612 pixels off by more than 60 levels on a test frame, against a maximum of 39 with bicubic. 4K renders made before the switch have the artifact.
- A LUT is global. It can't light a face without lighting the wall too.
- The app's Unprompted checkbox text undersells it. It says "light grain cleanup" and 30 fps. The profile actually uses medium across-frame cleanup and keeps the source's frame rate, so a 60 fps source stays 60.
Decisions
| Date | Decision |
|---|---|
| 29 Oct 2026 | A very bright "LIT" grade was rejected: too bright, and it looked low quality. |
| 3 Nov 2026 | Defaults set to Clean bright + Crisp. A darker, warmer "moody" look matched to a reference reel was turned down. |
| 3 Nov 2026 | RIFE became the default interpolator after a clean 10-minute test. Interpolation moved before the upscale. |
| 5 Nov 2026 | From a side-by-side test of full episodes, the darker camera-natural look with medium across-frame cleanup, sharpening last, the source's 30 fps and a high bitrate became the Unprompted profile. The brighter, more heavily denoised 60 fps version lost: the plain version looked more natural, but its grain showed, so medium cleanup was added to it. |
| 5 Nov 2026 | After a GPU error hung a render, the watchdog was added. After a damaged file passed the duration check, the frame-count check was added. |
| 6 Nov 2026 | The Unprompted chain became the look for every reel and every YouTube video. |
Flag reference
| Flag | Default | What it does |
|---|---|---|
--profile unprompted | off | The Unprompted chain. Overrides look, detail, grade, denoise, sharpen and quality. |
--look | clean_bright | Colour grade by name, or a .cube file. |
--detail | crisp | Denoise + sharpen pair. |
--denoise / --sharpen | from preset | Replace one half of the detail preset, or off. |
--grade | auto | auto (whole-clip exposure), dynamic (per frame), off, or fixed eq values. |
--strength, --black-pct, --white-pct, --target-median | 0.8, 0.5, 99.5, 0.42 | Tune the auto exposure. |
--fps | 60 | Target frame rate. |
--interp | auto | auto, rife or minterpolate. |
--rife-scale | 0.5 | Resolution RIFE measures motion at. |
--interp-first | on | Interpolate before the upscale (turning it off is about 6x slower). |
--gpu-scale | on | Bicubic on the GPU. --no-gpu-scale uses CPU Lanczos. |
--scale-first | off | Upscale right after denoising, then grade and sharpen at 4K (what the profile uses). |
--codec | h264 | h264, hevc or av1. |
--encoder | auto | auto, nvenc, qsv, amf or cpu. |
--quality | 19 | Constant quality. Lower is better and bigger. |
--audio-bitrate | 192k | Only used when audio has to be re-encoded. |
--verify-exposure (--check) | off | Re-measures the finished brightness. Costs about 40% more render time. |
--direct-input | off | Read the source directly instead of through the Base64 copy. |
--base64-output | off | Also write a Base64 copy of the result. |
--start, --duration | whole file | Render part of a video. |
--dry-run (--detect-only) | off | Probe and print the plan without rendering. |
-y | off | Allow overwriting an existing output. |