Fuel Price API for Fleet Cost Planning

Fleet fuel costs are the second-largest operating expense after labor. Having state-level fuel price data in the same API as your truck routes, bridge clearances, and corridor restrictions means you can estimate total route costs in one integration.

Road511 now serves weekly US state-level and monthly Canadian province-level fuel prices from government sources.

The Sources

US — EIA (Energy Information Administration)

  • Weekly retail prices for all 50 states
  • Regular, midgrade, premium gasoline + diesel
  • USD per gallon
  • Updated every Monday

Canada — Statistics Canada

  • Monthly retail prices for 12 provinces/territories
  • Regular, premium gasoline + diesel
  • CAD cents per litre
  • Updated monthly

Both are official government data, free, and unrestricted.

Get Latest Prices

curl "https://api.road511.com/api/v1/fuel-prices?country=US&fuel_type=diesel&latest=true" 
  -H "X-API-Key: your_key"
{
  "data": [
    {
      "jurisdiction": "CA",
      "country": "US",
      "fuel_type": "diesel",
      "price": 4.892,
      "currency": "USD",
      "unit": "gallon",
      "period_date": "2026-04-07",
      "frequency": "weekly",
      "source": "eia"
    },
    {
      "jurisdiction": "TX",
      "country": "US",
      "fuel_type": "diesel",
      "price": 3.241,
      "currency": "USD",
      "unit": "gallon",
      "period_date": "2026-04-07",
      "frequency": "weekly",
      "source": "eia"
    }
  ],
  "total": 50,
  "has_more": false
}

Historical Trends

Track how prices change over time:

curl "https://api.road511.com/api/v1/fuel-prices?jurisdiction=CA&fuel_type=diesel&from=2025-01-01&to=2026-04-10" 
  -H "X-API-Key: your_key"

Returns every weekly data point for California diesel over the past 15 months.

Cross-Border Queries

Get both US and Canadian prices in one response:

curl "https://api.road511.com/api/v1/fuel-prices?fuel_type=diesel&latest=true&limit=100" 
  -H "X-API-Key: your_key"

Note: US prices are in USD/gallon, Canadian in CAD cents/litre. Convert as needed for your application.

Combine with Corridor Data

The real value is combining fuel prices with Road511’s other data:

  1. Plan the route — check bridge clearances and weight restrictions via /truck/corridor
  2. Estimate fuel cost — get diesel prices for each state the route crosses via /fuel-prices
  3. Check conditions — verify road conditions and active incidents via /events

All from one API, one key, one integration.

Use Cases

  • Fleet fuel budgeting — forecast costs based on state-level price trends
  • Route cost optimization — factor fuel price differences into route selection
  • Rate calculations — logistics platforms adjusting freight rates by fuel cost
  • Analytics — correlate fuel prices with traffic patterns, delivery frequency

Try It

Linux File System Explained Simply

Most beginners get confused in Linux not because of commands — but because they don’t understand the file system.

Once you understand how Linux organizes files, navigating any server becomes much easier and more predictable.

Unlike Windows, Linux uses a single tree structure starting from the root directory (/).

In this guide, you’ll learn the most important Linux directories and what they are used for.

Important Linux Concept

In Linux, almost everything is treated like a file:

  • Regular files
  • Directories
  • Hard disks
  • USB devices
  • Processes

This is one of the core ideas behind Linux design.

Overview of Linux File System

Everything in Linux starts from / (root).

/ is called the root directory.

It is the top-level starting point of the Linux filesystem.

All other directories are sub-directories of root.

Understanding Paths in Linux

A path tells Linux where a file or directory is located.

Absolute Path

Starts from the root directory /.

Example:

cd /home/user/documents

-> Full location from the root.

Relative Path

Starts from your current directory.

Example:

cd documents

-> Relative to where you are currently located.

Current Directory Concepts

Linux uses special symbols for navigation:

.   → Current directory
..  → Parent directory

Examples:

cd .
cd ..

-> cd .. moves one directory back.

Understanding /home

/home is the personal workspace area for normal users.

Examples:

/home/john
/home/admin

Each user usually has their own home directory to store:

  • Documents
  • Downloads
  • Personal files
  • Configuration files

Important Linux Directories

Directory Purpose Common Usage
/ Root directory (starting point) Everything is inside this
/home User personal files Documents, Downloads, Desktop
/etc Configuration files System & application settings
/var Variable data (logs, caches, queues) Logs, web server data
/tmp Temporary files Deleted on reboot
/bin Essential binary commands ls, cp, mv, cat
/usr User installed programs & libraries Most installed software
/root Home directory of root (admin) user Administrator files
/boot Boot loader and kernel files Files needed to start the system
/dev Device files Hard disks, USB, terminals
/proc Process and system information Live system data (virtual)
/sys Kernel & hardware information System hardware details
/opt Optional software Third-party applications
/mnt / /media Mount points for external drives USB drives, additional disks

Detailed Explanation of Most Used Directories

1. /home
User’s personal space.
Every normal user has a folder here.

2. /etc
Contains almost all configuration files.
This is one of the most critical directories in Linux systems.

  • /etc/passwd — user accounts
  • /etc/ssh/sshd_config — SSH settings
  • /etc/nginx/ — web server config

3. /var
Stores data that changes frequently.
Most production issues can be traced by checking logs in /var/log.

  • /var/log/ — system and application logs
  • /var/www/ — Default web files (for Apache/Nginx)

4. /tmp
Used for temporary files.
Usually cleared automatically after reboot.

5. /usr
Main location for installed user programs and libraries.

Examples:

  • /usr/bin/ — most user commands

Most system commands come from here.

6. /boot
Contains kernel and files required to boot the system.
Corruption here can prevent the system from booting.

7. /dev
Represents hardware devices as files.

Examples:

  • /dev/sda → hard disk
  • /dev/tty → terminals

Everything in Linux is treated as a file, including hardware.

8. /proc

Virtual filesystem that shows live process and kernel information.

Used for viewing system and process details.

9. /root

/root is the home directory of the root (administrator) user.

-> It is different from / (root directory).

10. /mnt and /media

These directories are commonly used when attaching external storage devices.

Examples:

USB drives
Additional disks

What is Mounting?

Linux attaches storage devices to directories using a process called mounting.

Examples:

/ → Main filesystem
/home → User files
/var → Logs and application data

Useful Commands to Explore the Filesystem

pwd
ls /
cd /var/log
tree

  • pwd → Show current location
  • ls/ → List root directories
  • cd → Navigate directories
  • tree → Visual directory structure

Simple Mental Model

Think of Linux filesystem like this:

  • / = The whole computer
  • /home = My personal room
  • /etc = Settings room
  • /var = Logs & changing data
  • /tmp = Temporary workspace
  • /usr = Installed applications
  • /boot = Engine to start the system

This model helps you quickly understand Linux structure without memorizing paths.

⚠️ Common Beginner Mistakes

  • Storing important files in /tmp
  • Editing files in /etc without backup
  • Deleting unknown folders thinking they are useless
  • Running rm -rf / (Never do this!)

Summary

  1. In this guide you learned the most important Linux directories:
  • /home → User files
  • /etc → Configuration files
  • /var → Logs and variable data
  • /tmp → Temporary files
  • /usr → Installed software
  • /boot → Boot files
  • /dev → Hardware devices

2. In this guide you learned:

  • Linux filesystem structure
  • Root directory (/)
  • Absolute vs relative paths
  • Current directory concepts (. and ..)
  • Important Linux directories
  • Mounting basics
  • Useful filesystem navigation commands

Understanding these directories is essential for navigating, troubleshooting, and managing Linux systems in real-world DevOps environments.

Next Post:
Linux File Permissions Explained Simply (chmod, chown, chgrp)

Building a shot-detection worker for an upload pipeline with PySceneDetect 0.7

📦 Code: github.com/USER/shot-detection-worker (replace before publishing)

TL;DR

We are going to build an upload worker that runs shot detection on a video, writes the boundary list to Postgres, and produces a 6-frame storyboard PNG for use as a hover-preview sprite. Stack is PySceneDetect 0.7 (released earlier this month), FFmpeg 8.1.1, Python 3.12, and a Redis-backed queue. About 120 lines of code.

Shot detection is one of those building blocks that quietly powers a lot of features people associate with “video AI”: chapter generation, smart thumbnails, hover-preview sprites, the first step of any auto-clipping pipeline. The open-source path covers more ground than people expect, and PySceneDetect 0.7 (which dropped on 2026-05-03) is the version I would build on today.

Let’s wire it up end to end.

🛠️ 1. Setup

# bash
mkdir shot-worker && cd shot-worker
python3.12 -m venv .venv && source .venv/bin/activate
pip install scenedetect==0.7 opencv-python-headless==4.10.0.84 
            rq==1.16 psycopg[binary]==3.2.0 boto3==1.34.0

We also need FFmpeg on the host:

# bash
$ ffmpeg -version | head -1
ffmpeg version 8.1.1 ...

A throwaway DB for the boundaries table:

-- schema.sql
CREATE TABLE shots (
    asset_id   TEXT NOT NULL,
    shot_index INT  NOT NULL,
    start_s    NUMERIC(10, 3) NOT NULL,
    end_s      NUMERIC(10, 3) NOT NULL,
    metric     NUMERIC(10, 3),
    PRIMARY KEY (asset_id, shot_index)
);

CREATE INDEX shots_asset_idx ON shots (asset_id);
# bash
psql -d shotworker -f schema.sql

🎬 2. The detector picker

PySceneDetect 0.7 ships five detectors, and the right one depends on the content. A small router up front saves a lot of pain later.

# worker/detectors.py
from scenedetect import AdaptiveDetector, ContentDetector, ThresholdDetector
from scenedetect.detectors import HistogramDetector, HashDetector

def pick_detector(content_class: str):
    """Pick the PySceneDetect detector that suits the content class.

    content_class comes from the upload metadata: 'talking_head', 'sports',
    'animation', 'screen_recording', etc. Tune thresholds against your own
    samples; the defaults here are starting points, not ground truth.
    """
    match content_class:
        case "sports" | "action":
            # Rolling-average baseline handles fast camera motion better.
            return AdaptiveDetector(adaptive_threshold=3.0, min_scene_len=15)
        case "animation":
            # Histogram delta works better than HSV deltas on animated content.
            return HistogramDetector(threshold=0.05, min_scene_len=15)
        case "screen_recording":
            # Perceptual hashing skips long static stretches.
            return HashDetector(threshold=0.395, min_scene_len=30)
        case "fade_heavy":
            # Threshold-based detection catches fade-to-black act breaks.
            return ThresholdDetector(threshold=12.0, min_scene_len=15)
        case _:
            # Default: HSV content delta. Works on most diverse content.
            return ContentDetector(threshold=27.0, min_scene_len=15)

💡 Tip: keep a small set of labeled test clips (one per content class) and re-run the detector on them whenever you touch thresholds. The “did my change regress” question gets cheaper to answer fast.

🔍 3. The detection function

PySceneDetect’s high-level API is small enough that the whole detection step is a dozen lines:

# worker/detect.py
from pathlib import Path

from scenedetect import open_video, SceneManager
from worker.detectors import pick_detector


def detect_shots(video_path: Path, content_class: str = "default"):
    """Run shot detection on a video file.

    Returns a list of (start_seconds, end_seconds, metric_score) tuples.
    """
    video = open_video(str(video_path))
    scene_manager = SceneManager()
    detector = pick_detector(content_class)
    scene_manager.add_detector(detector)

    scene_manager.detect_scenes(video=video, show_progress=False)

    shots = []
    for i, (start, end) in enumerate(scene_manager.get_scene_list()):
        shots.append((
            start.get_seconds(),
            end.get_seconds(),
            None,  # metric per scene not exposed directly in 0.7
        ))
    return shots

You can also grab the per-frame metric data by adding a StatsManager and writing it to CSV, which is invaluable when threshold tuning:

# worker/detect.py (extended version with stats)
from scenedetect import StatsManager

def detect_shots_with_stats(video_path, content_class, stats_csv):
    video = open_video(str(video_path))
    stats = StatsManager()
    scene_manager = SceneManager(stats_manager=stats)
    scene_manager.add_detector(pick_detector(content_class))
    scene_manager.detect_scenes(video=video, show_progress=False)
    stats.save_to_csv(stats_csv)
    return [(s.get_seconds(), e.get_seconds(), None)
            for s, e in scene_manager.get_scene_list()]

🖼️ 4. The storyboard

For a hover-preview sprite, we want one frame per shot. FFmpeg does the heavy lifting; PySceneDetect tells it where to look:

# worker/storyboard.py
import subprocess
from pathlib import Path


def extract_keyframes(video_path: Path, shots, out_dir: Path, max_frames: int = 6):
    """Extract one frame per shot, capped at max_frames total."""
    out_dir.mkdir(parents=True, exist_ok=True)

    # If there are more shots than slots, sample evenly.
    if len(shots) > max_frames:
        step = len(shots) / max_frames
        sampled = [shots[int(i * step)] for i in range(max_frames)]
    else:
        sampled = shots

    paths = []
    for i, (start_s, end_s, _) in enumerate(sampled):
        midpoint = start_s + (end_s - start_s) / 2
        out_path = out_dir / f"frame_{i:02d}.jpg"
        subprocess.run(
            [
                "ffmpeg", "-ss", f"{midpoint:.3f}", "-i", str(video_path),
                "-frames:v", "1", "-q:v", "3", "-vf", "scale=320:-2",
                "-y", str(out_path),
            ],
            check=True,
            stderr=subprocess.DEVNULL,
        )
        paths.append(out_path)

    # Stitch into a 1x6 sprite.
    sprite_path = out_dir / "sprite.png"
    subprocess.run(
        ["ffmpeg", "-i", str(out_dir / "frame_%02d.jpg"),
         "-vf", f"tile={max_frames}x1", "-y", str(sprite_path)],
        check=True,
        stderr=subprocess.DEVNULL,
    )
    return sprite_path

-ss before -i is intentional: it lets FFmpeg seek before decoding, which makes the extract cheap on long videos. Putting -ss after -i reads from the start of the file every time, which is fine on a 30-second clip and miserable on a 90-minute one.

🔌 5. The worker

We tie everything together with an RQ job. Whichever queue you use, the shape is the same: pull asset, detect, write boundaries, produce storyboard, mark asset ready.

# worker/job.py
import json
import logging
from pathlib import Path
import tempfile

import boto3
import psycopg

from worker.detect import detect_shots_with_stats
from worker.storyboard import extract_keyframes

logger = logging.getLogger(__name__)
s3 = boto3.client("s3")


def process_upload(asset_id: str, bucket: str, key: str,
                   content_class: str, dsn: str):
    """End-to-end shot detection + storyboard for a single uploaded asset."""
    with tempfile.TemporaryDirectory() as tmp:
        local = Path(tmp) / "input.mp4"
        s3.download_file(bucket, key, str(local))
        logger.info("downloaded %s/%s -> %s", bucket, key, local)

        stats_csv = Path(tmp) / "stats.csv"
        shots = detect_shots_with_stats(local, content_class, stats_csv)
        logger.info("detected %d shots in %s", len(shots), asset_id)

        # Persist boundaries
        with psycopg.connect(dsn) as conn:
            with conn.cursor() as cur:
                cur.execute(
                    "DELETE FROM shots WHERE asset_id = %s", (asset_id,),
                )
                cur.executemany(
                    "INSERT INTO shots (asset_id, shot_index, start_s, end_s, metric) "
                    "VALUES (%s, %s, %s, %s, %s)",
                    [(asset_id, i, s, e, m) for i, (s, e, m) in enumerate(shots)],
                )

        # Storyboard
        out_dir = Path(tmp) / "storyboard"
        sprite = extract_keyframes(local, shots, out_dir, max_frames=6)
        s3.upload_file(str(sprite), bucket, f"storyboards/{asset_id}.png")
        s3.upload_file(str(stats_csv), bucket, f"shot-stats/{asset_id}.csv")

        return {
            "asset_id": asset_id,
            "shots": len(shots),
            "sprite_key": f"storyboards/{asset_id}.png",
        }

Hooking it up to RQ:

# worker/run.py
from rq import Queue, Worker
from redis import Redis
import os

if __name__ == "__main__":
    redis = Redis.from_url(os.environ["REDIS_URL"])
    q = Queue("uploads", connection=redis)
    with Worker([q], connection=redis) as w:
        w.work()

Enqueue an upload job from your upload handler:

# in your upload handler
from rq import Queue
from redis import Redis

q = Queue("uploads", connection=Redis.from_url(REDIS_URL))
q.enqueue(
    "worker.job.process_upload",
    asset_id="upload_abc123",
    bucket="my-uploads",
    key="raw/abc123.mp4",
    content_class="talking_head",
    dsn=os.environ["DB_DSN"],
    job_timeout=600,
)

⚡ 6. Throughput and gotchas

A few notes from real deployments:

  • PySceneDetect is CPU-bound and decode-heavy. The detector itself is fast; the bottleneck is OpenCV decoding the video. On commodity CPU (4 vCPU, 8 GB RAM), 1080p talking-head content processes faster than real time. Mileage varies on dense 4K content.
  • Use opencv-python-headless on servers. The full opencv-python package pulls in GTK/Qt and breaks in containerized environments.
  • Sample frames if you do not need pixel-precise boundaries. PySceneDetect has a downscale_factor argument that subsamples the input. For chapter-generation use cases, a 2x downscale halves processing time and changes the boundary list by a frame or two at most.
  • The CSV stats file is gold. Save it. The day a content class regresses and the PM asks “why did chapters drop”, that file is the answer.
# faster detection at the cost of frame-level precision
video = open_video(str(video_path), backend="opencv")
scene_manager = SceneManager()
scene_manager.auto_downscale = True
scene_manager.detect_scenes(video=video, frame_skip=2)

⚠️ Note: frame_skip improves throughput but means the detector misses very short shots (a 4-frame quick cut at frame_skip=2 may not register). Tune to your content.

🧪 7. Verifying the output

A small script that opens the storyboard and prints the boundary list:

# bash
$ python -m worker.cli verify upload_abc123
asset upload_abc123
  shots: 14
  total: 73.21s
  storyboard: s3://my-uploads/storyboards/upload_abc123.png
  sample boundaries:
    [00:00:00.000 - 00:00:04.120]  (shot 0)
    [00:00:04.120 - 00:00:09.480]  (shot 1)
    [00:00:09.480 - 00:00:13.000]  (shot 2)
    ...

Open the sprite. One frame per shot, six total, evenly distributed.

What’s next

A few directions to take this once the baseline works:

  • Chapter generation. Group shots that are at least 30 seconds long; the rest is a rules engine on top of the boundary list.
  • Smart thumbnails. Pipe the keyframes through a sharpness + face score and pick the best per shot.
  • Auto-clipping. Detect “interesting” moments by some other signal (audio energy, transcript keywords) and snap them to the nearest shot boundary; the clips stop looking like clips and start looking like edits.

The library does the boring part well, so you get to spend the engineering budget on the parts that actually feel like product.

video #python #tutorial #ffmpeg

Wiring VMAF (and PSNR) into your encoder CI with FFmpeg 8.1 and ffmpeg-quality-metrics

📦 Code: github.com/USER/encoder-qa-ci (replace before publishing)

TL;DR

We are going to wire a perceptual-quality gate into a CI workflow using FFmpeg 8.1.1, libvmaf, and the ffmpeg-quality-metrics Python wrapper. The job runs PSNR, SSIM, and VMAF against a fixed reference ladder and fails the merge if VMAF drops below threshold. Works on CPU; the same setup runs roughly 6x faster on GPU with VMAF-CUDA.

PSNR has been the default “is my encoder okay” metric in CI pipelines for a decade, and it is starting to show its age. It cannot tell when the encoder traded perceptual quality for raw pixel error, and that is exactly the failure mode per-title and ML-driven encoders walk into. Let’s wire up something better.

We will build this in three steps:

  1. Stand up a tiny test bench with a reference ladder.
  2. Run PSNR + SSIM + VMAF with ffmpeg-quality-metrics.
  3. Wrap the whole thing in a CI script that fails on regression.

🛠️ 1. The test bench

You need three things: a reference master, a set of encoded renditions (“distorted” in metric-speak), and a fixed VMAF model file.

# bash
mkdir encoder-qa && cd encoder-qa
mkdir reference distorted models

# A short, representative reference. Pick a clip that matches your worst-case content:
# face-heavy, motion, fine detail. The "TearsOfSteel" or "BigBuckBunny" clips work for demos.
curl -L -o reference/master.mov 
  https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_5MB.mp4

Drop the VMAF model into models/. The vmaf_v0.6.1.json model is the most widely cited Netflix VMAF model; there are also vmaf_4k_v0.6.1.json for 4K viewing and a phone variant. Pin the one you use, exactly:

# bash (clone the model into the repo so CI doesn't fetch over the network)
git clone --depth 1 https://github.com/Netflix/vmaf.git /tmp/vmaf
cp /tmp/vmaf/model/vmaf_v0.6.1.json models/

💡 Tip: treat the model file like a lockfile. Different VMAF model versions produce different scores. If you upgrade, you re-baseline every threshold.

Now generate a reference ladder. We use FFmpeg 8.1.1; older versions are fine but you want at least 6.1 for VMAF-CUDA, and 8.x for the cleanest webvtt/libsvtav1 integration.

# bash (produce three renditions for the test ladder)
ffmpeg -i reference/master.mov -c:v libx264 -preset medium -crf 23 -vf scale=1280:720  distorted/720p.mp4
ffmpeg -i reference/master.mov -c:v libx264 -preset medium -crf 26 -vf scale=854:480   distorted/480p.mp4
ffmpeg -i reference/master.mov -c:v libx264 -preset medium -crf 28 -vf scale=640:360   distorted/360p.mp4

Verify the FFmpeg version. The libvmaf and libsvtav1 wiring tightened up across the 8.0 → 8.1 line:

# bash
$ ffmpeg -version | head -1
ffmpeg version 8.1.1 ...
$ ffmpeg -filters | grep -E "libvmaf|psnr|ssim"
 .. libvmaf            VV->V       Calculate the VMAF between two video streams.
 .. psnr               VV->V       Calculate the PSNR between two video streams.
 .. ssim               VV->V       Calculate the SSIM between two video streams.

If libvmaf is not listed, your FFmpeg was built without --enable-libvmaf. Most distro builds ship it; static builds from BtbN/FFmpeg-Builds include it by default.

📐 2. Running the metrics

You can call ffmpeg -lavfi libvmaf... directly, but the output format is awkward to parse, and you end up writing the same Python wrapper everyone else has. Use ffmpeg-quality-metrics (slhck/ffmpeg-quality-metrics, still actively maintained). It runs all three metrics in one pass, emits JSON, and handles the model-path plumbing for you.

# bash
pip install ffmpeg-quality-metrics

A single rendition through the gate:

# bash
ffmpeg-quality-metrics distorted/720p.mp4 reference/master.mov 
  --metrics psnr ssim vmaf 
  --vmaf-model-path models/vmaf_v0.6.1.json 
  --output-format json > metrics_720p.json

The output looks like this (truncated):

{
  "global": {
    "psnr": { "psnr_avg": 41.82, "psnr_min": 36.41, "psnr_max": 45.97 },
    "ssim": { "ssim_avg": 0.978 },
    "vmaf": { "vmaf_avg": 88.7, "vmaf_min": 71.2, "vmaf_max": 96.4 }
  },
  "input_file_dist": "distorted/720p.mp4",
  "input_file_ref": "reference/master.mov"
}

global is the aggregate. The per-frame data is also in the JSON if you ask for --output-format json with the frame-level flag, and that is the file you want when an encoder regresses and you need to find which 200 frames lost 8 points.

⚠️ Note: VMAF on CPU is workable on short clips but slow on long ones. If you have NVENC-capable hardware, add --vmaf-features cuda (the wrapper passes it through to libvmaf) and decode through h264_cuvid / hevc_cuvid. The roughly 6x speedup NVIDIA documents lines up with what I see in practice when I keep frames on the GPU end-to-end.

🚦 3. Turning it into a CI gate

The script: run the metrics on every rendition, compare the aggregate against a per-rendition threshold, exit non-zero on regression.

# scripts/qa_gate.py
import json
import subprocess
import sys
from pathlib import Path

# Per-rendition VMAF floor. Tune to your content.
# Tighter on top renditions, looser on the bottom rung.
THRESHOLDS = {
    "720p": {"vmaf_avg": 85, "vmaf_min": 65, "ssim_avg": 0.96},
    "480p": {"vmaf_avg": 78, "vmaf_min": 55, "ssim_avg": 0.94},
    "360p": {"vmaf_avg": 70, "vmaf_min": 45, "ssim_avg": 0.92},
}

REFERENCE = "reference/master.mov"
MODEL = "models/vmaf_v0.6.1.json"

def run_metrics(distorted: Path) -> dict:
    out = subprocess.check_output([
        "ffmpeg-quality-metrics", str(distorted), REFERENCE,
        "--metrics", "psnr", "ssim", "vmaf",
        "--vmaf-model-path", MODEL,
        "--output-format", "json",
    ])
    return json.loads(out)["global"]

def main() -> int:
    failures = []
    for name, floor in THRESHOLDS.items():
        rendition = Path(f"distorted/{name}.mp4")
        if not rendition.exists():
            failures.append(f"{name}: rendition missing")
            continue
        metrics = run_metrics(rendition)
        for metric, minimum in floor.items():
            value = metrics.get(metric.split("_")[0], {}).get(metric)
            if value is None or value < minimum:
                failures.append(f"{name} {metric}: got {value}, want >= {minimum}")
        print(f"{name}: vmaf {metrics['vmaf']['vmaf_avg']:.1f}, "
              f"ssim {metrics['ssim']['ssim_avg']:.3f}, "
              f"psnr {metrics['psnr']['psnr_avg']:.1f}")
    if failures:
        print("nFAILURES:")
        for f in failures:
            print(f"  - {f}")
        return 1
    return 0

if __name__ == "__main__":
    sys.exit(main())

Run it locally first:

# bash
$ python scripts/qa_gate.py
720p: vmaf 88.7, ssim 0.978, psnr 41.8
480p: vmaf 81.2, ssim 0.961, psnr 38.9
360p: vmaf 72.4, ssim 0.943, psnr 36.1

The output you actually want is when somebody breaks the encoder:

# bash
$ python scripts/qa_gate.py
720p: vmaf 79.1, ssim 0.962, psnr 42.4
480p: vmaf 81.2, ssim 0.961, psnr 38.9
360p: vmaf 72.4, ssim 0.943, psnr 36.1

FAILURES:
  - 720p vmaf_avg: got 79.1, want >= 85
exit code: 1

Look at what PSNR did here: it actually went up (42.4 vs the previous 41.8). The encoder traded perceptual quality for raw pixel error and a PSNR-only gate would have shipped it. The VMAF gate caught it.

🤖 4. The GitHub Actions job

# .github/workflows/encoder-qa.yml
name: encoder-qa
on:
  pull_request:
    paths:
      - "encoder/**"
      - "scripts/qa_gate.py"

jobs:
  qa:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4

      - name: Install FFmpeg 8.1.1
        run: |
          curl -L -o ffmpeg.tar.xz 
            https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-master-latest-linux64-gpl.tar.xz
          tar xf ffmpeg.tar.xz --strip-components=2 -C /usr/local/bin --wildcards '*/bin/ffmpeg' '*/bin/ffprobe'
          ffmpeg -version | head -1

      - name: Install metrics wrapper
        run: pip install ffmpeg-quality-metrics

      - name: Build the test ladder
        run: ./scripts/build_ladder.sh

      - name: Run quality gate
        run: python scripts/qa_gate.py

A few notes on this in production:

  • Cache the reference master in an S3 bucket or a Git LFS object. Re-fetching from a public CDN on every run is a recipe for flaky builds.
  • Treat the model file as a build input. Bump it deliberately, and re-baseline thresholds when you do.
  • Keep the per-frame JSON as a build artifact for at least 30 days. When an encoder regresses, the headline number tells you it broke; the per-frame data tells you where.

⚠️ Things that bite you in real workflows

A short list of things I have had to fix on actual CI pipelines:

  • Different frame counts between reference and distorted. Trim with ffmpeg -ss/-to on both sides, or VMAF will silently truncate to the shorter one and the score will surprise you.
  • Color space mismatches. A BT.709 source compared against a BT.601 encode will produce noisy VMAF scores. Normalize with zscale=transfer=bt709:matrix=bt709.
  • Resolution mismatches. libvmaf scales the distorted to match the reference; if you really want to test “how this looks on a 720p screen”, scale the reference down to 720p first, then compare.
  • CPU vs CUDA scores drift slightly. Same model, same files, different paths through the math. Pick one for CI and stick to it.

What’s next

A few directions worth exploring:

  • Per-title encoding QA. The same gate, but a thresholds-per-content-class table. Talking heads, sports, animation, screen content; each gets its own floor.
  • Frame-level alerting. Plot the per-frame VMAF in your CI artifact viewer. A two-second dip below 60 is often more telling than the global average.
  • GPU-resident pipelines. If you encode with NVENC and want sub-realtime QA on long content, run h264_cuvid decoders into a libvmaf filter graph that stays on the GPU. The 6x speedup is real; the plumbing is fiddly enough to be its own post.

Wire it up, get the gate green, and the first time a PR turns it red you will be glad you stopped trusting PSNR as the whole story.

video #ffmpeg #tutorial #devops

Bikin Chatbot Sendiri yang Bisa Jawab Pertanyaan dari Dokumen kamu

demo project

Di tulisan ini kita akan membahas soal sebuah proyek kecil bernama Simple RAG Chatbot. Tujuannya sederhana: bikin chatbot yang bisa menjawab pertanyaan berdasarkan dokumen yang kamu punya — bukan ngarang dari pengetahuan model. Cocok buat kamu yang baru pertama kali denger istilah RAG dan pengen tahu cara kerjanya tanpa pusing.

Repository proyek aplikasi : https://github.com/ardisaurus/simple-rag-chatbot

demo project

Masalah yang Ingin Diselesaikan

Coba bayangin kamu punya banyak banget dokumentasi produk: file Markdown, catatan teknis, panduan instalasi, FAQ. Setiap kali ada orang baru gabung, mereka selalu nanya hal yang sebenernya udah ada di dokumen — tapi siapa juga yang sempat baca semuanya?

Solusi instan yang mungkin terlintas: “kasih aja ke ChatGPT terus suruh jawab.” Tapi ada dua masalah:

  1. Model AI seperti ChatGPT tidak tahu isi dokumen pribadimu.
  2. Kalau kamu paste seluruh dokumen ke chat, biayanya mahal dan sering kepotong limit token.

Di sinilah teknik bernama RAG masuk.

Apa Itu RAG?

RAG kepanjangannya Retrieval-Augmented Generation. Kedengeran ribet, padahal idenya simpel banget:

  1. Retrieval (pengambilan): cari potongan dokumen yang paling relevan dengan pertanyaan user.
  2. Augmented (diperkaya): tempelin potongan itu ke prompt yang dikirim ke LLM (model bahasa besar).
  3. Generation (pembuatan jawaban): LLM jawab berdasarkan potongan yang barusan dia terima.

Jadi LLM-nya gak perlu hapal seluruh dokumenmu. Dia cuma perlu baca beberapa paragraf yang relevan untuk setiap pertanyaan. Hemat token, hemat biaya, dan jawabannya jadi lebih akurat karena ada bukti sumbernya.

Cara Komputer Cari Potongan yang Relevan: Embeddings

Pertanyaannya: gimana komputer tau potongan dokumen mana yang relevan sama pertanyaan user?

Jawabannya pakai konsep embeddings. Embedding itu cara mengubah teks menjadi sebuah deretan angka (vektor) yang merepresentasikan makna teks tersebut. Dua kalimat yang artinya mirip akan menghasilkan vektor yang mirip juga, walaupun kata-katanya berbeda.

Contoh:

  • “How do I install the product?” → [0.12, -0.34, 0.88, ...]
  • “Cara memasang aplikasinya gimana?” → [0.11, -0.33, 0.86, ...]

Walaupun bahasanya beda, vektornya berdekatan karena maknanya mirip.

Lalu kita simpan semua vektor dokumen di sebuah vector database. Saat user nanya, kita ubah pertanyaannya jadi vektor, terus cari vektor dokumen yang paling dekat. Itulah potongan yang paling relevan.

Tools yang Dipakai di Proyek Ini

Aku sengaja pilih tools yang gratis atau super murah supaya kamu bisa self-hosting di laptop sendiri:

  • Next.js (App Router) — framework React untuk bikin web app.
  • TypeScript — biar kode lebih aman dan mudah di-maintain.
  • LangChain.js — library yang mempermudah semua langkah RAG: split dokumen, bikin embedding, retrieval, sampai panggil LLM.
  • ChromaDB — vector database yang bisa jalan lokal pakai Docker.
  • OpenRouter — gateway buat berbagai LLM (GPT, Claude, Llama, dll). Bayar per token, dan model default-nya cuma beberapa rupiah per pertanyaan.
  • @xenova/transformers — library untuk bikin embedding langsung di laptopmu, tanpa API. Jadi gak perlu langganan OpenAI cuma buat embedding.
  • Tailwind CSS — supaya tampilannya rapi tanpa nulis CSS panjang.

Format Dokumen yang Didukung & Batasannya

Sebelum kita masuk ke alur teknisnya, penting kamu tahu dulu dokumen apa aja yang bisa diproses sama proyek ini, dan apa aja batasan dari implementasi sekarang. Jangan sampai kamu masukin file PDF terus heran kenapa chatbot-nya jawab “saya tidak tahu” — ternyata file-nya emang gak pernah ke-index.

Format yang Didukung Saat Ini

Hanya tiga ekstensi yang dibaca oleh script ingest:

  • .md — Markdown standar.
  • .markdown — sama aja, beda ekstensi.
  • .txt — teks polos.

Daftar ini didefinisikan di lib/ingest.ts sebagai SUPPORTED_EXTENSIONS. File dengan ekstensi lain akan di-skip diam-diam (tanpa warning) saat script jalan menjelajahi folder. Subfolder ikut dibaca rekursif, jadi kamu bebas mengelompokkan dokumen ke dalam beberapa folder.

Format yang Belum Didukung

Berikut yang belum bisa langsung kamu masukin tanpa modifikasi kode:

  • PDF — perlu library tambahan kayak pdf-parse. Cara nambahinnya udah dijelasin di file data/docs/wiki/08-extending.md.
  • DOCX / Office documents — perlu parser tersendiri (misal mammoth untuk DOCX).
  • HTML — bisa diproses, tapi butuh parser untuk strip tag HTML supaya yang ke-embed cuma teksnya.
  • MDX — walaupun mirip Markdown, ekstensinya .mdx jadi gak akan ke-pickup. Kalau kamu yakin gak ada JSX di dalamnya, gampang aja: tambahin .mdx ke SUPPORTED_EXTENSIONS.
  • Gambar / scan / screenshot — proyek ini gak punya OCR. Kalau dokumenmu berbentuk gambar, perlu pipeline tambahan (misal Tesseract atau API OCR) untuk ngubah jadi teks dulu.
  • CSV / spreadsheet / JSON terstruktur — bisa di-index sebagai teks polos, tapi hasilnya kurang optimal karena splitter-nya gak paham struktur tabel.

Cara Kerja Aplikasinya, Step by Step

1. Ingestion: Masukin Dokumen ke Database

Pertama, kita siapin semua dokumen di folder data/docs/. Bisa Markdown atau TXT. Lalu jalanin:

npm run ingest

Yang terjadi di balik layar:

  1. Script jalanin walk di folder data/docs/, ngambil semua file yang didukung.
  2. Setiap file dipotong-potong jadi chunk kecil (sekitar 1000 karakter, dengan overlap 150 karakter biar konteksnya gak putus).
  3. Setiap chunk diubah jadi vektor pakai model embedding lokal.
  4. Vektor + chunk + metadata (nama file, nomor chunk) disimpan ke Chroma.

Sekarang Chroma punya “ingatan” tentang dokumenmu, siap dipakai untuk pencarian.

2. Pertanyaan dari User

User buka http://localhost:3000, ketik pertanyaan, terus pencet enter. Pertanyaan itu dikirim ke /api/chat.

3. Retrieval: Cari Chunk yang Relevan

Backend ngambil pertanyaan, ubah jadi vektor pakai model embedding yang sama, lalu tanya ke Chroma: “kasih aku 5 chunk yang vektornya paling dekat dengan ini.”

Chroma balikin 5 chunk beserta metadata-nya.

4. Generation: Tanya ke LLM

5 chunk tadi dirakit jadi sebuah prompt seperti ini (disederhanakan):

Kamu adalah asisten dokumentasi. Jawab HANYA berdasarkan konteks
berikut. Kalau jawabannya tidak ada di konteks, bilang kamu tidak tahu.

Konteks:
[1] (instalasi.md #0) Untuk memasang aplikasi, unduh installer ...
[2] (instalasi.md #1) Setelah terpasang, jalankan dari menu ...
[3] ...

Pertanyaan: Bagaimana cara memasang aplikasinya?

Prompt itu dikirim ke OpenRouter, dan model jawab berdasarkan konteks yang udah dikasih. Karena ada instruksi tegas “jawab hanya dari konteks”, model gak akan ngarang.

5. Streaming: Jawaban Muncul Sedikit-Sedikit

Daripada nunggu jawaban full baru ditampilkan, jawabannya distreaming token per token ke browser. User langsung lihat huruf demi huruf mengalir, mirip pengalaman ChatGPT. Lebih enak dilihat dan kerasa cepat.

6. Sumber Ditampilkan

Di bawah setiap jawaban ada tombol “Sources” yang bisa diklik. Isinya daftar chunk yang dipakai untuk menjawab, lengkap dengan nama file dan nomor chunk-nya. Jadi user bisa verifikasi jawaban si bot.

Kenapa Pilihan Teknisnya Begini?

Kenapa embedding-nya lokal, bukan pakai OpenAI?

Karena bisa gratis. Model Xenova/all-MiniLM-L6-v2 cukup pintar buat dokumentasi pendek, ukurannya kecil (~30 MB), dan jalan langsung di Node.js tanpa internet. Cocok buat self-hosting.

Kenapa LLM-nya pakai OpenRouter?

OpenRouter ngasih akses ke banyak model dengan satu API key. Kamu bisa ganti dari gpt-4o-mini ke claude-3.5-sonnet ke llama-3.1 cuma dengan ubah satu env var. Bagus buat eksperimen.

Kenapa Chroma, bukan Pinecone atau Weaviate?

Chroma gratis, jalan lokal, dan setup-nya cuma satu perintah Docker. Untuk skala kecil-menengah (puluhan ribu chunk) udah lebih dari cukup.

Kenapa wipe-and-replace saat ingest, bukan update incremental?

Biar simpel. Kalau ada file yang dihapus atau di-rename, gak akan ada chunk orphan yang tertinggal. Untuk dokumentasi yang gak terlalu besar, re-ingest cepat banget — hitungan detik.

Cara Coba di Laptopmu

Singkat aja, langkah-langkahnya:

# 1. Install dependency
npm install

# 2. Setup env (isi OPENROUTER_API_KEY dari https://openrouter.ai/keys)
cp .env.example .env

# 3. Jalanin Chroma di Docker
npm run chroma

# 4. Index dokumentasi sample yang udah disediain
npm run ingest

# 5. Jalanin aplikasinya
npm run dev

Buka http://localhost:3000, tanya apa aja soal dokumentasi yang udah di-index. Misalnya: “What is the default chunk size?”

Batasan Lain yang Perlu Dipahami

Selain soal format file, ada beberapa batasan implementasi yang penting kamu tahu:

  • Encoding harus UTF-8. Kalau file .txt kamu disimpan dalam encoding lain (misal Windows-1252), karakter non-ASCII (è, ñ, é, dll) bisa berantakan saat dibaca.
  • Splitter berbasis karakter, bukan struktur. Splitter default-nya RecursiveCharacterTextSplitter, yang motong teks berdasarkan jumlah karakter. Artinya tabel Markdown atau code block panjang bisa kepotong di tengah. Untuk Markdown yang lebih rapi splitnya, kamu bisa ganti ke MarkdownTextSplitter yang lebih sadar struktur heading.
  • Embedding model default-nya English-leaning. Xenova/all-MiniLM-L6-v2 dilatih dominan pada teks bahasa Inggris. Untuk dokumentasi Bahasa Indonesia, hasilnya masih lumayan tapi gak seoptimal model multilingual. Kalau kualitas pencarian dirasa kurang, ganti ke model multilingual seperti Xenova/multilingual-e5-small (jangan lupa re-ingest karena dimensi vektornya beda).
  • Ingest itu wipe-and-replace, bukan incremental. Setiap kali npm run ingest dijalankan, seluruh collection di Chroma dihapus dan dibangun ulang dari nol. Konsekuensinya: gak bisa update satu file aja. Tapi enaknya: gak akan ada chunk basi tertinggal.
  • Gak ada akses kontrol per-dokumen. Semua orang yang bisa akses chatbot akan dapet jawaban dari semua dokumen yang ke-index. Kalau ada dokumen sensitif, pisahin di collection berbeda atau jangan di-index sama sekali.
  • Chunk besar bisa boros token. Default CHUNK_SIZE = 1000 karakter × RETRIEVAL_K = 5 chunk = ~5000 karakter konteks per pertanyaan. Kalau modelmu mahal atau context window-nya kecil, turunin salah satu angka itu.

Intinya: proyek ini sengaja dibuat simpel dulu. Format yang lebih kompleks dan fitur incremental update memang sengaja gak dimasukin supaya kode-nya tetep gampang dibaca. Kalau butuh, semuanya bisa kamu tambahin sendiri dengan modifikasi kecil di lib/ingest.ts.

Penutup

RAG itu konsep yang awalnya kelihatan ribet, padahal intinya cuma: cari potongan yang relevan, tempelin ke prompt, biarkan LLM jawab berdasarkan itu.

Proyek ini sengaja dibuat minimal dan mudah dibaca. Total kode inti cuma di folder lib/ dan app/ — tiap file punya tanggung jawab yang jelas. Kalau kamu pengen belajar gimana sistem RAG bekerja dari nol, baca aja file-filenya satu per satu, dimulai dari lib/ingest.ts, lalu lib/rag.ts.

Selamat ngoprek, dan semoga proyek kecil ini bisa jadi titik awal kamu bikin asisten dokumentasi sendiri!

Repository proyek aplikasi : https://github.com/ardisaurus/simple-rag-chatbot

Learning Arabic: Where to Start

Cover image Calligraphy by Tugram.art

Learning Arabic: Where to Start

A Lebanese native Arabic speaker who learned French, English, and Spanish shares the clearest, calmest path into Arabic , from the root system and tashkīl to dialects, listening habits, and the one mindset shift that changes everything.

Learning Arabic often feels intimidating. New letters, unfamiliar sounds, multiple dialects, and a writing system that looks nothing like Latin scripts can quickly overwhelm even motivated learners. I’ve met many people , especially heritage learners , who feel they should know Arabic, yet don’t know where to begin or how to reconnect with it.

I’m a Lebanese native Arabic speaker who learned French (in school), English, and Spanish later in life. Spanish, in particular, became my fourth language at the age of 20. What I learned through that journey, and through years of observing how people struggle with Arabic, is that languages don’t become “hard” by nature. They become hard when we start from the wrong angle.

This article is for heritage learners, Arabs born or raised outside Arabic-speaking countries, and curious learners who want a clear, calm, and structured way to start Arabic , without panic.

First: Don’t Panic

This may sound trivial, but it matters more than any textbook.

Many learners approach Arabic with stress: “I’ll never understand this.” “There are too many dialects.” “I don’t even know how to read.”

Ironically, millions of people already read Arabic without understanding it. A clear example is Turkish Muslims who learn to read the Qur’an fluently, pronounce every letter correctly, yet don’t understand the meaning at first. Understanding comes later.

💡
Key principle: Separate reading, pronunciation, and understanding. They don’t need to arrive at the same time. This one mindset shift removes most of the pressure that kills motivation in the first few weeks.

Start With the Structure: The Root System

In my opinion, the most elegant entry point into Arabic is its word structure. Arabic is built around roots , often three-letter roots , that generate an entire ecosystem of meaning. Once you understand this, Arabic stops feeling chaotic and starts feeling logical.

Root Word Meaning
K-T-B (writing) كَتَبَ kataba he wrote
كِتَاب kitāb book
مَكْتَب maktab desk / office
مَكْتَبَة maktaba library
D-R-S (studying) دَرَسَ darasa he studied
دَرْس dars lesson
مَدْرَسَة madrasa school
مُدَرِّس mudarris teacher
Instead of memorising thousands of isolated words, you’re learning families of meaning. This approach appeals especially to analytical learners , the “nerds” , because it turns Arabic into a system rather than a mystery.

Tashkīl (Vowel Marks) Are Not Optional for Beginners

One of the biggest mistakes beginners make is skipping tashkīl , the short vowel marks written above and below letters.

Native speakers rely on context. Beginners don’t have that luxury yet. If you don’t know whether a word is pronounced kataba, kutiba, or kutub, you’re guessing , and guessing slows progress. Starting with fully vocalized text removes ambiguity and builds correct pronunciation from day one. For a full breakdown of how they work, see our guide on Arabic tashkīl and harakat.

🔑
Rule of thumb:
Use tashkīl at the beginning, then drop it progressively as your context-reading skills improve. It is a scaffold, not a crutch.

Reading Before Understanding

If I had to give one counter-intuitive piece of advice, it would be this: start by reading, even if you don’t understand.

Read out loud. Train your mouth. Train your eyes. Get comfortable with the script. Understanding will follow. This is not unique to Arabic. I experienced something similar with French and Spanish , sound familiarity came before full comprehension. Your brain needs time to map symbols to sounds before it can attach meaning efficiently.

Dialects vs MSA: A False Conflict

Learners are often told they must choose: Modern Standard Arabic (MSA) or a dialect. In reality, Arabs constantly move between them.

Register Where it’s used
MSA News, formal writing, education, cross-dialect communication
Dialects Daily conversations, social media, family life
Here’s something important that non-native learners often don’t realise: when Arabs from different regions speak together, we instinctively adjust our speech toward MSA. We simplify dialectal words, choose shared vocabulary, and “meet in the middle.” This means learning MSA is never wasted , even for speaking.

Why I Often Recommend Levantine Arabic

I may be biased, but Levantine Arabic (Lebanese, Syrian, Palestinian, Jordanian) sits relatively close to MSA in pronunciation. Most letters are clearly pronounced.

Word MSA Levantine Some Maghrebi
“red” أحمر aḥmar aḥmar ḥmar (also means “donkey”)
This doesn’t make other dialects wrong. But for learners who care about pronunciation clarity, Levantine can serve as a gentle bridge between spoken Arabic and MSA.

What Lyrics, Movies, and Games Taught Me About Languages

I didn’t learn my other languages from textbooks alone. Here’s what actually moved the needle:

Spanish (started at 20)
What truly unlocked Spanish for me was writing full song lyrics while listening , not filling blanks, writing everything. This forces intense listening and exposes weak spots immediately.

French
School gave me basic French, but also a strong Lebanese accent and very little up-to-date vocabulary. What changed everything was fully transcribing French movies, starting with animation (clearer diction, intentional speech). Then came sound replication , not just speaking, but imitating native rhythm and intonation.

English
English came through gaming and movies. Mostly passive at first. Active speaking arrived later at university with international friends , and yes, it felt awkward at the beginning. Even now, professional vocabulary continues to grow with use. That’s normal.

🎯
Apply this to Arabic: Writing down song lyrics = excellent listening training. Adding tashkīl to movie transcripts = powerful intermediate exercise. Reading aloud daily = the fastest route to pronunciation mastery.

Writing Arabic Without Fear

As a native speaker, I still double-check my Arabic writing when I want to avoid dialectal spelling and stick to correct MSA , especially when messaging family across regions.

For that, I use an Arabic grammar and spelling corrector like Sahihli, particularly when I want to make sure my writing stays clean and neutral rather than dialect-heavy. Tools like this are most useful after you’ve started writing , not before.

Practical Next Steps

  • Learn the alphabet and sounds properly
  • Read fully vocalized text daily, even without understanding
  • Study root patterns, not isolated words
  • Choose MSA + one spoken dialect and don’t overthink the choice
  • Use listening and transcription exercises regularly
  • Be patient with speaking , it always lags behind listening, in every language

Conclusion

Arabic rewards consistency more than intensity. You don’t need talent, a perfect accent, or a strict plan. You need calm exposure, respect for the structure of the language, and trust in the process.

I’ve learned four languages, all differently, and Arabic , despite being my native tongue , has shown me something universal: languages open slowly, then all at once.

Start gently. Stay curious. And let Arabic meet you halfway.

Shipping WebVTT subtitles in HLS that actually stay in sync (a hands-on guide for 2026)

📦 Code: github.com/USER/hls-webvtt-pipeline (replace before publishing)

TL;DR

We are going to build a real HLS subtitle pipeline. Take an SRT file, convert to WebVTT, segment it the way HLS expects, add the X-TIMESTAMP-MAP cue, declare it in the manifest, and verify it plays in sync in HLS.js 1.6.16 and Shaka Player. By the end you will have a working ladder + subtitle track and the confidence that it will not drift on seek.

If you have shipped HLS captions as a single sidecar .vtt and called it done, this is the upgrade you have been putting off. The bug it fixes (caption drift after seek, especially on Chrome on Android) is the kind of bug that gets reported as “the subtitles are wrong”, with no useful repro steps, by users who never come back. Let’s build the version that does not break.

🛠️ 1. The pieces we need

  • FFmpeg 8.1.1 (webvtt muxer; older versions work but the muxer behavior is friendlier in 8.x).
  • A packager. We will use shaka-packager because it has the cleanest support for segmented WebVTT and fMP4-wrapped subtitles. mp4box works too.
  • HLS.js 1.6.16 (latest as of April 2026) and Shaka Player 4.x for verification.
  • A short test video (1 to 2 minutes) and an SRT file with at least a dozen cues.
# bash
mkdir hls-webvtt && cd hls-webvtt
mkdir source segments manifest

# A short test video
curl -L -o source/master.mp4 
  https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/720/Big_Buck_Bunny_720_10s_5MB.mp4

# A test SRT
cat > source/captions.srt <<'SRT'
1
00:00:00,000 --> 00:00:03,500
Hello, and welcome to the show.

2
00:00:03,500 --> 00:00:07,000
Today we are talking about HLS captions.

3
00:00:07,000 --> 00:00:10,000
And why most pipelines get them wrong.
SRT

📝 2. SRT to WebVTT (the easy part)

FFmpeg’s webvtt muxer converts SRT for you. The conversion itself is mostly a header swap, but it normalizes line breaks and timestamps along the way:

# bash
ffmpeg -i source/captions.srt -c:s webvtt source/captions.vtt
cat source/captions.vtt
WEBVTT

1
00:00:00.000 --> 00:00:03.500
Hello, and welcome to the show.

2
00:00:03.500 --> 00:00:07.000
Today we are talking about HLS captions.

3
00:00:07.000 --> 00:00:10.000
And why most pipelines get them wrong.

This file works as a sidecar in HLS. It is also the source of every cross-browser sync bug you are about to hit. We need to segment it and add the timestamp map.

✂️ 3. Segmenting WebVTT for HLS

HLS expects WebVTT split into segments that align with the media segments. With six-second video segments, the subtitles should be on six-second segments too. The packager handles this.

First, build the video ladder. Two renditions is enough for the demo:

# bash
ffmpeg -i source/master.mp4 -c:v libx264 -preset medium -crf 23 
  -vf scale=1280:720 -g 144 -keyint_min 144 -sc_threshold 0 
  -hls_time 6 -hls_playlist_type vod 
  -hls_segment_filename "segments/720p_%03d.ts" 
  manifest/720p.m3u8

ffmpeg -i source/master.mp4 -c:v libx264 -preset medium -crf 26 
  -vf scale=854:480 -g 144 -keyint_min 144 -sc_threshold 0 
  -hls_time 6 -hls_playlist_type vod 
  -hls_segment_filename "segments/480p_%03d.ts" 
  manifest/480p.m3u8

Now segment the subtitles. Shaka Packager does this with a single descriptor:

# bash
docker run --rm -v "$PWD:/work" -w /work 
  google/shaka-packager 
  in=source/captions.vtt,stream=text,language=en,segment_template=segments/subs/en_$Number$.vtt,playlist_name=manifest/subs/en.m3u8,hls_group_id=subs,hls_name=English 
  --hls_master_playlist_output manifest/master.m3u8 
  --segment_duration 6

The result is a directory of per-six-second WebVTT files plus a media playlist (manifest/subs/en.m3u8) that references them.

Open one of the segment files:

# segments/subs/en_2.vtt
WEBVTT
X-TIMESTAMP-MAP=MPEGTS:540000,LOCAL:00:00:00.000

00:00:00.000 --> 00:00:01.000
And why most pipelines get them wrong.

That X-TIMESTAMP-MAP line is the whole point. The cue inside the segment is on a local clock starting at zero. The packager has set MPEGTS:540000 to tell the player “this clock-zero corresponds to MPEG-2 PTS 540000”, which is 6 seconds at the 90kHz tick rate, which is where the segment starts.

💡 Tip: open every segment file and confirm the X-TIMESTAMP-MAP is present and correct. Some packagers only emit it on the first segment. The HLS spec is generous about this; player implementations are not.

🧷 4. Wiring the manifest

Shaka Packager wrote a master playlist for us. Let’s look at it:

# manifest/master.m3u8
#EXTM3U
#EXT-X-VERSION:6

#EXT-X-MEDIA:TYPE=SUBTITLES,GROUP-ID="subs",NAME="English",LANGUAGE="en",AUTOSELECT=YES,DEFAULT=NO,FORCED=NO,URI="subs/en.m3u8"

#EXT-X-STREAM-INF:BANDWIDTH=3200000,RESOLUTION=1280x720,SUBTITLES="subs",CODECS="avc1.640028"
720p.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1600000,RESOLUTION=854x480,SUBTITLES="subs",CODECS="avc1.4d401f"
480p.m3u8

A few things worth pointing out:

  • SUBTITLES="subs" on every EXT-X-STREAM-INF line. Forget this and the renditions never advertise the subs.
  • AUTOSELECT=YES lets the player pick this track when the OS language matches. Set to NO if you want subs strictly opt-in.
  • DEFAULT=NO keeps subs off by default. Most product teams want this; set to YES only for forced subtitles (translated dialogue, on-screen text translation).

🌐 5. Serving and verifying

Serve the whole tree with any static server:

# bash
cd manifest && python3 -m http.server 8080

A minimal HTML harness using HLS.js 1.6.16:

<!-- index.html -->
<!doctype html>
<html>
  <body>
    <video id="v" controls autoplay style="width: 100%; max-width: 720px;"></video>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/hls.min.js"></script>
    <script>
      const video = document.getElementById('v');
      if (Hls.isSupported()) {
        const hls = new Hls({ enableWebVTT: true });
        hls.loadSource('http://localhost:8080/master.m3u8');
        hls.attachMedia(video);
        hls.on(Hls.Events.SUBTITLE_TRACKS_UPDATED, (_, d) =>
          console.log('subtitle tracks:', d.subtitleTracks),
        );
      } else if (video.canPlayType('application/vnd.apple.mpegurl')) {
        // Native HLS path (Safari, iOS)
        video.src = 'http://localhost:8080/master.m3u8';
      }
    </script>
  </body>
</html>

Open the page, hit the cc button in the player UI, and confirm the cue text shows up exactly when the actor speaks. Now hit seek to the middle of the timeline. The cue should jump to whatever line was being spoken at that point, with no drift.

⚠️ Note: HLS.js’s light build (hls.light.min.js) does not include WebVTT parsing. Use the standard build for subtitle work.

Repeat with Shaka Player to make sure both engines agree:

<script src="https://cdn.jsdelivr.net/npm/shaka-player@4/dist/shaka-player.compiled.min.js"></script>
<script>
  shaka.polyfill.installAll();
  const player = new shaka.Player(document.getElementById('v'));
  player.load('http://localhost:8080/master.m3u8');
  player.setTextTrackVisibility(true);
</script>

If you see drift in one player but not the other, it is almost always an X-TIMESTAMP-MAP problem in your packager output. Open the failing segment, check the MPEGTS value, recompute it (segment_start_seconds * 90000), and re-segment.

🚨 6. The bugs you will probably hit

A quick checklist of what trips teams up:

Symptom Likely cause
Captions perfect at start, drift after seek X-TIMESTAMP-MAP missing or wrong on later segments
Captions show up but lag by a few seconds Subtitle segments and media segments have different durations
Captions work on Safari, blank on Chrome Using HLS.js light build (no WebVTT parser)
Captions appear but lose positioning Converter flattened align, line, position cue settings
Captions show on 720p stream but not 480p Missing SUBTITLES="subs" on one EXT-X-STREAM-INF line
Low-latency live captions arrive late or never LL-HLS part loading bug (fixed in HLS.js 1.6.x; upgrade)

🧪 7. Going further: fMP4-wrapped subtitles

For CMAF-aligned pipelines and modern players, you can package the same WebVTT inside fMP4 segments instead of standalone .vtt files. The shaka-packager invocation becomes:

# bash (fMP4-wrapped subtitles)
docker run --rm -v "$PWD:/work" -w /work google/shaka-packager 
  in=source/captions.vtt,stream=text,language=en,format=ttml,init_segment=segments/subs/en_init.mp4,segment_template=segments/subs/en_$Number$.m4s,playlist_name=manifest/subs/en.m3u8,hls_group_id=subs,hls_name=English 
  --hls_master_playlist_output manifest/master.m3u8 
  --segment_duration 6

Modern Safari, HLS.js 1.6.x, Shaka Player 4.x, and ExoPlayer all read fMP4 subtitle tracks. Smart TVs are still a mixed bag; if your product targets connected TV, run the test matrix before flipping the switch.

What’s next

A few directions worth exploring once the basic pipeline is solid:

  • IMSC ingest. Premium content arrives as IMSC 1.1. Build a converter that preserves positioning and styling on the way to WebVTT; you will need it the first time a partner sends you a .dfxp.
  • Multi-language ladders. The same EXT-X-MEDIA pattern scales to N languages; the work is in the packager invocation, not the manifest.
  • Forced subtitles. Set FORCED=YES on subtitle tracks that translate on-screen text (foreign-language signs, locked subtitles in mixed-language scenes). The player surfaces them differently.

The shortcut version of HLS subtitles is fine for a demo. The version above is what stops being a support ticket.

video #webdev #tutorial #javascript

With Android CLI, Google is Making the Android Toolchain Agent-Friendly

Google introduced new Android development tools that enable building apps up to 3x faster by using AI agents, including a redesigned Android command-line interface (CLI), structured skills”, and an integrated knowledge base. These tools are designed to support agent-driven workflows and are compatible with third-party agents such as Claude Code and Codex, in addition to Google Gemini.

By Sergio De Simone

Samsung sendikası grevi askıya aldı

Güney Kore merkezli elektronik şirketi Samsung Electronics bünyesindeki sendikalı çalışanlar, performansa dayalı ikramiyeler konusunda yaşanan anlaşmazlık nedeniyle planlanan grev kararını askıya aldıklarını bildirdi.