My format shifting journey (av1, h265, CPU encoding) [Complete, pending new av1 encoders]

I’m making a new thread mostly because the other AV1 threads don’t do things the exact way I’m doing them, nor focus on what I focus on, so rather than try and take over their threads, here I am.

Goals:

  1. Format shift personal movie collection for long term storage.
  2. Preserve videos with as close to near-lossless quality as possible, while reducing file size as much as possible. Keeping in mind that the power use of a very slow encode might outweigh the space savings at a given cost of storage. IE: if storage is cheap, just save the raw files.
  3. “One setting to rule them all” Realistically this will probably be 2-3 setups and a flow-chart to pick between them. I don’t watch any anime, but anime or early Disney animated films would require meaningfully different settings to what I have.

147300_.zip (75.7 MB)

This is a collection of screen grabs for every h265 and AV1 encode I have done so far, so that you can flip through them and spot the differences for yourself. This frame is consistently one of the hardest for either h265 or AV1 (SVT-av1 specifically) to encode. It has a lot of fine detail (the grain pattern in the marble, smoke, clothing wrinkles) and difficult lighting (hard shadows on clothing, detail in blacks from clothing). Also, being from 1999, it has actual film grain.

encode_tests.zip (33.6 KB)

This is a spreadsheet I made (apparently uploading a raw .ods file isn’t allowed) to track whether an encode is profitable, or to help me split hairs when visual quality is close. The ‘SSIM avg’ column is computed via a script I have that averages the ‘All’ SSIM score for every frame in a given encode pass.

In the h265 days I did try using VMAF, but I find pixel-peeping to be a far better solution, and have switched over to SSIM exclusively. Specifically my process is:

  1. Use SSIM to identify which frame(s) are among the worst in an encode (I have a script that gives me the top 10 worst frames)
  2. Visually look at the list and identify any frames which show up in multiple encodes consistently
  3. Put those frames in another script which will go encode by encode and extract that exact frame from each encode. Allowing me to layer them on top of each other (in GIMP for example) and easily see what changes from encode to encode.

So, in the end, it’s my eyeball that determines quality, and SSIM is only used to help me find where to look.

What I’ve found so far:
I did try the PSNR tuning mode (-svtav1-params tune=1) and found it made the image worse than VQ (visual quality, tune = 0) mode. I will re-test it once I’ve found encode settings I’m happy with.

Film Grain Tuning (-svtav1-params film-grain=)

  • Enabling this feature instantly nerfs performance. On a preset 6, CRF 32 run (I had to start somewhere) I went from 172 FPS to 44.
  • For the test movie used, film grain settings of 30-50 provide no visually identifiable change (to me). Meaning setting 30 and setting 50 look the same.
  • Grain settings of 20 and below look like someone shot the frame out of focus.
  • Grain settings of >= 30 actually introduce more grain than the original movie had. And because the grain is a pattern, it looks worse than the natural grain the movie had.
  • In my (limited) tests, it did not seem to provide any meaningful space savings. (<1MB at most)
  • Grain should be given one last chance once I settle on what encode settings I am happy with for visual quality.

Presets and CRF

  • In h265, preset and CRF are more or less disconnected. Preset is how fast you want the encode to go, CRF is how good you want it to look.
    • There is some visual quality change from preset alone, but compared to AV1 it is minor.
    • In general, lowering CRF by 1, while raising the preset by 1 category, results in the same visual quality. IE: Slow-21 and Medium-20 should look pretty close to identical.
  • In AV1, preset and CRF are related. Preset DOES change quality even if CRF remains constant.
    • Presets are not strictly an improvement in all scenarios. For example, look at the screens for preset 6 CRF 20 and look at the granite behind Trinity’s head. Then look at the preset 3 CRF 20 pass. In preset 6, it looks better than in preset 3. But then look at Neo’s coat (as you view him, the left of his coat) and notice that preset 3 preserves the shadows more faithfully than preset 6 does. CRF remained the same, so we are purely seeing the quality changes the preset alone brings to the table.
    • CRF and preset need to be somewhat close to each other. Using a high preset (say 6) with a low CRF (15) balloons the file size. Preset 6 does make the encode go fast, but the file size grows exponentially if the CRF is too low for the preset.
    • In general, this table provides what CRF ranges make sense for what preset:
Preset Lowest Recommended CRF
6 25
5 10
4 1
3 1
2 1
1 1

To save you needing to open the spreadsheet (which also doesn’t have all the data, it was shared for the formulas) here are the h265 encoding results:

Preset CRF File Size (bytes) VMAF Encode Time (s) Cost to Encode Cost to Store Total savings (min) Total savings (max) Effective FPS of encode SSIM avg Notes
veryfast 17 13,638,785,557 98.024514 2280 $0.04 $1.10 $0.48 $1.45 86.0 0.989588
Slow 21 10,229,301,875 97.952315 10915.32 $0.18 $0.83 $0.61 $1.58 18.0 0.932447 Good detail preserved
faster 18 11,695,746,266 97.840642 2363.46 $0.04 $0.95 $0.63 $1.61 83.0 0.988597 Makes granite blurry
medium 19 11,203,315,853 97.770822 4023.77 $0.07 $0.91 $0.65 $1.62 48.7 0.987871 Creates a ‘halo’ around Trinity’s head of fuzzy pixels. Looks worse than fast, somehow.
fast 19 9,901,491,168 97.617865 2178.6 $0.04 $0.80 $0.78 $1.75 90.0 0.987519 Viable, a bit of smearing, but minor
medium 20 9,493,557,954 97.530238 3805.97 $0.06 $0.77 $0.79 $1.76 51.5 0.986599 Makes granite look fuzzy
veryslow 23 7,281,782,040 97.484458 54000 $0.90 $0.59 $0.13 $1.10 3.6 0.985194 Good detail preserved
medium 21 7,989,584,534 97.253111 3573.58 $0.06 $0.65 $0.91 $1.89 54.9 0.985218
fast 21 6,951,535,923 97.031401 2111.3 $0.04 $0.56 $1.02 $1.99 92.9 0.985072
faster 21 6,969,701,535 97.027839 2012.02 $0.03 $0.56 $1.02 $1.99 97.5 0.98502
veryfast 21 6,963,443,574 97.020857 1977.45 $0.03 $0.56 $1.02 $2.00 99.2 0.985003

And here’s the table of SVT-AV1 tests:

Preset CRF File size SSIM avg Encode Time (s) Grain Tune Cost to encode Cost to store Total savings (min) Total Savings (max) Rawtime Effective FPS Notes
5 5 25,970,127,466 0.993285 1536.752 -1 0 $0.03 $2.10 -$0.51 $0.46 25m36.752s 127.590528595375 Pixel-perfect to original
3 10 11,934,861,357 0.990331 4760.455 -1 0 $0.08 $0.97 $0.57 $1.55 79m20.455s 41.1882897748219 Slightly better than preset 4 (in terms of quality)
4 10 11,930,788,932 0.989879 2537.649 -1 0 $0.04 $0.97 $0.61 $1.58 42m17.649s 77.2663989385451 Slightly better than preset 5 (in terms of quality)
5 10 12,108,739,788 0.989626 1424.769 -1 0 $0.02 $0.98 $0.62 $1.59 23m44.769s 137.618799959853 Viable, and around 10x the speed of h265 for roughly the same filesize
2 15 7,373,968,517 0.988214 15161.148 -1 0 $0.25 $0.60 $0.77 $1.74 252m41.148s 12.9327277855213 Loss of detail greater than in h256
3 15 7,019,701,213 0.96769 8602.513 30 0 $0.14 $0.57 $0.91 $1.88 143m22.513s 22.792758348636 More grain than original
3 15 7,019,410,060 0.974034 8607.923 20 0 $0.14 $0.57 $0.91 $1.88 143m27.923s 22.7784333107998
3 15 7,018,245,599 0.980799 8615.896 10 0 $0.14 $0.57 $0.91 $1.88 143m35.896s 22.757354545598
3 15 6,993,933,809 0.987416 5501.848 -1 0 $0.09 $0.57 $0.96 $1.93 91m41.848s 35.6380256233905 Loss of detail greater than in h256
4 15 7,057,829,629 0.987002 2500.819 -1 0 $0.04 $0.57 $1.01 $1.98 41m40.819s 78.4043147464891 Loss of detail greater than in h256
5 15 7,184,785,645 0.98664 1438.365 -1 0 $0.02 $0.58 $1.01 $1.99 23m58.365s 136.317972141981 Loss of detail greater than in h256
6 15 8,505,336,370 0.986897 1101.868 -1 0 $0.02 $0.69 $0.91 $1.88 18m21.868s 177.947812260634
3 20 4,096,666,031 0.984512 5806.464 -1 0 $0.10 $0.33 $1.19 $2.16 96m46.464s 33.7684001829685
4 20 4,199,626,977 0.984175 2517.053 -1 0 $0.04 $0.34 $1.24 $2.21 41m57.053s 77.8986378117584
5 20 4,279,650,358 0.983914 1590.778 -1 0 $0.03 $0.35 $1.25 $2.22 26m30.778s 123.257299258602
6 20 5,313,952,980 0.984217 1123.135 -1 0 $0.02 $0.43 $1.17 $2.14 18m43.135s 174.578300916631
6 25 3,443,314,663 0.982076 1139.728 -1 0 $0.02 $0.28 $1.32 $2.29 18m59.728s 172.036661378855
6 32 1,918,913,775 0.95215 4364.214 50 0 $0.07 $0.16 $1.39 $2.36 72m44.214s 44.9279068349994 Looks bad
6 32 1,918,811,306 0.955061 4367.156 40 0 $0.07 $0.16 $1.39 $2.36 72m47.156s 44.897640478151 Looks bad
6 32 1,918,730,677 0.959553 4364.13 30 0 $0.07 $0.16 $1.39 $2.36 72m44.130s 44.9287715993795 Looks bad
6 32 1,918,439,525 0.965862 4368.704 20 0 $0.07 $0.16 $1.39 $2.36 72m48.704s 44.8817315157997 Looks bad
6 32 1,917,275,080 0.972595 4368.798 10 0 $0.07 $0.16 $1.39 $2.36 72m48.798s 44.8807658307846 Looks bad
6 32 1,892,929,992 0.979196 1119.448 -1 0 $0.02 $0.15 $1.45 $2.42 18m39.448s 175.153289835705 Looks bad
6 32 1,753,565,212 0.98004 1136.838 -1 1 $0.02 $0.14 $1.46 $2.43 18m56.838s 172.474002452416 Looks bad

Conclusions:
To get the same visual quality h265 can give me, I need a CRF somewhere between 14 and 10. But at that CRF, the file size would be roughly the same as h265. SVT-AV1 is HUGELY faster than h265. Like 10x faster for very minimal quality loss (with preset 5) and even with preset 3 CRF 10, it is essentially equal to h265 Slow CRF21, while still being 3x faster to encode.

But there’s no file size savings at CRF 10. Or the file is slightly worse (by about 1 GB). And what is the net cost savings of AV1, since it’s 10x faster? About 2 cents. $0.02. Electricity, even at 20c/kwh, is far cheaper than disk.

In terms of disk space? If you’re willing to have your encodes take longer, using the slower/veryslow preset in h265, while bumping the CRF up slightly (veryslow CRF23 / slower CRF22) will save you significant disk space for the extra encoding time with no obvious visual quality change. Slow 21 is 10.23GB. Veryslow 23 is 7.2 GB. However, veryslow lives up to its name and only runs at about 3.6 FPS, and the power usage of such a long encode means that you only end up saving $0.13 versus the $0.61 you save with Slow 21. I have not tested slower, I’ll do that tonight, mostly from morbid curiosity. And also because my SSIM score on my slow21 encode is weirdly low, so I’m going to re-do it to test.

Further testing Conditional
If anyone has experience with aom-psy101, aom-av1ador, or aom-av1-psyex, feel free to reach out. I tried looking into these and av1-psy does seem to improve visual quality, but it does that by encoding incredibly slowly. I didn’t test presets 1 and 2 because if I wanted it to take 10 hours to encode a movie…it has to encode, at worst, overnight. I’m not going to plan my day around not being able to use my PC for a few hours while an encode finishes.

I can’t find any data that tells me these custom forks are good enough to warrant testing. I’d be happy to learn I was wrong, but I’m not curious enough to explore at this time.

2 Likes

i compress my home videos from RAW but i couldn’t touch movies.
a lot of the look of the movies, the ones i have from 20th century, have the grain and micro contrast which is almost always gone after a compression.

So, if you look at my encode shots, h265 Slow CRF 21 is what I consider the ‘gold standard’ (though fast 19 seems viable too). They both appear to be nearly lossless, with the only changes I can find being how pixelated bits are shaded. It’s different, but not ‘blurry’. Take a look at the screens and tell me what you think.

What are you using for browsing for all this format shifted content?

Nothing? I just keep them in a folder and click on the name I want to watch. I never stream them anywhere, and I’m the only user. So…literally just files in a folder.

The “gold standard” or “golden record” is always the original data unless the format shift is lossless (i.e., can be mapped back to the original bits).

So, again, if you bother to look at the screenshots, there is frame called _master.png and that is indeed taken from the source itself, without any compression applied. And when you compare Slow 21 to that image, you’ll see some very, very subtle changes, but nothing meaningful. Is that too much to ask that you try to critique me in good faith?

i think i get what you’re saying about film grain. looking at those comparison shots, it’s interesting that the higher grain settings (30-50) add more grain than the original film had.

It’s definitely hard to judge grain properly from still frames. grain is something you perceive better in motion. but I’m with you on maintaining visual quality than saving space.

just those subtle film textures are part of what gives older movies their character.

Until we have things like dolby vision support in encoders that don’t cost a small fortune to license and decoders that work without drm; format shifting most modern content seems sort of a waste.

I don’t own a device capable of displaying dolby vision. I’m still on the fence as to whether HDR is worth it. Investing in a monitor/TV that properly supports HDR is a big invest when most HDR content is just upsampled SDR. It’s more of a ‘do I want to dump that much money to find out if I think it’s worth it?’ and right now that answer is ‘no’. Either that or there are $150 4k HDR panels (because all HDR movies are 4k, so no sense getting HDR on anything else) that are excellent and I’m just not aware of it.

But getting a 4k panel means either gaming at 4k, or playing at a non-native resolution (which tends to look bad). So…I think 4k is still 5-10 years away for me.

For me, the main reasons to format shift are because playback speed is so much faster. Removing unskippable sections is also great. It’s more about QoL without losing anything visually, for me at least. I like watching videos on VLC rather than powerdvd. RIP NvDVD.

I also save some space by downmixing 5.1 to stereo (since I don’t have a 5.1 system). But I do keep the original files so that if I ever do get a 5.1 system I can just replace the audio track without needing to re-encode everything. “But that has cost!” Old 1 TB HDDs aren’t super useful to me, and 1TB / 30GB/file = ~30 movies per drive. And the HDD is still an order of magnitude faster than going back to optical if I ever want to re-encode or replace something.

“Then why encode anything!” Because waiting for HDDs to spin up is one of the things I hate most in life. I’ve done it before, I hated it immensely. Also, streaming an un-encoded file takes like 25-30MB/s of bandwidth, and while a HDD should be able to deliver that, the reality is you’ll get random stutters or freezes and I’ve yet to find a video player that really likes scrubbing around in such high bandwidth files. They all kinda…poo the bed on some level.

As I do more tests it becomes clear: CRF sets the file size (± a few % or so) and preset sets the speed. Quality doesn’t strictly get better or worse with a higher preset, so preset is far, far more about speed than quality.

CRFs of 10 or lower are as big or bigger than h265 encodes, but with higher presets (4-5) they are much faster than h265…I’ve been looking at my photos all morning. I need a break, and some more time to think on what the data is telling me before I draw a conclusion.

I also plan to run at least a couple GPU encoding tests, just because ‘why not’? If the GPU can encode as good as the CPU, why not? I have a 9060xt 16gb, so modern, but cheap. Mostly just curious.

as a heavy HDR monitor user and color grader, I’ve got some insights to share.

I bought a Blackmagic 10-bit video card to get accurate 10 bit signal and HDR data to the monitor with Resolve’s “clean feed” the PA32UCX miniLED monitor with 1200 nits brightness? Not worth the hefty investment. unless you like to grade HDR for your own enjoyment.
After extensively comparing HDR and SDR versions of numerous movies in Potplayer, switching it on and off, I can say you can be 100% satisfied with SDR.

Many movie rips are already in 10-bit, and the much-touted “increased brightness” of HDR is heavily dependent on the color grader’s skills and capabilities. The perceived quality improvement is often minimal.

I would have been content with an accurate & bright SDR monitor all these years. The real pro of this monitor isn’t its HDR capabilities, but its exceptional color accuracy (using a calibrator) and SDR brightness.

You don’t need to spend $3,000 to get a monitor of this caliber anymore. Technology has advanced, and more affordable options now offer similar quality.

For gaming, I’ve found that smaller monitors - 24" or 27" - offer better experience compared to 32". The screen size allows for better content spread, improved visual clarity, and a more focused gaming experience, not to mention the fps benefit without aggressive scaling.

It’s not about size, but about finding the perfect balance between screen real estate and visual performance that suits my specific gaming needs.
BUT a 4K monitor is useful for in-game screenshots because at least in CP2077 it’s based on the screen and i tried to upscale it later and it looked terrible.

2 Likes

I’m not an expert by any means and this may just be specific to the Sony televisions I watch content on but the difference between a movie with quality hdr grading vs the sdr version is quite extreme to my eyes. To the point where it is hard to accept the sacrifice of losing the wider luminance range and improved coloring for the sake of saving disk space format shifting.

1 Like

i think what you’re seeing is the tone map being turned on and off.
good thing you said TV because the TVs do A LOT of processing.

You don’t have to recode to lose the annoyances of DVDs/Blurays though. I remux my Blurays to .mkv (keeping the original, usually H.264, encode and the selection of audio and subtitle tracks that I want). Original quality, no unskippable sections, and you can use any player program you want. (I also just serve my movies as files and play them with mpv or Kodi.)

I spent a little bit of time researching and testing recoding to AV1 but I couldn’t find a setting that gave substantially smaller files while keeping the original quality. I found that the time and effort of recoding wasn’t worth it to me.

2 Likes

@Nescafe Thanks for that!

I feel like I’ve explored what I want to explore. Everyone I can find says hardware encoding quality is worse than CPU for any given setting, and so I don’t see any value in exploring that. There are a few other AV1 forks out there, attempting to tune the aom or SVT encoders to have better quality per byte. Problem is, I can’t find any benchmarks for aom-psy101 or aom-av1ador, and while I can find benchmarks for svt-av1-psy, the increase in quality comes at the cost of encoding speed, which removes AV1’s singular advantage.

If anyone has experience with those other encoders and feels I should give them a try, let me know, but for now, the data + my instincts are pointing to it not really being worth it. h265 seems to be my path forward for now. AV1 is a valiant attempt, and it has some use cases and merits, just not for me.

Since I’ve more or less decided that h265 is my way forward, I decided to look into whether there was any more juice to squeeze out of h265 before I make my ‘standard’. Two settings were apparently worth looking into, PME (parallel motion estimation) and the tune itself. I used ‘-tune grain’ for all my tests because I remembered, from years ago (maybe a decade?) I had found that looked the best. So I ran some tests:

Preset Slow and CRF 21 for this table. h265.

-tune grain PME File size SSIM avg Encode Time (s) Cost to encode Cost to store Total savings (min) Total Savings (max) Effective FPS
Active Off 9,899,722,890 0.987526 12110.25 $0.20 $0.80 $0.62 $1.59 16.2
Active On 9,899,722,887 0.987526 12517.08 $0.21 $0.80 $0.61 $1.58 15.7
Inactive Off 4,410,313,469 0.984222 8517.29 $0.14 $0.36 $1.12 $2.09 23.0
Inactive On 4,410,313,466 0.984222 8874.1 $0.15 $0.36 $1.11 $2.09 22.1

My first thought: “Holy crap! Grain tuning is making the files huge!” But then I remembered to actually check my encodes. The SSIM says they should look nearly identical. Reality? Without -tune grain, the encodes look as good as AV1 CRF 20 or so. Meaning Neo’s abs become a smear. Trinity’s neck becomes an instagram filter. Lots of fine detail goes away. 4GB and around an hour of encoding time is the cost of that fine detail.

What’s funny? Some of the h265 docs I looked at said “Tuning shouldn’t be necessary in most cases”. It has QUITE the impact.


^^This image is effective grain_nopme

The reason there isn’t a notune_nopme image? The forum told me it has the same SHA1 hash as notune_pme, and it won’t let me add a duplicate photo. So, when I say “They are identical” it extends to being bit-for-bit identical. :stuck_out_tongue:

To some, I’m sure this seems like splitting hairs, but here’s the thing, I am just saving you time. Use whatever encode settings you feel are best. I gave you the data. I have what I want, and what I feel I need. You can make your own choices.

If anyone was wondering, yes, I did run AV1 with -tune grain as well. Just for giggles, I’ll run another test without -tune grain specified and see what happens.

Oh, and as for PME. With grain mode active, it seems to slow down rendering by about 1 FPS. Probably because so many settings are turned down/off that the overhead outweighs any performance gain. But without a tuning applied, it does give you about 1 FPS. So, conditional utility with literally no visual impact.

So uh…I realized I’m a moron. The frames I’ve been attaching are not 147300. The script I use to extract them had a flaw and it was extracting the nearest keyframe, NOT the frame I was asking for. So the screenshots are all frame 146880. That said, the actual 147300 is a shot of all the columns exploding.

I realized I never actually shared the scripts I used to test, so here they are:


import os
import subprocess
import time
from datetime import datetime, timedelta

#Reminder, Audio, Video, and Subtitle streams all count from 0
def h265_custom_encode(INPUT_CLIP, OUTPUT_FOLDER, preset, crf, audio_stream_index, default_subtitle_stream_index=None, tune=None):
    os.makedirs(OUTPUT_FOLDER, exist_ok=True)

    base_name = os.path.basename(INPUT_CLIP)
    OUTPUT_CLIP_1 = os.path.join(OUTPUT_FOLDER, base_name)

    log_file_path = os.path.join(OUTPUT_FOLDER, f"encode-log_{preset}_crf{crf}_{base_name}.txt")

    avx512_params = "-x265-params asm=avx512:pmode=1"

    cmd = [
        "ffmpeg", "-y", "-i", INPUT_CLIP,
        "-map", "0:v:0",
        "-c:v", "libx265",
        "-crf", str(crf),
        "-preset", preset,
    ]

    if tune:
        cmd += ["-tune", tune]
        cmd += ["-metadata", f"encoding_tune={tune}"]
    else:
        #Only include PME if -tune grain is NOT specified, it's a 1FPS perf loss to run PME with grain.
        avx512_params += ":pme=1"
    cmd += avx512_params.split()

    # Copy audio/subs, add metadata
    cmd += [
        "-map", f"0:a:{audio_stream_index}",
        "-c:a", "libopus",
        "-ac", "2",
        "-b:a", "160k",
        "-map", "0:s?",
        "-c:s", "copy",
        "-metadata", f"encoding_data={preset}",
        "-metadata", f"encoding_crf={crf}"
    ]

    # If a default subtitle stream is specified, set its disposition
    if default_subtitle_stream_index:
        cmd += [f"-disposition:s:{default_subtitle_stream_index}", "default"]
    else:
        cmd += [f"-disposition:s:0", "0"]

    cmd += [OUTPUT_CLIP_1]

    with open(log_file_path, "w") as log_file:
        print(f"Running encoding: {' '.join(cmd)}")
        log_file.write(f"Running encoding: {' '.join(cmd)}\n")
        log_file.flush()
        subprocess.run(cmd, stdout=log_file, stderr=log_file, check=True)

subprocess.run(["powerprofilesctl", "set", "performance"])

RIPS_FOLDER="/path/to/rips/"
MY_ENCODE="/path/to/encoded/folder/"

h265_custom_encode(os.path.join(RIPS_FOLDER, "Ben Hur_t00.mkv"), MY_ENCODE, "slow", 21, 1, tune="grain")
h265_custom_encode(os.path.join(RIPS_FOLDER, "sergeant_york", "Sergeant York_t01.mkv"), MY_ENCODE, "slow", 21, 0, tune="grain")
h265_custom_encode(os.path.join(RIPS_FOLDER, "muppet_christmas", "Muppet Christmas Carol - BLU-RAY_t01.mkv"), MY_ENCODE, "slow", 21, 0, default_subtitle_stream_index=6, tune="grain")

subprocess.run(["powerprofilesctl", "set", "power-saver"])

^^That script’s job is to be a record of how you encoded the movies. This script is for when you’re done testing, you’ve found the settings you want to use, and you want to encode the video, audio, set subtitles, etc. This is for the ‘real’ encodes, not for testing.

It downmixes audio to libopus stereo since I don’t have a 5.1 setup (and when I get one, I’ll just rip and replace the audio track from the master). Only one audio stream is copied, and the one stream you tell it to copy is downmixed to libopus stereo at 160k.

If no subtitle track is specified, all subtitles are copied and none are set as default. If a subtitle track is specified (I tend to set forced subs as my default) then it becomes the default, but all subtitles are still copied. Reason: Even 30+ subtitles take up, what, 1-2 MB? No reason to strip them I can see.

#!/bin/bash

INPUT_CLIP="/path/to/testmovie.mkv"
OUTPUT_FOLDER=/path/to/test_data/folder/

h265_custom_encode() {
    INPUT_CLIP=$1
    OUTPUT_FOLDER=$2

    preset_1=$3
    crf_1=$4
    TUNE=$5

    OUTPUT_CLIP_1="${OUTPUT_FOLDER}crf${crf_1}_${preset_1}-${TUNE}_$(basename $INPUT_CLIP)"

    AVX512="-x265-params asm=avx512:pmode=1"

    #With grain tuning, PME mode hurts performance by about 1 FPS (circa 2025)
    if [ $TUNE != "grain" ]
    then
        AVX512="${AVX512}:pme=1"
    fi
    
    tune_str=""
    if [ $TUNE != "NoTune" ]
    then
        tune_str="-tune $TUNE"
    fi

    ENCODE_LOG_NAME="${OUTPUT_FOLDER}encode-log_${preset_1}_crf${crf_1}-${TUNE}"

    ffmpeg -y -i $INPUT_CLIP -c:v libx265 $AVX512 -crf $crf_1 -preset $preset_1 $tune_str -c:a copy -c:s copy $OUTPUT_CLIP_1 > $ENCODE_LOG_NAME 2>&1

    OUTPUT_SSIM_LOG_1="${OUTPUT_FOLDER}ssim_log_crf${crf_1}_${preset_1}_${TUNE}_h265_$(basename $INPUT_CLIP).txt"

    echo "ffmpeg -i \"$INPUT_CLIP\" -i \"$OUTPUT_CLIP_1\" -lavfi ssim=stats_file=${OUTPUT_SSIM_LOG_1} -f null -" >> "${OUTPUT_FOLDER}ssim_commands.sh"
}

av1_custom_encode() {
    INPUT_CLIP=$1
    OUTPUT_FOLDER=$2

    preset_1=$3
    crf_1=$4
    grain_1=$5
    av1_tune_1=$6
    OUTPUT_CLIP_1="${OUTPUT_FOLDER}crf${crf_1}_${preset_1}g${grain_1}t${av1_tune_1}_av1_$(basename $INPUT_CLIP)"

    #Note: -tune grain/film/etc has no effect with svt-av1

    #Tune0 tuns for SSIM, 1 would tune for PSNR. Test both.
    av1_params="-svtav1-params tune=${av1_tune_1}"

    #Note: Using the film-grain param is a significant performance limiter, capping to around 40FPS.
    if [ $grain_1 -ne -1 ]
    then
        av1_params="${av1_params}:film-grain=${grain_1}"
    fi

    ENCODE_LOG_NAME="${OUTPUT_FOLDER}encode-log_${preset_1}_crf${crf_1}g${grain_1}t${av1_tune_1}_av1"

    { time ffmpeg -y -i $INPUT_CLIP -c:v libsvtav1 $av1_params -g 120 -crf $crf_1 -preset $preset_1 -c:a copy -c:s copy $OUTPUT_CLIP_1; } > $ENCODE_LOG_NAME 2>&1

    OUTPUT_SSIM_LOG_1="${OUTPUT_FOLDER}ssim_log_crf${crf_1}_${preset_1}_${grain_1}_${av1_tune_1}_av1_$(basename $INPUT_CLIP).txt"

    echo "ffmpeg -i \"$INPUT_CLIP\" -i \"$OUTPUT_CLIP_1\" -lavfi ssim=stats_file=${OUTPUT_SSIM_LOG_1} -f null -" >> "${OUTPUT_FOLDER}ssim_commands.sh"
}

# Function to run all SSIM commands after encoding
run_ssim_commands() {
    OUTPUT_FOLDER=$1
    # Run the SSIM commands in parallel, up to 6 at a time
    if [ -f "${OUTPUT_FOLDER}ssim_commands.sh" ]; then
        cat "${OUTPUT_FOLDER}ssim_commands.sh" | xargs -I {} -P 6 bash -c '{}'
        rm "${OUTPUT_FOLDER}ssim_commands.sh"  # Clean up
    fi
}
powerprofilesctl set performance

#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER medium 19 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER medium 18 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER fast 18 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER fast 19 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER faster 18 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER faster 17 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER veryfast 17 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER veryslow 23 grain
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER slow 21 grain

#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER slow 17 NoTune
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER slow 16 NoTune


#ffmpeg -y -i /path/to/testmovie.mkv -c:v libx265 -x265-params asm=avx512:pmode=1 -crf 21 -preset slow -c:a copy -c:s copy /path/to/test_data/folder/crf21_slow_notune_nopme_testmovie.mkv > /path/to/test_data/folder/encode-log_slow_crf21_notune_nopme 2>&1

#echo "ffmpeg -i /path/to/testmovie.mkv -i /path/to/test_data/folder/crf21_slow_notune_nopme_testmovie.mkv -lavfi ssim=stats_file=/path/to/test_data/folder/ssim_log_crf21_slow_notune_nopme -f null -" >> /path/to/test_data/folder/ssim_commands.sh

#ffmpeg -y -i /path/to/testmovie.mkv -c:v libx265 -x265-params asm=avx512:pmode=1:pme=1 -crf 21 -preset slow -tune grain -c:a copy -c:s copy /path/to/test_data/folder/crf21_slow_grain_pme_testmovie.mkv > /path/to/test_data/folder/encode-log_slow_crf21_grain_pme 2>&1

#echo "ffmpeg -i /path/to/testmovie.mkv -i /path/to/test_data/folder/crf21_slow_grain_pme_testmovie.mkv -lavfi ssim=stats_file=/path/to/test_data/folder/ssim_log_crf21_slow_grain_pme -f null -" >> /path/to/test_data/folder/ssim_commands.sh

#ffmpeg -y -i /path/to/testmovie.mkv -c:v libx265 -x265-params asm=avx512:pmode=1:pme=1 -crf 21 -preset slow -c:a copy -c:s copy /path/to/test_data/folder/crf21_slow_notune_pme_testmovie.mkv > /path/to/test_data/folder/encode-log_slow_crf21_notune_pme 2>&1

#echo "ffmpeg -i /path/to/testmovie.mkv -i /path/to/test_data/folder/crf21_slow_notune_pme_testmovie.mkv -lavfi ssim=stats_file=/path/to/test_data/folder/ssim_log_crf21_slow_notune_pme -f null -" >> /path/to/test_data/folder/ssim_commands.sh

#Runs at aroun 6 FPS.
#h265_custom_encode $INPUT_CLIP $OUTPUT_FOLDER slower 22

#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 -1 1 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 10 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 20 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 30 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 40 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 50 0 grain

#Despite what docs claim, tune 2 is NOT supported in ffmpeg. It should be SSIM tuning.
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 32 -1 2 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 25 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 20 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 6 15 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 5 20 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 4 20 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 3 20 -1 0 grain

#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 5 15 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 5 10 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 5 10 -1 0 NoTune
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 5 5 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 4 15 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 4 10 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 3 15 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 3 10 -1 0 grain

#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 2 15 -1 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 3 15 10 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 3 15 20 0 grain
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 3 15 30 0 grain
#Runs at 5.5 FPS, 10hrs to encode.
#av1_custom_encode $INPUT_CLIP $OUTPUT_FOLDER 1 15 -1 0 grain

run_ssim_commands $OUTPUT_FOLDER

powerprofilesctl set power-saver

^^This bash script is a bit chaotic because it was actively developed as I was testing things. For example, I didn’t realize that -tune grain didn’t actually do anything with AV1 until I’d done all my encoding tests.

If you decide to take this and use it, you’ll probably want to fix me using crf_preset in some spots and preset_crf in others, but if you do that, you’ll need to fix the ssim_ scripts below as well.

When I started developing this I didn’t realize I’d need to fiddle with more than crf and preset. So rather than re-write everything, I just made it so whichever parameter was last, instead of doing _newstting1_newsetting2, which would break some of the log parsing I built in other scripts later on in this post, I would use -. So crf_preset-newsetting1-newsetting2. That made sure the settings kept creating unique filenames and saved me a lot of rewriting.

The point of this script was that I could uncomment 2-3 lines, let them run overnight, and slowly comment out tests as I completed them, while having a record of what I had already tried.

You’ll notice it creates a shell file full of ffmpeg commands to run SSIM. The reason for this is that SSIM is rather slow, but it doesn’t take up 100% of CPU time. So, to save wallclock time, the SSIM commands are run all at once only AFTER all encode tasks are finished. This is done via xargs, to limit the number of parallel jobs to 6. Why? Because 5-6 SSIM tasks takes my 9800x3d to 100%. It’s an efficient load for my CPU.

If you’re still not getting ‘why?’, here’s what wallclock time would look like without it:

  • encode first movie for 3 hours (0-3hr) 100% CPU
  • compute SSIM for movie 1 (30 minutes, 20% CPU usage) (3-3.5hr)
  • encode movie 2 for 3 hours, 100% CPU (3.5-6.5 hr)
  • compute SSIM for movie 2 (30 minutes, 20% CPU usage) (6.5-7hr)
  • encode movie 3 for 3 hours, 100% CPU (7-10 hr)
  • compute SSIM for movie 2 (30 minutes, 20% CPU usage) (10-10.5hr)

So, in total, it takes 10.5 hours to encode 3 movies. I don’t sleep for 10 hours. But if I do all the SSIM work at the end:

  • encode first movie for 3 hours (0-3hr) 100% CPU
  • encode movie 2 for 3 hours, 100% CPU (3-6 hr)
  • encode movie 3 for 3 hours, 100% CPU (6-9 hr)
  • compute SSIM for all movies in parallel (30 minutes, 60% CPU usage) (9-9.5hr)

I just saved myself an hour. If your encode tests are shorter (like when I was doing av1, where an encode takes < 1 hour in most cases) this stack up is even bigger. 6 encodes per night? You just saved 3 hours. It takes 30 minutes to run 1 job or 6, so why not run them all at once, together?

Note: My tests always encoded a full length movie. I found that trying to encode short 10-15 minute clips didn’t give the same results as full movies.

#!/usr/bin/env python3
import re
import sys
from typing import List, Tuple

def parse_ssim_log(ssim_log):
    ssim_values = []
    overall_ssim_sum = 0
    ssim_frame_count = 0
    with open(ssim_log, "r") as f:
        for line in f:
            match = re.match(r"n:(\d+).*All:([0-9\.]+)", line)
            if match:
                frame_num = int(match.group(1))
                ssim_value = float(match.group(2))
                overall_ssim_sum += ssim_value
                ssim_frame_count += 1
                ssim_values.append((frame_num, ssim_value))

    ssim_avg = overall_ssim_sum / ssim_frame_count
    return ssim_values, ssim_avg

def get_lowest_ssim_frames(file_list):
    lowest_ssims = []
    for ssim_log in file_list:
        ssim_values, ssim_avg = parse_ssim_log(ssim_log)
        # Sort by SSIM value (ascending)
        lowest_ssim = sorted(ssim_values, key=lambda x: x[1])[:10]
        lowest_ssims.append((ssim_log, lowest_ssim, ssim_avg))

    #os.remove(ssim_log)  # Cleanup log file after use
    return lowest_ssims

def main():
    # Check if files are provided
    if len(sys.argv) < 2:
        print("Please provide SSIM files to analyze", file=sys.stderr)
        sys.exit(1)

    # Get file paths from command line arguments
    filepaths = sys.argv[1:]

    # Find and print lowest SSIM scores
    lowest_scores = get_lowest_ssim_frames(filepaths)
    
    print("SSIM Scores (lowest first):")
    for ssim_log, ssim_scores, ssim_avg in lowest_scores:
        print(f"{ssim_log} - avg {ssim_avg:.6f}")
        for frame, score in ssim_scores:
            print(f"\tFrame: {frame}, Score: {score:.6f}")

if __name__ == "__main__":
    main()

^^ I called this one ssim_collator.py. You call it: python3 ssim_collator.py /path/to/ssim_log*

It’s job, go through all those SSIM files and:

  1. Give you a global average of the SSIM ‘all:’ field.
  2. Show you the 10 worst frames in the encode by SSIM ‘all’ score.
import glob
import os
import subprocess
from concurrent.futures import ThreadPoolExecutor, as_completed

original_video = "/path/to/testmovie.mkv"
encoded_files_folder = "/path/to/test_data/folder/"
screenshot_folder = os.path.join(encoded_files_folder,"screengrabs")
encoded_files_glob = f"{encoded_files_folder}*.mkv"
MAX_WORKERS = 5

def prep_encoded_for_extraction(encoded_file, frame_number):
    global screenshot_folder

    crf, preset = os.path.basename(encoded_file).split("_")[:2]
    os.makedirs(screenshot_folder, exist_ok=True)

    encoded_frame_fname = f"{frame_number}_{crf}_{preset}.png"
    output_frame = os.path.join(screenshot_folder, encoded_frame_fname)

    return encoded_file, frame_number, output_frame

def prep_original_for_extraction(frame_number):
    global original_video, screenshot_folder

    os.makedirs(screenshot_folder, exist_ok=True)

    original_frame_fname = f"{frame_number}_master.png"
    original_frame = os.path.join(screenshot_folder, original_frame_fname)

    return original_video, frame_number, original_frame

def extract_frame(source_file, frame_number, output_frame):
    if os.path.isfile(output_frame) and os.path.getsize(output_frame) > 0:
        return f"Using cached encode frame for {output_frame}", ""

    cmd = [
        "ffmpeg", "-i", source_file,
        "-vf", f"select=eq(n\\,{frame_number})", "-vsync", "vfr",
        output_frame
    ]
    process = subprocess.run(cmd, capture_output=True, text=True)
    return process.stdout, process.stderr


if __name__ == "__main__":
    files = glob.glob(encoded_files_glob)
    submitted_commands = set()

    frames_to_get = [147300]
#    frames_to_get = [117324,118678, 157360, 147300, 25009]
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
        futures = {}

        for frame_number in frames_to_get:
            for encoded_file in files:
                encoded_file, frame_number, output_frame = prep_encoded_for_extraction(encoded_file, frame_number)
                work_tuple = (encoded_file, frame_number, output_frame)
                if work_tuple not in submitted_commands:
                    submitted_commands.add(work_tuple)
                    futures[executor.submit(extract_frame, *work_tuple)] = output_frame
                    print(f"Submitting task to extract frame {frame_number} from {encoded_file}")

                original_video, frame_number, original_frame = prep_original_for_extraction(frame_number)
                work_tuple = (original_video, frame_number, original_frame)
                if work_tuple not in submitted_commands:
                    submitted_commands.add(work_tuple)
                    futures[executor.submit(extract_frame, *work_tuple)] = original_frame
                    print(f"Submitting task to extract frame {frame_number} from {original_video}")

        for future in as_completed(futures):
            cmd = futures[future]
            try:
                stdout, stderr = future.result()
                print(f"Command {cmd} completed with output:\n{stdout}")
            except Exception as e:
                print(f"Command {cmd} failed with error: {e}")

And, the last script. This is the one that had a bug. This is the fixed version. Why a ThreadPoolExecutor? The ffmpeg command to extract one frame takes forever. anywhere from 2-6 minutes, or longer. I tried speeding it up by using -ss to get to the nearest keyframe, that didn’t work. So, again, you can run a few of them in parallel.

As you do encoding tests, you should see certain frames (147300 in my case, as well as the others listed in the commented out array) that consistently show up in the ‘worst’ SSIM scores. You can run this script to automatically extract screenshots of those hard to encode frames from the master encode, as well as all your encode tests. The screenshot names match the crf_preset-setting1-setting2 naming pattern, so it’s easy to understand which grab came from which test.

You can then use GIMP/Photoshop/something else to layer the images on top and pixel peep them.

…I was today years old when I learned that movies don’t just have text subtitles. I knew some movies had visually styled subtitles. I never questioned how. When you do an ffprobe and you see something like:

Stream #0:25(ita): Subtitle: hdmv_pgs_subtitle (pgssub)
That pgssub? That means bitmap pictures. As in the subtitles are not stored as text, they are stored as images. One of my movies has almost 1GB (724MB) of subtitles! And considering I only want forced subtitles…holy balls. I thought I was only wasting 1-2MB copying subtitles over. It never occurred to me they’d save the subtitles as fucking pictures! AHH!

Since it’s your archive and you’re already transcoding baking the subs you want into the encode should be fine. As long as you’re willing to give up the near lossless subtitle images. With your focus on image quality over file size and speed the difference may go unnoticed… Back to testing :slight_smile: