{"slug": "i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every", "title": "I redacted my username from a screen recording three times and missed it every time", "summary": "A developer who builds a desktop AI agent discovered that redacting a username from a screen recording is unreliable when verified by eye, as the text leaked in multiple frames. The developer implemented a frequency-domain correlation-based tracking method to follow moving dialog boxes and adjusted thresholds to catch faint text, but ultimately found that a systematic scan of the entire output was necessary to catch all leaks, including one in a browser address bar that persisted for 23 seconds.", "body_md": "*Originally published on my own site.*\n\nI make a desktop AI agent. I recorded a demo of it writing files, and the\n\napproval dialog showed `/Users/<my-username>/Downloads/…`\n\nin plain text.\n\nBlurring it out should have been a five-minute job. **I checked the result by\neye three times and missed a leak every time.** The fix that finally worked was\n\nThe conclusion first: **you cannot verify a redaction by looking at it.** The\n\nscan needs to be as much a part of the pipeline as the blur.\n\nThe naive version: open one frame where the path is visible, measure the box,\n\nand paint over it.\n\n```\ndelogo=x=450:y=300:w=370:h=44:enable='between(t,99.9,102.4)'\n```\n\nThe result had the username visible **for the first one or two seconds**.\n\nmacOS dialogs animate in. They keep moving until they settle.\n\n| time (s) | dialog top | dialog left |\n|---|---|---|\n| 98.4 | 372 | 458 |\n| 98.8 | 230 | 180 |\n| 99.2 | 19 | 462 |\n| 99.6 | 71 | 407 |\n| 100.0 | 94 |\n398 |\n| 102.4 | 94 | 398 |\n\nA box measured at 100.0 s lands nowhere near the frame at 98.4 s — and the text\n\nis legible the whole way in.\n\nYou can sidestep this by trimming each cut to start after the dialog settles.\n\nThat was not available here: the whole point of the video was that it is\n\nuncut. **So the box has to follow the text.**\n\nThe dialog moves, but the font size does not. So take the rendered\n\n`/Users/sa`\n\nfrom a settled frame as a **stencil**, and slide it over every\n\nframe to find the best match.\n\nDone directly, that is 2 million positions × 5,700 pixels per frame. Python\n\nwill not finish. Correlation is a convolution, and convolution is one\n\nmultiplication in the frequency domain.\n\n``` python\ndef correlate(field, kernel):\n    \"\"\"Convolution via FFT. The direct loop does not finish.\"\"\"\n    fh, fw = field.shape\n    F = np.fft.rfft2(field)\n    K = np.fft.rfft2(kernel[::-1, ::-1], s=(fh, fw))\n    out = np.fft.irfft2(F * K, s=(fh, fw))\n    kh, kw = kernel.shape\n    return out[kh - 1:, kw - 1:]        # align the origin to top-left\n```\n\nScore on two things: **misses and over-eager matches.**\n\n``` python\ndef find(img, tmpl):\n    d = dark(img)\n    hit  = correlate(d, tmpl)                    # stencil ink ∩ frame ink\n    load = correlate(d, np.ones_like(tmpl))      # total ink inside the window\n    ink  = tmpl.sum()\n\n    score = hit / (load + 0.35 * ink + 1e-6)\n    score[hit < 0.55 * ink] = 0                  # reject misses outright\n    y, x = np.unravel_index(int(np.argmax(score)), score.shape)\n    return int(x), int(y), float(score[y, x])\n```\n\n`hit`\n\non its own latches onto any dense block of dark pixels — a code excerpt,\n\nfor instance. Dividing by `load`\n\npenalises windows carrying ink the stencil\n\ndoes not account for. The `0.35 * ink`\n\nterm in the denominator keeps the score\n\nfrom exploding in near-empty regions.\n\nFeed the per-frame boxes into `delogo`\n\n's `enable`\n\n. Merge runs at the same\n\nposition, or you end up with 500 filters and the graph stops building.\n\nThe one above. The fixed box does not land.\n\nTracking still leaked. **The dialog appears translucent first.**\n\nMy `dark()`\n\nthreshold was 120, so the faint text on those frames did not count\n\nas ink and tracking never started there. Faint text is still readable text.\n\n``` python\ndef dark(img):\n    # Threshold is 150. At 120 the faint text of the fade-in is dropped,\n    # tracking never starts, and those frames pass through untouched.\n    return (img < 150).astype(np.float32)\n```\n\nI also extend each run **0.3 s earlier and 0.2 s later**.\n\nThis is the one that mattered.\n\nAt the end of the video the agent opens the generated HTML in a browser.\n\n**The address bar showed file:///Users/<my-username>/… for 23 seconds.**\n\nI had been staring at dialogs. **It never occurred to me to look there.**\n\nYou do not find what you did not think to look for.\n\nAt that point I stopped trusting the redaction and started scanning the output.\n\n```\nTEMPLATES = [\n    (100.0, (452, 302, 150, 38)),   # write_file  … larger type\n    (148.7, (850, 512,  90, 28)),   # open_path   … smaller type\n    (152.0, (940,  12, 100, 30)),   # address bar … different again\n]\n```\n\n**One stencil is not enough.** Different dialogs are different widths and set\n\ntheir text at different sizes. With a single stencil the smaller one never\n\nmatched at all and instead latched onto false peaks around 0.30.\n\nSet the verification threshold higher than the tracking one. Measured: real\n\nhits scored 0.64–0.74, false peaks topped out at 0.46, so 0.55 separates them\n\ncleanly. At 0.45 the scan flagged 179 frames of browser iconography and buried\n\nthe real result.\n\nThe scan found **one more leaked frame.**\n\nThe leak was at 113.933… s.\n\nI was tracking at 20 fps, so I looked at 113.90 and 113.95. The output is\n\n30 fps, so the frame that actually ships is 113.933…. **Nobody looked at it.**\n\n```\n# (start, end, frames per second to sample)\nWINDOWS = [\n    (113.4, 118.2, 30),     # animating — sample at the output rate\n    (150.8, 174.8,  5),     # static — coarse is fine\n]\n```\n\nSample the moving stretches at the output rate and leave the static ones\n\ncoarse. Running everything at 30 means 4,000 FFTs and no result.\n\nFinal pass: 2,225 frames scanned, nothing found.\n\nNone of this is needed if the app never puts a username on screen in the first\n\nplace, which is the real fix and the one I shipped. **I kept the scan anyway.**\n\nYou do not find what you did not think to look for.\n\nThe app in the recording is [Wisp](https://rengaworks.gumroad.com/l/wisp) — a\n\ndesktop agent with a 3D body that runs commands and writes files, and shows you\n\nexactly what it is about to do before it does it.", "url": "https://wpnews.pro/news/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every", "canonical_source": "https://dev.to/renga154/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every-time-1gnd", "published_at": "2026-08-14 03:46:41+00:00", "updated_at": "2026-08-14 04:21:35.669602+00:00", "lang": "en", "topics": ["developer-tools"], "entities": [], "alternates": {"html": "https://wpnews.pro/news/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every", "markdown": "https://wpnews.pro/news/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every.md", "text": "https://wpnews.pro/news/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every.txt", "jsonld": "https://wpnews.pro/news/i-redacted-my-username-from-a-screen-recording-three-times-and-missed-it-every.jsonld"}}