How Helen Gets Made A making-of, not the live show Watch her live

Cut · YouTube · the pipeline

The pipeline that cuts and posts four Shorts a day without a person

A Short once went out that ran 14.9 seconds backwards, because a short beat had been centred on its own timestamp with no regard for where the edit already was. The rule that came out of it sits in a comment dated 2026-08-25 in one of two Python files on the Windows PC that runs Frigate. A scheduled task runs one of them every fifteen minutes; between them they poll Frigate, decide whether a cat visit is worth a video, cut it across the cameras that saw her, caption it, and post it as a public YouTube Short.

Excerpts from those two files, not the 4,600 lines; every excerpt is from the file that runs. The rules they enforce are on the cut page and the four publishing doors on the YouTube page.

Helen at the water bowl; a Water Cam event like this one is where most published visits begin
A Frigate cat event on the Water Cam. Water is the one station that is always eligible, whatever the length.

Both files run on the same desktop as Frigate, against http://127.0.0.1:5000. helen_pipeline.py (2,059 lines) is the library: the Frigate client, the vision and caption calls, the caption renderer, the YouTube uploader, the state files. helen_multiangle.py (2,603 lines) imports it and owns the editorial logic: stations, visits, the shot list, the crop, the publishing clock. If you only read one thing, read the shot list; that is where the rules on the cut page became code.

What it asks Frigate for

Four endpoints. No MQTT, no webhooks, no Frigate plugin; the pipeline is a client of the same REST API the Frigate web UI uses, so nothing in Frigate had to change.

Frigate API calls, in the order a run makes them
CallUsed for
GET /api/configCamera discovery. Every camera name in the live config is matched against station aliases; unknown names are ignored or refused (below). Adding a camera to Frigate is enough to add it here.
GET /api/events?camera=<cam>&label=cat&after=<epoch>&limit=120&has_clip=1Per camera, not system-wide: the system-wide list is newest-first across every detecting camera (the three house cameras have detect off and never produce events), and a busy hour at one bowl pushes a quiet camera's events off the end.
GET /api/<cam>/start/<s>/end/<e>/clip.mp4Each shot, as a time-range export from continuous recording (see retention: this is why the cat cameras keep three days of everything, not just events). Frigate honours the start and overshoots the end by 1.1–2.1 s per clip, measured.
GET /api/events/<id>, GET /api/events/<id>/snapshot.jpgThe event's bounding box and path for the crop, and the hero frame for the caption model and the thumbnail.
helen_pipeline.py — the two calls that matter
FRIGATE = "http://127.0.0.1:5000"

def frigate_events_camera(camera, after_epoch, limit=120):
    """Recent cat events for one camera since after_epoch (epoch seconds)."""
    url = "%s/api/events?camera=%s&label=cat&after=%s&limit=%d&has_clip=1" % (
        FRIGATE, urllib.parse.quote(str(camera)), repr(float(after_epoch)), limit)
    try:
        return http_json(url, timeout=20)
    except Exception as e:
        log("frigate camera poll error (%s): %s" % (camera, e))
        return []
helen_multiangle.py — one shot, straight out of the recording
def fetch_range(cam, start, end, dest, timeout=180):
    """One shot, straight out of Frigate's continuous recording."""
    url = "%s/api/%s/start/%s/end/%s/clip.mp4" % (
        P.FRIGATE, cam, repr(float(start)), repr(float(end)))
    dst, n = P.http_download(url, dest, timeout=timeout)
    if n <= 2000 or not Path(dst).exists() or Path(dst).stat().st_size <= 2000:
        raise RuntimeError("empty range clip for %s" % cam)
    return dst

Visits: events become one thing

Frigate emits an event per camera per object. A cat that drinks, walks to the food bowl and eats is three or four events on two cameras. The pipeline's unit is the visit: events from any camera, sorted by start time, joined while the next one starts within 45 s of the latest end seen so far, capped at seven minutes. Events still in progress (no end_time) are left for the next run.

helen_multiangle.py — build_visits
SESSION_GAP_SEC = _envf("HELEN_MA_GAP", 45.0)      # events this close = one visit
SESSION_MAX_SEC = _envf("HELEN_MA_SPAN", 420.0)    # hard cap on one visit's span

def build_visits(events, cam2st, gap=None, max_span=None):
    """Cluster finished cat events across all Helen cams into visits.

    A visit is a run of events where each next event starts no later than
    `gap` seconds after the latest end seen so far. Because the clustering is
    done across cameras, two angles of the same moment land in one visit, and
    so does a cat that walks water -> food inside the gap.
    """
    gap = SESSION_GAP_SEC if gap is None else gap
    max_span = SESSION_MAX_SEC if max_span is None else max_span
    usable = []
    for ev in events or []:
        try:
            cam = ev.get("camera")
            if cam not in cam2st:
                continue
            if not ev.get("has_clip"):
                continue
            if ev.get("start_time") is None or ev.get("end_time") is None:
                continue          # still in progress -- wait for a later run
            usable.append(ev)
        except Exception:
            continue
    usable.sort(key=lambda e: float(e["start_time"]))

    visits = []
    cur = []
    cur_end = None
    cur_start = None
    for ev in usable:
        s = float(ev["start_time"])
        e = float(ev["end_time"])
        if cur and (s <= cur_end + gap) and ((e - cur_start) <= max_span):
            cur.append(ev)
            cur_end = max(cur_end, e)
        else:
            if cur:
                visits.append(cur)
            cur = [ev]
            cur_start = s
            cur_end = e
    if cur:
        visits.append(cur)
    return visits

A visit is eligible if the cat was in frame for at least six seconds (the union of the event spans, not the padded clip length: "a 2-second blip padded to 12 s is still a 2-second video of a cat"), and then either it touches the Water station, which is always eligible, or its whole span plus padding clears a 20-second floor. When a visit publishes, every event in it is marked processed, so the same drink cannot become a second Short from another angle.

The shot list, and the rule that a cut never goes backwards

Each event becomes a beat: camera, start, end, score. Beats on the same camera that touch within two seconds merge (Frigate regularly emits a short event nested inside a long one on the same camera). Then:

helen_multiangle.py — the rules, from the docstring, and the constants
SHOT_MIN_SEC = _envf("HELEN_MA_SHOT_MIN", 2.5)
SHOT_MAX_SEC = _envf("HELEN_MA_SHOT_MAX", 5.0)
MAX_SHOTS = int(_envf("HELEN_MA_MAX_SHOTS", 4))
SHOT_PAD_SEC = _envf("HELEN_MA_SHOT_PAD", 1.5)     # lead-in/out around each beat
# episode length comes from helen_pipeline: MIN_EPISODE 14 s, MAX_EPISODE 20 s

def build_shotlist(visit, cam2st, min_total=None, max_total=None, lead=None,
                   open_cam=None, max_shots=None):
    """Chronological (camera, start, end) shots covering the visit.

    Rules, in order:
      1. One beat per camera-run, in the order the cat triggered them.
      2. Consecutive beats from the SAME camera are merged -- a cut back to the
         angle you are already on is not a cut.
      3. Too many beats to fit the Shorts window: keep the highest-scoring ones,
         then restore chronological order. Never more than MAX_SHOTS.
      4. Shot lengths are clamped to [SHOT_MIN, SHOT_MAX], then scaled so the
         total lands inside MIN_EPISODE..MAX_EPISODE (14-20s as of
          2026-08-02; was 8-15s, which ended just below the best
          performing length bucket across the first 81 uploads).
    """
helen_multiangle.py — placing each shot on the visit's shared clock
        # 2026-08-25 HARD RULE: a cut never goes backwards. The centring branch
        # above places a short beat around its own timestamp with no regard for
        # where the edit already is, which is what put a 14.9s-earlier shot last
        # in an aired episode. Clamp to the cursor; if the beat's own footage is
        # then behind the edit, drop the beat rather than time-travel.
        if cursor is not None and start + 0.001 < cursor:
            start = cursor
        end = start + dur
        if shots:
            _ov = min(end, b["end"]) - max(start, b["start"])
            if _ov < min(1.5, dur * 0.4):
                log("shot dropped: %s beat is behind the edit (%.1fs of usable "
                    "footage) -- keeping the cut chronological" % (b["cam"], _ov))
                continue
        cursor = end

That is the whole fix for the backwards Short (the cut page tells the story from the viewer's side). Every shot now starts at or after the previous shot's end on the one clock all cameras share, and a beat whose footage would fall behind that point is dropped instead of reordered. The self-test suite has a case for it (13b. cuts never travel backwards).

The vertical crop

Each shot is cropped to 1080×1920 before the concat, at full source resolution, so every angle keeps its native detail and each camera can carry its own horizontal bias (the Face camera, for instance, is steered away from the bowl edge). The crop centre comes from the event: GET /api/events/<id> gives the box and, if Frigate tracked one, the path, and a piecewise-linear pan expression follows it.

helen_multiangle.py — the pan expression ffmpeg evaluates per frame
def _pan_expr(knots):
    """ffmpeg per-frame expression for cx(t): piecewise linear through knots."""
    expr = "%.4f" % knots[-1][1]
    for (ta, ca), (tb, cb) in reversed(list(zip(knots, knots[1:]))):
        seg = ("(%.4f+(%.4f)*((t-%.3f)/%.3f))"
               % (ca, cb - ca, ta, max(0.001, tb - ta)))
        expr = "if(lt(t,%.3f),%s,%s)" % (tb, seg, expr)
    return "if(lt(t,%.3f),%.4f,%s)" % (knots[0][0], knots[0][1], expr)
helen_multiangle.py — why -t comes before -i
    # 2026-08-24: -t BEFORE -i, so each shot contributes exactly the length
    # build_shotlist assigned it. fetch_range asks Frigate for start->end and
    # Frigate returns more (measured +1.1s to +2.1s per shot, +5.84s over four),
    # and this loop used to ingest every file whole - which is how a 20.0s
    # shotlist became a 25.1s Short. Frigate honours the START, so trimming from
    # the front of each file keeps exactly the chosen window.
    for _i, p in enumerate(shots_paths):
        _d = None
        try:
            _d = float(durs[_i]) if durs else None
        except Exception:
            _d = None
        if _d and _d > 0.2:
            cmd += ["-t", "%.3f" % _d]
        cmd += ["-i", str(p)]

The caption

Two model calls per video, both resolved by role through a small resolver with an ordered fallback chain and an allowlist, so no file in the pipeline names a vendor. The vision role samples three frames across the assembled clip (not Frigate's single best-score snapshot, which captions a walk-past as a meal) and returns a sentence about what she did. The caption role turns that into the decree. The resolver, the chains and the day one vendor went down are on the AI page.

helen_models.py — roles, in fallback order
DEFAULT_ROLES = {
    # Vision: must be able to see the frame.
    "vision": [
        "openrouter:google/gemini-2.5-flash",
        "openrouter:google/gemini-2.5-flash-lite",
        "together:google/gemma-3n-E4B-it",
        "gemini:gemini-2.5-flash",
        "anthropic:claude-haiku-4-5-20251001",
    ],
    # Caption: short creative text, cheap tier first.
    "caption": [
        "openrouter:google/gemini-2.5-flash-lite",
        "openrouter:google/gemini-2.5-flash",
        "together:meta-llama/Llama-3.3-70B-Instruct-Turbo",
        "gemini:gemini-2.5-flash-lite",
        "anthropic:claude-haiku-4-5-20251001",
    ],
}
MAX_CALLS_PER_RUN = int(os.environ.get("HELEN_MAX_MODEL_CALLS", "60"))
helen_pipeline.py — the decree prompt
def make_decree(behavior, camera, frame_hint=None):
    touch = cam_touch(camera)
    user = (
        "WHAT HELEN JUST DID:\n    %s\n\n"
        "Write ONE very short thought or observation about what Helen just did -- a brief "
        "line, ideally 5-9 words and NEVER more than 10, warm and a little wry. You MAY fold in this single local "
        "touch or omit it: '%s'. No history. Return only the line, no period needed." % (behavior, touch)
    )

A lane that returns a billing or quota error is marked dead for the run and the next one is tried. If the vision call fails everywhere, the description falls back to a plain sentence built from the station; if the caption call fails everywhere, the run stops rather than uploading a video with no words on it, and a separate backfill task fills the slot from a library whose captions come from a local file, not an API. The gold text is burned on with Pillow and ffmpeg at 1080×1920; the rules for where it sits are on the cut page, and the prompt, the guardrails around it and the person who still reads every line are on the AI page.

Guardrails, every one of which was earned

What refuses to publish, and why it exists
GateWhat it does
NEVER_PUBLISH deny listCamera names that may record and be watched but may never reach YouTube. Checked first, before the station map, so no future edit can rescue one. Written after an audit found that an unrecognised camera used to be adopted as a new station and green-lit.
may_publish() fails closedReturns False for None, for an empty string, and for any station not in the known table. It used to return True for anything whenever the restriction env var was unset, which was always, in production.
HOUSE_DENY prefixesfront_door, back_yard, driveway, porch, remote_ and friends are ignored at discovery. The house cameras exist in Frigate for the house; see naming.
Person guardAt the single choke point every publishing path goes through, a visit that overlaps a Frigate person event on the same camera is refused. This one fails closed even if Frigate cannot be reached: 10 of 75 eligible visits over 72 hours had a person in them, which is why person is tracked on the cat cameras at all. A separate task every 20 minutes re-checks what has already been posted.
Minimum cat timeMIN_CAT_SEC = 6.0: the union of event spans, not clip length.
Never the same station twice runningA hard rule, not a score penalty. Eleven consecutive Water Cam uploads on 2026-07-31 earned it. If only that station is available, the run holds.
RateMAX_PER_RUN = 1, COOLDOWN_MIN = 25 between uploads, and a 45-minute window per publishing slot that can be consumed once per day.
AgeEvents older than 30 hours are ignored in the multi-angle cutter (the library default is 12). She is nocturnal: a week of Frigate events showed peaks at 21:00, 01:00, 22:00 and 00:00 and nothing at all 09:00–14:00, so a 01:00 visit has to survive until the 19:00 slot.
A model's opinionNever asked. None of the gates in this table calls a model, and the two model calls that exist sit downstream of every one of them. Two of these gates were found missing by a coding assistant reading this file on 2026-08-23: may_publish() then returned true for anything, and two cameras were being discarded every run as unrecognised. The same session wrote the resolver, after the caption vendor's billing limit had taken the channel dark that morning.
YouTube says nouploadLimitExceeded (a 400, the channel's daily cap) pauses uploads for eight hours; quotaExceeded, invalid_grant and a few others are treated as permanent for the run instead of retried. More cameras made the pipeline more selective, not more prolific.

The upload

helen_pipeline.py — upload_public (trimmed)
    svc = build("youtube", "v3", credentials=creds, cache_discovery=False)
    snip = {"title": title[:95], "description": description[:4900],
            "categoryId": "15"}                       # Pets & Animals
    if tags:
        # YouTube caps the tag list at 500 characters total, so trim rather than
        # let the whole insert fail on a 400.
        picked, used = [], 0
        for t in tags:
            if used + len(t) + 1 > 480:
                break
            picked.append(t)
            used += len(t) + 1
        snip["tags"] = picked
    body = {
        "snippet": snip,
        "status": _hero_status(privacy, publish_at),  # public, not made for kids
    }
    # chunksize=-1 sends the whole file in ONE request, which silently defeats
    # resumable=True. Fine for a 3 MB clip, fatal for a 200 MB compilation.
    media = MediaFileUpload(str(mp4), chunksize=8 * 1024 * 1024,
                            resumable=True, mimetype="video/*")
    req = svc.videos().insert(part="snippet,status", body=body, media_body=media)

The OAuth token is asked for the scopes it already holds (youtube.upload and youtube.force-ssl); for weeks the code requested only upload, which is why every thumbnail call failed with insufficient permissions and every video shipped with a YouTube-picked frame. A Short is just a vertical video under sixty seconds; there is no Shorts flag in the API.

One run of everything above, uploaded by the code in the previous block. Look for the shot order (frame one is already a cat), the crop following her across the wide source, and the ten-word decree held in the bottom third with the top of the frame left to her.

What schedules it

One Windows scheduled task, HelenCamUploader2, runs python.exe helen_multiangle.py --once every fifteen minutes as SYSTEM. Most runs exit in a second with outside a publishing slot -- nothing to do. Inside a slot the run polls, builds visits, picks the best one that prefers the slot's station, and publishes at most one.

helen_multiangle.py — the four doors
# (HH, MM, preferred station or None for "best available")
# 2026-08-02 RETIMED from 81 published videos. Median views by publish hour,
# SOLO uploads only -- batched uploads are excluded because they poison every
# bucket they touch:
#
#     05:00 150 | 06:00  94 | 07:00 150 | 08:00  44 | 09:00  72 | 10:00  32
#     11:00   4 | 12:00   2 | 13:00   2 | 15:00   0 | 19:00 159
#
# Per-hour samples are small (n=2-6), so treat this as "stop publishing into
# proven dead hours", not as a precise optimum.
# 2026-08-23 CUT FROM 6 TO 4, operator: "3 or 4" a day.
PUBLISH_SLOTS = [
    (5, 0, "water"),       # 05:00 measured 150; 05:30 straddled 06:00's 94
    (7, 0, "food"),        # 150, joint-best morning hour
    (17, 0, "court"),      # the overhead; unmeasured hour, ramps into the peak
    (19, 0, "face"),       # 159, the best hour -- given to the most productive
                           # camera on the system (helen_food_face, 38 events
                           # on 2026-08-22)
]
SLOT_WINDOW_MIN = _envf("HELEN_MA_SLOT_WINDOW", 45.0)

The station is a preference, not a requirement: if there is no Court visit at 17:00 the run takes the best available and says so in the log. Local time, because the schedule is about when people watch, and the machine is in Eastern.

The other tasks on the channel (Windows Task Scheduler, all as SYSTEM)
TaskWhen · what
HelenCamUploader2every 15 min · helen_multiangle.py --once, the page you are reading
HelenCamDrinkingCompdaily 20:00 · helen_pipeline.py --drinking-comp: every drink of the day as one reel, "The Royal Drinking Hours"
HelenCamBackfillevery 30 min · fills an unconsumed slot from the library when the live path had nothing
HelenPersonPurgeevery 20 min · a second pass of the person check on its own clock, independent of the uploader
HelenCamHealth, HelenTaskGuard, HelenPublishProofdaily · every 5 min · 4×/day: health, task-liveness and publish-proof checks that page the operator instead of failing quietly
HelenLongArcWeekly, HelenSaga, HelenNewsletterWeekly, HelenVoteTallyweekly and daily: the long-arc recap, the season serial, the newsletter, the vote count. Separate scripts, same library.

What it does not do

  • It does not review. A person looks at the posted frames on a phone after the fact and fixes what is wrong. The rules above are what keep that list short; they are not a substitute.
  • It does not know what is funny. The caption model gets a sentence about what she did and a station touch. Everything it writes is short enough to be harmless when it is wrong.
  • It is one machine. Frigate, the recordings, the pipeline and both live streams share a desktop. When Windows Update reboots it, the 15-minute task resumes; a slot missed during the reboot is gone.