Skip to content

Repository files navigation

VideoLoop

A small, fixed-size "video digest window" for community and activity websites: short, silent, self-hosted teaser clips play one after another in a fresh random order for every visitor, and a click jumps to the full video on YouTube (or wherever it lives). Zero dependencies, MIT licensed — commercial use is welcome.

日本語版 README はこちら / Born on www.rumicar.com, where it plays digests of ~100 activity videos next to its sibling project PhotoStrip. They pair well: photos flow, videos rotate, and both follow the same rule — the visitor only pays bandwidth for the attention they choose to spend.

Live demo

The problem we wanted to solve

Our club records every event on video. The videos sat on a YouTube channel, and the website's top page embedded exactly one of them — with a play button. Analytics said what you already suspect: almost nobody presses play. A hundred videos of real activity, invisible.

The obvious "fix", an autoplaying embedded player, has a hidden price: it streams for as long as the page is open, on the visitor's data plan, whether they watch or not. We had just built PhotoStrip around the opposite principle and didn't want to abandon it.

So the real requirements were:

  1. Motion without a click — otherwise it will not be seen.
  2. The visitor's bandwidth is spent only while they are actually looking.
  3. Every visit looks different — a fixed opening video makes a living channel look frozen.
  4. Clicking must reach the real video, so views count where the videos live.

Options we considered

Option Autoplays? Visitor bandwidth Verdict
Click-to-play embed no none until click honest, but never clicked
Autoplaying muted embed yes ~2 MB/min for as long as the page is open works, but the visitor pays continuously; per-video embeds also load third-party scripts
Thumbnail strip no tiny motion-less; same "never clicked" problem
Self-hosted teasers (this) yes ~230 KB per 8-second clip, only while visible chosen

The teaser idea in one sentence: pre-render the first impression. An 8-second, silent, 270p clip is ~230 KB — the size of two photos. One hundred videos become ~20 MB of static files that any web server can serve from cache, with no player SDK, no iframe, no third-party requests.

A note on scope: the teaser generator is designed for your own videos — recordings you made, or ones you have permission to re-host. When choosing a source mode, two facts are worth knowing: automated downloading is considered a grey area under YouTube's ToS even for your own uploads, and feeding the generator your original video files directly (--source) sidesteps that question entirely — it is also faster and works for videos that were never uploaded anywhere.

How it works

┌ tools/make_teasers.py (cron, nightly) ──────────────────────┐
│ enumerate videos → download/read sources → ffmpeg:          │
│  8s from the 20% mark, 270p, silent, ~230 KB → <id>.mp4     │
│  + <id>.jpg poster + manifest.json (newest first)           │
│ deleted source ⇒ teaser deleted too (privacy)               │
└─────────────────────────────────────────────────────────────┘
                              ↓ static files
┌ video-loop.js (browser) ────────────────────────────────────┐
│ shuffle manifest (Fisher–Yates, per page view)              │
│ one <video muted playsinline preload="none">                │
│ plays only while ≥40% on-screen AND the tab is visible      │
│ ended → next clip; error → skip; all errors → stop          │
│ reduce-motion / data-saver ⇒ poster + a start button        │
└─────────────────────────────────────────────────────────────┘

Decisions worth stealing:

  • Shuffle in the browser, not on the server. Server-side shuffling freezes inside page caches: one "random" order gets cached and every visitor sees it for the cache lifetime. We shipped that bug in PhotoStrip's first attempt; a visitor reported it. Client-side, a cached page still opens differently on every view.
  • preload="none" + IntersectionObserver + visibilitychange. Not a byte is fetched until the window scrolls into view, and playback pauses when it scrolls out or the tab is hidden (browsers keep playing muted video in background tabs; the observer alone does not catch that).
  • The generator deletes what the source deleted. A video you made private must not keep playing on your site. The nightly run removes teasers whose source is gone — and refuses to delete anything at all if the enumeration itself looks broken, so a transient failure can't wipe the set.
  • Fixed 16:9 window with object-fit: contain. Old 4:3 videos are letterboxed instead of resizing the layout per clip.
  • One-line, ellipsized titles. A two-line title would push everything below the window down by a line — and which video opens is random, so the page height would jitter per visit.

Pitfalls we hit (so you don't have to)

Every one of these is a real defect we shipped or nearly shipped; most were caught by adversarial review (see RETROSPECTIVE).

  • The 404 machine gun. "On error, skip to the next clip" is obviously right — until every clip 404s (folder moved; cached HTML pointing at deleted teasers). Then error→next→error becomes an infinite request loop in every open browser. Stop after one full circle of consecutive errors.
  • The timeout poison pill. One video that takes longer than the subprocess timeout doesn't just fail — an uncaught TimeoutExpired crashes the whole nightly run at the same video every night, and the retry counter never advances. Convert timeouts into ordinary failures.
  • The lying pause button. If autoplay is refused (iOS Low Power Mode), a button that just toggles its own label reads "Pause" while nothing plays. Drive the label from the <video>'s play/pause events.
  • Cache-layer mixing while deploying. Right after editing server code, the page cache can be refilled with old output by a worker whose opcode cache hasn't revalidated yet — and curl won't see it, because its cache key (no Accept-Encoding: gzip) differs from real browsers'. Purge caches only after the code cache has settled, then verify with a real browser.
  • Poster/clip cache asymmetry. Our nginx gave images expires max and videos nothing. Result: deleted videos' posters would have lived in browser caches forever while clips got re-fetched every visit. Give the teaser folder its own moderate expires (we use 7 days) for both.
  • Grep is not a parser. While verifying "are all 98 videos in the page?", grep -o '"videos":\[[^]]*' silently stopped at the first ] — which appeared inside a video title. Parse JSON with a JSON parser, even for one-off checks.

Which recipe fits your site?

Your site Recipe
Static HTML (GitHub Pages, Netlify, plain hosting) examples/01-static-html — generate the folder, upload, done. The browser fetches manifest.json, so new videos need no HTML edits.
PHP behind a full-page cache examples/02-php — embed the manifest server-side so there is exactly one cache layer.
WordPress examples/03-wordpress — a 70-line shortcode plugin, same design that runs on rumicar.com.

The generator runs anywhere Python 3 and a static ffmpeg binary run — a $5 VPS, a Raspberry Pi, your laptop before you rsync. No sudo needed: drop the binaries in ~/bin.

Usage

<link rel="stylesheet" href="video-loop.css">
<div id="loop"></div>
<script src="video-loop.js"></script>
<script>
  VideoLoop(document.getElementById('loop'), {
    manifest: 'teasers/manifest.json'   // written by tools/make_teasers.py
  });
</script>

Or pass clips explicitly (any source, not just the generator):

VideoLoop(el, {
  items: [
    { src: 'clips/a.mp4', poster: 'clips/a.jpg',
      title: 'Spring workshop', link: 'https://youtu.be/…' },
    // link is optional - without it the window is not clickable
  ],
  // Fewer than 2 clips ⇒ nothing is rendered (one clip is not a rotation).
  shuffle: true,     // default: fresh order per page view
  autoplay: true,    // default: set false to always wait for the start button
  threshold: 0.4,    // how visible before playing
  controls: true,    // pause/play button (WCAG 2.2.2)
  linkTarget: '_blank',
  labels: { pause: '⏸ Pause', play: '▶ Play',
            start: '▶ Play previews', watch: 'Watch the full video' }
});

Generate teasers:

# from local video files (clean & portable):
python3 tools/make_teasers.py --source /path/to/videos --out teasers

# from your own YouTube channel (see the note on source modes above):
python3 tools/make_teasers.py --channel https://www.youtube.com/@You/videos --out teasers

# useful knobs: --seconds 8 --height 270 --offset-frac 0.2 --limit 10 --js-runtime node

Accessibility

  • A pause/play button is always rendered (WCAG 2.2.2 — auto-moving content must be stoppable; hover tricks don't exist on touch screens).
  • prefers-reduced-motion and data-saver mode suppress autoplay entirely: the visitor sees a poster and a start button. Starting by hand is their decision, which is exactly what those settings ask for.
  • The clickable window carries an aria-label naming the current video; titles are written with textContent (no innerHTML anywhere).

Browser support

Modern browsers, 2021+. Muted autoplay is what all of them allow; if a browser still refuses, the visitor sees the poster and the button — the page never breaks.

What this is not

  • Not a video player — it never plays sound or full videos; it advertises them where they live.
  • Not a YouTube downloader — the generator's channel mode is a convenience for digesting your own channel (see the note on source modes above).
  • Not a carousel library — one window, one job.

License

MIT. Use it, modify it, sell things built with it — attribution appreciated but not required. If it helps your community show its activity, that's the point.

The license covers this software, not the videos processed with it. MIT applies to the code in this repository; it grants no rights over any video content, which remains governed — as always — by copyright law and the terms of the platforms involved. VideoLoop was designed, and is documented here, for one job: showing a community its own activities.

About

Bandwidth-friendly video digest window: silent self-hosted teasers, random per visit, click-through to the full video. Zero dependencies, MIT.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages