SafeFrameArchitecture
Design document

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.

A useful test of the claim: run one scan online, then put the device in airplane mode and scan a local file. It still works, because there was never anything on the other end of a wire to call.

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.

  1. 1IngestFile or URL — object URL, never uploaded
  2. 2SampleSeek-driven, adaptive fps, densify on hits
  3. 3PreprocessDownscale to 224×224 RGB on a canvas
  4. 4ClassifyTier 1 always · Tier 2 on borderline
  5. 5AggregateEWMA → hysteresis → segments
  6. 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.

Safe
Borderline
Flagged
0.00.350.751.0
  • 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.

One rule earned the hard way: Tier 2 arbitrates, it does not overwrite. An earlier build simply replaced the screener's score with the verifier's, which handed the verifier a unilateral veto — and videos containing real explicit footage started coming back clean, because their frames happened to fall inside the escalation band. The scores are now blended, with a floor at the screener's explicit-class score: the verifier may overrule “this looks suggestive”, but it may not overrule “this is explicit”.

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.

  1. 1MobileNetV2 screener — every frame
  2. 2ViT-base verifier — borderline frames only
  3. 3Tier-1 verdict — once the per-video budget is spent

Execution provider

The same weights, whatever the device can actually run.

  1. 1WebGPU · fp16
  2. 2WebGPU · fp32
  3. 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.

  1. 1Full cascade
  2. 2Tier-2 worker dies mid-run → finish on Tier 1
  3. 3Low-tier device → Tier 2 never downloaded at all

Capability detection

Every browser API used has a documented fallback.

  1. 1requestVideoFrameCallback → timeupdate
  2. 2OffscreenCanvas → hidden canvas
  3. 3File System Access → localStorage
  4. 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.

0.00.51.0
raw per-frame score smoothed (EWMA) flag threshold release threshold flagged segment
Flags a run of consecutive raw frames above the threshold even when smoothing keeps the averaged score below it. Uncheck to see the behaviour that produced a false negative on real footage.Turn this off with the Short insert scenario selected to see the false negative it was written to fix.
Flaggedconfidence 80%1 segment29 frames

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.

deviceMemoryhardwareConcurrencyWebGPU probe5-frame live micro-benchmarktier

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.

TierSignalsSamplingCascadeBytes over the wire
HighWebGPU present · ≥8 GB · fast micro-bench1 fps + densifyTier 1 + Tier 2 on WebGPU≈4 MB now, ViT lazily on first borderline frame
MidNot High or Low · no WebGPU, <8 GB or bench >50 ms1 fps + densifyTier 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-bench0.5 fps + densifyTier 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.
On quantization: int8 weights are roughly a quarter the size of fp32 for a small accuracy cost, which is normally an easy trade on a mobile network. The caveat is that quantized operators are not equally supported across execution providers — see the execution-provider chain above for what that cost us in practice.

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.

  1. 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.

  2. 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.

  3. 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.

ApproachAccuracyCost on low-end hardwareVerdict
Single small modelWeakest — confuses skin tone and swimwear more oftenBestRejected 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 modelStrongUnacceptable — tens of MB to download and seconds per frame under WASM on old phonesRejected: fails the low-end constraint outright.
Parallel ensembleStrongest on paperWorst — pays the large-model cost on every single frameRejected: roughly doubles compute for a marginal gain over the cascade, and directly contradicts the resource budget a mobile target imposes.
Adaptive cascadeNear-ensemble — the big model sees exactly the frames where it changes the answerPays the Tier-2 cost only on the borderline fractionChosen. Multi-model robustness at a fraction of multi-model cost, and it degrades by device tier instead of failing.
What the cascade costs: two models to version and tune instead of one; a routing band that itself needs calibration; and a pathological video where most frames are borderline approaches ensemble cost — which is why there is a per-video escalation budget, after which Tier-1 verdicts apply.

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.

ResponseExperienceTechnical costVerdict
BlurContent instantly unreadable; the screen does not appear brokenOne GPU-composited CSS filterCore of the default, but combine with pause — audio keeps playing otherwise.
PauseStops progression immediatelyTrivialNecessary and insufficient: the flagged frame stays visible.
Hide the playerStrongest block, but jarring and context-destroyingTrivialKept as a strict-mode option.
Replacement cardBest for a child — explains calmly and offers a path forwardTrivialPart of the default composite.
Region blurMost graceful — the rest of the scene stays watchableNeeds localization, not classification: several times the compute, and a miss shows exactly the wrong pixelsThe right long-term ambition, the wrong default for low-end hardware. Roadmap, not shipped.
Skip the segmentThe video 'just works'Needs a pre-scan segment map, which we haveA strong second mode once segment boundaries have earned trust; it can silently remove context.
The parent gate is scoped to one detection, deliberately. Approving a flagged moment must not disarm the guard for the rest of the video, so the override expires with the episode that prompted it and the next detection gates again.

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