How SafeFrame decides
A video goes in; a verdict comes out; nothing leaves the device. This page is the reasoning behind that sentence — the pipeline, the two models and why those two, the fallback chains that keep a scan alive when something fails, and the tradeoffs taken on purpose. Everything described here is running in the tab you are reading it in.
The problem
Accept a video by upload or URL, decide whether it contains inappropriate visual content, and return a verdict with a confidence. The governing constraint is that all of it runs in the browser: no inference API, no server-side processing, no telemetry. The production target is a mobile browser on hardware that may be several years old.
That constraint is the product, not an obstacle. A kids-safety browser cannot ship children's screen content to a server — it fails on privacy, on legal exposure, on offline reliability, on latency, and on per-user cost. So the architecture treats “no backend” as a property to prove rather than a limitation to route around. There is no database and no server component anywhere in this system. The only things persisted are model weights in the Cache API and preferences in localStorage, both on the user's own machine.
Why image classifiers, not a vision LLM
Deciding whether a frame contains nudity is an image-classification problem, and it has purpose-built open models measured in megabytes. A multimodal LLM can also answer the question, but the smallest usable vision-language models are hundreds of megabytes to gigabytes, take seconds per frame on a phone, and drain a battery doing it. Against the constraints that actually matter here — compute, memory, battery, device variability — they lose on every one.
There is a second, quieter reason. A classifier returns a calibrated probability per class, which is what makes smoothing, hysteresis, thresholds and a tunable precision/recall posture possible. A generative model returns text you then have to parse and cannot easily calibrate. For a safety decision that a parent may need to adjust, the numeric output is the more honest interface.
The pipeline
Six stages, each chosen so the expensive work happens as rarely and as cheaply as possible.
- 1IngestFile or URL — object URL, never uploaded
- 2SampleSeek-driven, adaptive fps, densify on hits
- 3PreprocessDownscale to 224×224 RGB on a canvas
- 4ClassifyTier 1 always · Tier 2 on borderline
- 5AggregateEWMA → hysteresis → segments
- 6RespondVerdict JSON + blur / pause / replace
Sampling
A hidden video element is seeked programmatically rather than played, which runs faster than real time and decodes no audio. Baseline is one frame per second; when a frame lands in the uncertain band the sampler densifies locally — the surrounding couple of seconds at 2 fps — so a short flash next to a hit is not missed. Ten minutes of video becomes a few hundred classified frames instead of eighteen thousand.
Preprocess
Every frame is drawn down to 224×224 before any model sees it, because that is the models' native input size. This is the single largest performance win in the system: classifying a native-resolution frame would cost orders of magnitude more for no accuracy gain whatsoever.
Threading
Tier-2 inference runs in a Web Worker, so the heavy path never blocks the main thread. The sampling loop yields to the event loop between frames, which is why progress, the timeline and the cancel button stay responsive for the whole scan — the benchmark panel counts long tasks over 50 ms so that claim is measured rather than asserted.
The two-tier cascade
Every sampled frame is scored by the cheap model. That score routes the frame into one of three bands, and only the middle band costs anything more.
p < 0.35SafeAccept. No further work — this is most of a normal video.0.35 ≤ p < 0.75BorderlineEscalate to Tier 2, budget permitting. The two opinions are then fused — Tier 2 dominates but cannot erase Tier 1's explicit-class evidence.p ≥ 0.75FlaggedFlag directly. Tier 1 is already confident, so spending the big model here would buy nothing.
The band is the whole design. Narrow it and you have a single-model system; widen it to everything and you have paid for an ensemble. Only the frames where the cheap model is genuinely unsure are worth the expensive model's time.
Fallback chains
Nothing in this system has a single point of failure. Four independent chains degrade rather than break, and all four are in the shipped code rather than in a plan for later.
Model cascade
Accuracy where it matters, cost where it doesn't.
- 1MobileNetV2 screener — every frame
- 2ViT-base verifier — borderline frames only
- 3Tier-1 verdict — once the per-video budget is spent
Execution provider
The same weights, whatever the device can actually run.
- 1WebGPU · fp16
- 2WebGPU · fp32
- 3WASM · int8
Ordering here is load-bearing, not cosmetic. int8 weights on the WebGPU backend returned silent numeric garbage — no exception, just wrong answers — so float weights come first on WebGPU and int8 is reserved for WASM, where it is both correct and the bandwidth win it was designed to be.
Graceful degradation
A broken component must cost accuracy, never the whole scan.
- 1Full cascade
- 2Tier-2 worker dies mid-run → finish on Tier 1
- 3Low-tier device → Tier 2 never downloaded at all
Capability detection
Every browser API used has a documented fallback.
- 1requestVideoFrameCallback → timeupdate
- 2OffscreenCanvas → hidden canvas
- 3File System Access → localStorage
- 4CORS-readable URL → guided to file upload
Scoring and the decision layer
A per-frame score is combined from the classifier's classes as p = clamp(porn + hentai + sexy × 0.4). The ×0.4 weight is a deliberate policy choice, not a tuning artefact: it makes it arithmetically impossible for swimwear alone to flag a video at default thresholds, which is the single most common false positive in this domain.
Frame scores then feed two independent decision paths. An EWMA-smoothed series with hysteresis draws segment boundaries — it ignores single-frame spikes, which is exactly what you want for deciding where content starts and stops. But smoothing also damps short events, so a second pass looks at the raw series and treats a sustained run of over-threshold frames as evidence in its own right. Either path can flag a video; both produce segments, so the verdict and the timeline can never disagree on screen.
Try it — this widget runs the shipped aggregator
A three-second insert. The EWMA peaks around 0.57 — under the 0.60 threshold — so hysteresis alone would report this video clean. This is the false negative found in live testing; the sustained-evidence pass is what catches it.
Device tiering
The same build serves a low-end phone and a high-end desktop. It does that by deciding, before it downloads anything large, what this particular device can actually sustain.
Static hints alone are unreliable — deviceMemory is coarse and capped, and a phone can report four cores while thermally throttling. So the hints only propose a tier; a real measurement on the real hardware confirms it before anything large is downloaded.
| Tier | Signals | Sampling | Cascade | Bytes over the wire |
|---|---|---|---|---|
| High | WebGPU present · ≥8 GB · fast micro-bench | 1 fps + densify | Tier 1 + Tier 2 on WebGPU | ≈4 MB now, ViT lazily on first borderline frame |
| Mid | Not High or Low · no WebGPU, <8 GB or bench >50 ms | 1 fps + densify | Tier 1 + Tier 2 (WebGPU if present, else WASM), same 40-frame budget | ≈4 MB now, ViT only if a frame is genuinely borderline |
| Low | <4 GB · ≤2 cores · slow micro-bench | 0.5 fps + densify | Tier 1 only | ≈4 MB, total. The ViT is never fetched. |
The models
Two open models, chosen for complementary reasons: one small enough to run on everything, one accurate enough to be worth asking when the first is unsure.
Tier 1 — screener
MobileNetV2 NSFW
- Source
- NSFWJS (open weights, bundled in the npm package)
- Parameters
- ≈3.5 M
- Weights
- ≈4 MB quantized
- Output
- 5 classes: drawing · hentai · neutral · porn · sexy
- Runtime
- TensorFlow.js — WebGL, WASM fallback
- Network
- No third-party fetch. Same-origin chunks from the app's own build, cached for offline use after the first scan.
Tier 2 — verifier
ViT-base NSFW detector
- Source
- AdamCodd/vit-base-nsfw-detector via Transformers.js
- Parameters
- ≈86 M
- Weights
- 84 MB int8 · 164 MB fp16 · 329 MB fp32
- Output
- 2 classes: sfw · nsfw
- Runtime
- ONNX Runtime Web — WebGPU, WASM fallback, in a Web Worker
- Network
- Fetched lazily on the first borderline frame, then persisted in the Cache API. Never fetched at all on a low-tier device.
Offline behaviour and caching
Model weights are the only meaningful bytes this app moves, so where and when they move is a design decision rather than an implementation detail.
1First scan
The app shell arrives on the first visit; the detection runtime and the 4 MB screener follow as same-origin chunks when a scan starts. They are part of the app's own build, not a model-hub download, so nothing third-party sits on the cold-start path.
2First borderline frame
Only now is the ViT requested, and only on mid/high-tier devices. A video that is obviously safe or obviously explicit never triggers it, so most scans cost zero extra bytes.
3Every visit after
Weights are served from the Cache API and the shell from the service worker. Detection then runs with the radio off — which is the honest test of whether inference is really local.
Alternatives considered
The cascade is the load-bearing decision, so here is what it was chosen over and why.
| Approach | Accuracy | Cost on low-end hardware | Verdict |
|---|---|---|---|
| Single small model | Weakest — confuses skin tone and swimwear more often | Best | Rejected as the only mode; the accuracy ceiling is too low for a safety product. It survives as the low-tier path, where it is the only honest option. |
| Single large model | Strong | Unacceptable — tens of MB to download and seconds per frame under WASM on old phones | Rejected: fails the low-end constraint outright. |
| Parallel ensemble | Strongest on paper | Worst — pays the large-model cost on every single frame | Rejected: roughly doubles compute for a marginal gain over the cascade, and directly contradicts the resource budget a mobile target imposes. |
| Adaptive cascade | Near-ensemble — the big model sees exactly the frames where it changes the answer | Pays the Tier-2 cost only on the borderline fraction | Chosen. Multi-model robustness at a fraction of multi-model cost, and it degrades by device tier instead of failing. |
Responding to a detection
Detection is only half the problem. The recommended default for a child audience is a composite response: blur the frame within one animation tick, pause playback, and cover the stage with a calm replacement card that offers an adult-gated way to continue. Blur alone leaves audio playing. Pause alone leaves the offending frame on screen. Hiding the player entirely reads as breakage to a child. The combination is what avoids all three failures.
| Response | Experience | Technical cost | Verdict |
|---|---|---|---|
| Blur | Content instantly unreadable; the screen does not appear broken | One GPU-composited CSS filter | Core of the default, but combine with pause — audio keeps playing otherwise. |
| Pause | Stops progression immediately | Trivial | Necessary and insufficient: the flagged frame stays visible. |
| Hide the player | Strongest block, but jarring and context-destroying | Trivial | Kept as a strict-mode option. |
| Replacement card | Best for a child — explains calmly and offers a path forward | Trivial | Part of the default composite. |
| Region blur | Most graceful — the rest of the scene stays watchable | Needs localization, not classification: several times the compute, and a miss shows exactly the wrong pixels | The right long-term ambition, the wrong default for low-end hardware. Roadmap, not shipped. |
| Skip the segment | The video 'just works' | Needs a pre-scan segment map, which we have | A strong second mode once segment boundaries have earned trust; it can silently remove context. |
Path to production on mobile
The pipeline ports as-is; what changes underneath it is the runtime. Inference moves to ONNX Runtime Mobile, Core ML or NNAPI for a large throughput and battery improvement — the Expo app in this repo already demonstrates that path with on-device ONNX inference and no network access at all.
Beyond the runtime: distil the verifier toward MobileViT or EfficientNet-Lite scale so mid-tier devices can afford it, add heads for violence and gore, and curate a labelled evaluation set so thresholds can be set per age band from measured precision/recall rather than judgement. And in a kids browser, the detector belongs in the engine rather than in a page: hooking the render pipeline removes the CORS problem entirely and scores every media surface uniformly.
Known limitations
- URLs whose host sends no CORS headers cannot be analyzed client-side. A proxy would fix it and would also break the central promise, so the app explains the situation instead. File upload always works, and an in-engine deployment does not have the problem at all.
- DRM and encrypted-media streams are out of reach for any client-side approach — the decoder deliberately yields no readable frames.
- Scope is sexual content. Violence, gore and drug imagery are not detected; that is an additional classifier head on the same pipeline, not a redesign.
- Sampling can miss a sub-second flash between samples. Densification narrows the window but does not close it; cheap scene-change detection is the production mitigation.
- Skin-heavy but safe content — beaches, swimwear, medical footage — remains the hardest negative. The ×0.4 weighting and the verifier both push against it, and a real product needs curated thresholds per age band.
- The live guard is Tier-1 only, by design: it favours latency over accuracy, so it is more prone to false positives than a pre-scan. A pre-scan is the higher-assurance path.
- Audio is not analyzed at all.
- Sustained processing on older hardware throttles thermally, which is why the low tier samples at half rate rather than trying to keep up.
Everything on this page is running locally right now.
Scan a video