A video can remain readable while its black and white levels are interpreted incorrectly. Changing a range label alone does not expand limited-range sample values, and applying the expansion twice changes values again. Use this calibration check when a conversion stage is meant to carry a known limited-range chart into a full-range delivery.
Check a video range conversion with known luma codes and frame tags
The handoff problem
A video can remain readable while its black and white levels are interpreted incorrectly. Changing a range label alone does not expand limited-range sample values, and applying the expansion twice changes values again. Use this calibration check when a conversion stage is meant to carry a known limited-range chart into a full-range delivery.
The useful output is an acceptance report tied to the exact reference, delivered file, decoded samples, frame tags and executable. It distinguishes three deliberately produced failure files from the correct conversion. This is a local reproduction of a documented failure class, not evidence that a live customer export or this website has suffered the defect.
Declare what is known before running
The reference is an original numerical chart: three 96×32 frames of planar 8-bit YCbCr 4:2:0 at five frames per second. Six luma stripes use 16, 32, 64, 128, 200 and 235. Both chroma planes are 128. Each successive frame rotates the stripe order by one position, so checking every decoded sample also detects a wrong frame order. All example files are generated locally by the complete code below, without using an external image.
For this declared limited-to-full contract, the independent luma answer is floor((Y - 16) * 255 / 219 + 0.5). The expected first-frame codes are 0, 19, 56, 130, 214 and 255. Chroma must remain 128. The accepted numerical tolerance is at most one luma code and exactly zero chroma codes, applied to every sample, with three full-range frame tags. This is a software rounding tolerance for this neutral chart, not a perceptual or broadcast tolerance.
The reference is not a photograph, an SDR-to-HDR conversion, a transfer-function conversion, a gamut conversion or a display calibration. Natural footage that happens not to contain black or white cannot establish its range from minimum and maximum values. Obtain the producer's intended interpretation before making a range change.
Setup and complete source
Use an existing Python 3.12 and FFmpeg 6.0 with the FFV1 encoder, scale, setparams and showinfo filters on macOS. The bounded run uses macOS 14.5 and Python 3.12.3. Nothing is installed or downloaded by the script. No account or paid API is used.
Save both complete files below together in a new folder. version.json binds range_check.py. A changed script needs a deliberate new lock and new verification. Output parents must exist and every output directory must be new. The script commits a completed directory with macOS exclusive rename and cleans up catchable failures. Forced termination or power loss can leave a temporary sibling; other operating systems are not supported by this commit method.
range_check.py
from pathlib import Path
import argparse, ctypes, errno, hashlib, json, math, os, re, shutil, subprocess, sys, tempfile
VERSION='0.1.0'
WIDTH,HEIGHT,FRAMES,FPS=96,32,3,5
FRAME_BYTES=WIDTH*HEIGHT*3//2
class Invalid(Exception):pass
def need(ok,code):
if not ok:raise Invalid(code)
def digest(data):return hashlib.sha256(data).hexdigest()
def save(path,obj):path.write_text(json.dumps(obj,indent=2,ensure_ascii=False,allow_nan=False)+'\n')
def load(path):
need(path.is_file() and not path.is_symlink(),'INPUT_FILE')
need(path.stat().st_size<=8192,'CONTRACT_SIZE')
return json.loads(path.read_text())
def bytes_at(path,limit):
need(path.is_file() and not path.is_symlink(),'INPUT_FILE')
need(path.stat().st_size<=limit,'INPUT_SIZE')
data=path.read_bytes();need(len(data)<=limit,'INPUT_SIZE');return data
def commit(stage,target):
need(sys.platform=='darwin','MACOS_EXCLUSIVE_COMMIT_REQUIRED')
libc=ctypes.CDLL(None,use_errno=True)
fn=libc.renamex_np;fn.argtypes=[ctypes.c_char_p,ctypes.c_char_p,ctypes.c_uint];fn.restype=ctypes.c_int
if fn(os.fsencode(stage),os.fsencode(target),4)!=0:
e=ctypes.get_errno();raise Invalid('OUTPUT_EXISTS' if e in (errno.EEXIST,errno.ENOTEMPTY) else 'COMMIT_FAILED')
def chart(levels):
data=bytearray()
for frame in range(FRAMES):
row=bytes(levels[(x//16+frame)%6] for x in range(WIDTH))
data.extend(row*HEIGHT);data.extend(bytes([128])*(WIDTH*HEIGHT//2))
return bytes(data)
def reference(folder):
contract=load(folder/'contract.json')
need(type(contract) is dict and set(contract)=={'schema','width','height','frames','fps','pixelFormat','sourceRange','targetRange','yLevels','sha256'},'CONTRACT_KEYS')
need(contract['schema']=='range-chart-v1','CONTRACT_SCHEMA')
for key,val in [('width',WIDTH),('height',HEIGHT),('frames',FRAMES),('fps',FPS)]:
need(type(contract[key]) is int and contract[key]==val,'FIXED_CHART_GEOMETRY')
need((contract['pixelFormat'],contract['sourceRange'],contract['targetRange'])==('yuv420p','limited','full'),'RANGE_CONTRACT')
levels=contract['yLevels']
need(type(levels) is list and len(levels)==6 and all(type(x) is int and 16<=x<=235 for x in levels),'LEVELS')
need(levels==sorted(set(levels)) and levels[0]==16 and levels[-1]==235,'LEVELS_ORDER_AND_ENDPOINTS')
data=bytes_at(folder/'reference.yuv',FRAME_BYTES*FRAMES)
need(len(data)==FRAME_BYTES*FRAMES,'REFERENCE_LENGTH')
need(data==chart(levels),'REFERENCE_PATTERN')
need(type(contract['sha256']) is str and digest(data)==contract['sha256'],'REFERENCE_HASH')
return contract,data
def expected_full(data):
result=bytearray(data)
for f in range(FRAMES):
start=f*FRAME_BYTES
for j in range(WIDTH*HEIGHT):result[start+j]=math.floor((data[start+j]-16)*255/219+0.5)
return bytes(result)
def verdict(actual,ranges,expected,target='pc'):
need(len(actual)==len(expected)==FRAME_BYTES*FRAMES,'DECODED_LENGTH')
y_diffs=[];uv_diffs=[]
for f in range(FRAMES):
start=f*FRAME_BYTES
y_diffs.extend(abs(actual[j]-expected[j]) for j in range(start,start+WIDTH*HEIGHT))
uv_diffs.extend(abs(actual[j]-expected[j]) for j in range(start+WIDTH*HEIGHT,start+FRAME_BYTES))
codes=[]
if max(y_diffs)>1:codes.append('LUMA_RANGE_MAPPING')
if max(uv_diffs)>0:codes.append('NEUTRAL_CHROMA_CHANGED')
if ranges!=[target]*FRAMES:codes.append('RANGE_TAG')
return {'accepted':not codes,'codes':codes,'maximumYCodeError':max(y_diffs),'maximumUvCodeError':max(uv_diffs),
'decodedFrameRangeTags':ranges,'expectedRangeTag':target,'frameCount':FRAMES,
'firstFrameBandY':[actual[j*16+8] for j in range(6)],
'decodedBytesSha256':digest(actual),'numericReferenceSha256':digest(expected),
'toleranceYCodes':1,'toleranceUvCodes':0}
def main():
ap=argparse.ArgumentParser()
ap.add_argument('mode',choices=['make','demo','check'])
ap.add_argument('--out',required=True)
ap.add_argument('--input-dir')
ap.add_argument('--ffmpeg')
ap.add_argument('--video')
args=ap.parse_args()
code=Path(__file__).resolve().parent;lock=load(code/'version.json')
need(lock=={'version':VERSION,'files':{'range_check.py':digest(Path(__file__).read_bytes())}},'CODE_LOCK')
out=Path(args.out).absolute()
need(out.parent.is_dir() and not out.parent.is_symlink(),'OUTPUT_PARENT')
need(not os.path.lexists(out),'OUTPUT_EXISTS')
need(sys.platform=='darwin','MACOS_EXCLUSIVE_COMMIT_REQUIRED')
stage=Path(tempfile.mkdtemp(prefix='.'+out.name+'-',dir=out.parent));events=[];inputs=[]
try:
if args.mode=='make':
need(not args.video and not args.input_dir,'MAKE_ARGUMENTS')
levels=[16,32,64,128,200,235];data=chart(levels)
(stage/'reference.yuv').write_bytes(data)
save(stage/'contract.json',{'schema':'range-chart-v1','width':WIDTH,'height':HEIGHT,'frames':FRAMES,'fps':FPS,
'pixelFormat':'yuv420p','sourceRange':'limited','targetRange':'full','yLevels':levels,'sha256':digest(data)})
commit(stage,out);print(json.dumps({'out':str(out),'constructedFrames':FRAMES}));return 0
need(args.input_dir and args.ffmpeg,'REQUIRED_ARGUMENTS')
need((args.mode=='check')==bool(args.video),'VIDEO_ARGUMENT')
folder=Path(args.input_dir).absolute();contract,data=reference(folder)
for name in ['reference.yuv','contract.json']:
p=folder/name;inputs.append({'path':str(p),'sha256':digest(p.read_bytes())})
(stage/'reference.yuv').write_bytes(data);save(stage/'contract.json',contract)
f=Path(args.ffmpeg).resolve();need(f.is_file() and os.access(f,os.X_OK),'FFMPEG_EXECUTABLE')
def run(arguments,name):
argv=[str(f),*arguments];r=subprocess.run(argv,capture_output=True,timeout=30)
stderr=r.stderr.decode(errors='replace')
events.append({'name':name,'argv':argv,'returnCode':r.returncode,'stderr':stderr,
'stdoutBytes':len(r.stdout),'stdoutSha256':digest(r.stdout)})
need(r.returncode==0,'TOOL_FAILURE:'+name)
return r.stdout,stderr
version,_=run(['-version'],'version');version=version.decode().splitlines()[0]
need(re.match(r'^ffmpeg version 6\.0(?:\s|[-.])',version) is not None,'FFMPEG_6_0_REQUIRED')
common=['-hide_banner','-nostdin','-nostats','-threads','1','-filter_threads','1']
def encode(source,destination,filter_text,tag,raw=False):
before=['-f','rawvideo','-pixel_format','yuv420p','-video_size',f'{WIDTH}x{HEIGHT}','-framerate',str(FPS)] if raw else []
run(common+before+['-i',str(source),'-map','0:v:0','-an','-frames:v',str(FRAMES),'-vf',filter_text,
'-pix_fmt','yuv420p','-c:v','ffv1','-level','3','-threads','1','-color_range',tag,'-n',str(destination)],destination.stem+'-encode')
def decode(video):
# Only tiny FFV1/yuv420p calibration deliveries are eligible; no arbitrary-film decoder claim.
_,header=run(common+['-i',str(video),'-map','0:v:0','-c:v','copy','-frames:v','0','-f','null','-'],video.stem+'-header')
matches=re.findall(r'Video: ffv1[^\n]*',header)
need(bool(matches) and all(re.search(r'\byuv420p(?:[,(\s]|$)',line) and re.search(r'\b96x32\b',line) for line in matches),'VIDEO_FORMAT_OR_GEOMETRY')
raw,log=run(common+['-i',str(video),'-map','0:v:0','-an','-frames:v',str(FRAMES+1),'-vf','showinfo',
'-pix_fmt','yuv420p','-c:v','rawvideo','-threads','1','-f','rawvideo','-'],video.stem+'-decode')
geometry=re.findall(r'\bn:\s*\d+[^\n]*?\bs:(\d+x\d+)',log)
need(geometry==[f'{WIDTH}x{HEIGHT}']*FRAMES,'FRAME_GEOMETRY_OR_COUNT')
ranges=re.findall(r'color_range:(\S+)',log)
need(len(raw)==FRAME_BYTES*FRAMES,'DECODED_LENGTH')
return raw,ranges
expected=expected_full(data);results={}
if args.mode=='demo':
limited=stage/'source-limited.mkv';good=stage/'converted-full.mkv'
encode(stage/'reference.yuv',limited,'setparams=range=limited','tv',True)
scale='scale=in_range=limited:out_range=full,format=yuv420p,setparams=range=full'
encode(limited,good,scale,'pc')
encode(limited,stage/'tag-only.mkv','setparams=range=full','pc')
encode(good,stage/'double-conversion.mkv',scale,'pc')
encode(good,stage/'wrong-tag.mkv','setparams=range=limited','tv')
for name in ['source-limited','converted-full','tag-only','double-conversion','wrong-tag']:
raw,tags=decode(stage/(name+'.mkv'))
results[name]=verdict(raw,tags,data if name=='source-limited' else expected,'tv' if name=='source-limited' else 'pc')
results[name]['encodedFileSha256']=digest((stage/(name+'.mkv')).read_bytes())
# PGM shows the actual decoded luma codes, not a colour-managed scene rendering.
(stage/(name+'-luma.pgm')).write_bytes(f'P5\n{WIDTH} {HEIGHT}\n255\n'.encode()+raw[:WIDTH*HEIGHT])
need(results['source-limited']['accepted'] and results['source-limited']['maximumYCodeError']==0,'SOURCE_ENCODING_CHANGED')
need(results['converted-full']['accepted'],'GOOD_CONVERSION_FAILED')
need(results['tag-only']['codes']==['LUMA_RANGE_MAPPING'],'TAG_ONLY_NEGATIVE_FAILED')
need(results['double-conversion']['codes']==['LUMA_RANGE_MAPPING'],'DOUBLE_NEGATIVE_FAILED')
need(results['wrong-tag']['codes']==['RANGE_TAG'] and results['wrong-tag']['maximumYCodeError']<=1,'WRONG_TAG_NEGATIVE_FAILED')
accepted=True
else:
p=Path(args.video).absolute();b=bytes_at(p,8000000)
inputs.append({'path':str(p),'sha256':digest(b)})
video=stage/'tested.mkv';video.write_bytes(b)
raw,tags=decode(video);results['delivery']=verdict(raw,tags,expected);accepted=results['delivery']['accepted']
results['delivery']['encodedFileSha256']=digest(b)
for item in inputs:need(digest(Path(item['path']).read_bytes())==item['sha256'],'INPUT_CHANGED')
save(stage/'report.json',{'version':VERSION,'mode':args.mode,'accepted':accepted,'results':results,
'inputs':inputs,'inputsUnchangedAtFinalHashCheck':True,'codeLock':lock,'python':sys.version,
'ffmpegVersion':version,'ffmpegSha256':digest(f.read_bytes()),'childCommandCount':len(events),
'scope':'Known neutral 8-bit YCbCr 4:2:0 chart only; range mapping and decoded frame tags, no HDR/gamut/display certification.',
'expectedFirstFrameY':[math.floor((x-16)*255/219+0.5) for x in contract['yLevels']],
'referenceAuthority':'Explicit generated chart contract, not inferred from natural-image min/max.'})
save(stage/'commands.json',events);save(stage/'version.json',lock)
commit(stage,out)
print(json.dumps({'out':str(out),'accepted':accepted,'checks':results}))
return 0 if accepted else 2
finally:
if stage.exists():shutil.rmtree(stage)
if __name__=='__main__':
try:sys.exit(main())
except (Invalid,ValueError,OSError,subprocess.TimeoutExpired) as e:
print(str(e),file=sys.stderr);sys.exit(2)
version.json
{
"version": "0.1.0",
"files": {
"range_check.py": "d68ec3d4b3a180b9f6d90a0139c1d0ca3da3f8eeb17eff9d6a74197354eabde8"
}
}
Construct the chart and reproduce the three faults
Run from the source folder. Replace the FFmpeg placeholder with your existing executable's absolute path. On a machine with limited memory, run one calibration job at a time.
python3 range_check.py make --out "my-chart"
python3 range_check.py demo --input-dir "my-chart" --ffmpeg "/absolute/path/to/ffmpeg" --out "my-demo"
The first command writes reference.yuv and contract.json. The second encodes a limited-range source into lossless FFV1/Matroska, makes four deliveries and decodes all five files. It verifies the limited source against the original bytes before accepting the demonstration. The source is kept unchanged; each transformation writes a new file.
| File | Actual operation | Required verdict |
|---|---|---|
| source-limited.mkv | Encode the reference with a limited-range declaration | Original luma codes and limited frame tags |
| converted-full.mkv | scale=in_range=limited:out_range=full,format=yuv420p,setparams=range=full | Full-range numeric mapping and full frame tags |
| tag-only.mkv | Set full-range metadata while retaining limited sample values | Reject with LUMA_RANGE_MAPPING |
| double-conversion.mkv | Reinterpret the converted full values as limited and expand again | Reject with LUMA_RANGE_MAPPING |
| wrong-tag.mkv | Retain converted values but declare limited range | Reject with RANGE_TAG |
A negative counts only when the same validator returns the stated defect code on a successfully decoded file. A missing file, codec failure, timeout or unexpected exception does not count as a successful negative. Inspect report.json and commands.json; do not infer success from the filenames. The PGM files show the first frame's actual decoded luma codes as grayscale swatches. They are numerical viewing aids, not colour-managed pictures of the source scene.
Check a calibration delivery from another stage
Pass the known chart through the stage you want to inspect, keeping this exact frame geometry and ordering. For this implementation, the returned video must be small FFV1/yuv420p with three 96×32 frames and no more than 8,000,000 bytes. This narrow contract makes byte-level comparison meaningful. A lossy MP4, changed size, added frames, coloured chart or arbitrary real movie needs a separately designed acceptance contract; do not widen the tolerance until it passes.
python3 range_check.py check --input-dir "my-chart" --video "my-demo/converted-full.mkv" --ffmpeg "/absolute/path/to/ffmpeg" --out "delivery-report"
The check command copies the selected delivery into its new report directory, records its original hash, verifies its codec and dimensions before decoding, then compares all three frames with the independently calculated full-range answer. It also checks the decoded frame range labels. It is a checker: it does not repair or relabel the supplied delivery. A successful audit returns exit code 0. A completed audit with a mapping/tag defect writes its rejection report and returns 2. Invalid input, code-lock failure or a tool failure also returns 2 but does not commit a completed report; read the explicit message and report rather than treating every nonzero exit as the same outcome.
To reproduce a real rejection, use a fresh report path and change only --video to my-demo/tag-only.mkv, double-conversion.mkv or wrong-tag.mkv. The first two may carry the expected full-range label while their luma values are wrong; the last may have correct values while its label is wrong. A metadata-only inspection misses one side of this contract.
Read the evidence and decide what to change
Keep report.json, commands.json, contract.json, reference.yuv and version.json with the delivery. The report contains source hashes, encoded and decoded hashes, every frame's range tag, maximum Y/UV errors, the first-frame band samples, Python and FFmpeg versions and the executable hash. The logged commands use the temporary paths that actually executed; the completed directory is moved into the requested destination only after its checks finish.
For a tag-only failure, inspect whether the pipeline only changed metadata. For a double-conversion failure, trace where expansion already occurred and remove the unintended second interpretation. For wrong-tag, confirm the intended range at that handoff before setting a label. These are diagnoses supported by the deliberately constructed cases, not automatic diagnoses of an unknown image.
This check adds a known-signal range-conversion acceptance record. Existing frame extraction selects timestamps and pixels, letterboxing checks geometry, image-sequence assembly checks order, and a grayscale window maps an explicitly chosen data interval for preview. This chart instead tests whether numeric range conversion and the decoded delivery labels agree. No new browser control, upload capability, platform codec approval or scene-quality judgment is implied.
Technical basis and limits
The FFmpeg 6.0 filter documentation distinguishes setparams metadata from scale's input/output YCbCr range conversion, and documents 8-bit luma endpoints of 16–235 and 0–255. The formula, chart, checker and acceptance artifact here are original implementation choices built around that declared contract.
Actual evidence is limited to the emitted files and recorded executable. The integer answer is independent of FFmpeg's scaler, but the video decode uses FFmpeg itself; no second decoder, player display, camera calibration, chroma-range transform, HDR pipeline, LUT or GPU implementation has been certified. The range tag checked is the decoded frame property, not an independent audit of every container and bitstream metadata field. Use owned or authorized inputs and obtain a fresh receipt for every new delivery. No practice download is offered.
Prepare your input
The check command copies the selected delivery into its new report directory, records its original hash, verifies its codec and dimensions before decoding, then compares all three frames with the independently calculated full-range answer. It also checks the decoded frame range labels. It is a checker: it does not repair or relabel the supplied delivery. A successful audit returns exit code 0. A completed audit with a mapping/tag defect writes its rejection report and returns 2. Invalid input, code-lock failure or a tool failure also returns 2 but does not commit a completed report; read the explicit message and report rather than treating every nonzero exit as the same outcome.
The reference is an original numerical chart: three 96×32 frames of planar 8-bit YCbCr 4:2:0 at five frames per second. Six luma stripes use 16, 32, 64, 128, 200 and 235. Both chroma planes are 128. Each successive frame rotates the stripe order by one position, so checking every decoded sample also detects a wrong frame order. All example files are generated locally by the complete code below, without using an external image.
Pass the known chart through the stage you want to inspect, keeping this exact frame geometry and ordering. For this implementation, the returned video must be small FFV1/yuv420p with three 96×32 frames and no more than 8,000,000 bytes. This narrow contract makes byte-level comparison meaningful. A lossy MP4, changed size, added frames, coloured chart or arbitrary real movie needs a separately designed acceptance contract; do not widen the tolerance until it passes.
The first command writes reference.yuv and contract.json. The second encodes a limited-range source into lossless FFV1/Matroska, makes four deliveries and decodes all five files. It verifies the limited source against the original bytes before accepting the demonstration. The source is kept unchanged; each transformation writes a new file.
- Optional: Pass the known chart through the stage you want to inspect, keeping this exact frame geometry and ordering. For this implementation, the returned video must be small FFV1/yuv420p with three 96×32 frames and no more than 8,000,000 bytes. This narrow contract makes byte-level comparison meaningful. A lossy MP4, changed size, added frames, coloured chart or arbitrary real movie needs a separately designed acceptance contract; do not widen the tolerance until it passes.