Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions fixtures/tooltip-blip.reference.expected.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{"decision":"emit","elapsedMs":0,"reason":"prime","score":144.000000,"seq":1}
{"decision":"skip","elapsedMs":500,"score":0.000000,"seq":2}
{"decision":"skip","elapsedMs":1000,"score":0.000000,"seq":3}
{"decision":"debounced","elapsedMs":1500,"score":12.350000,"seq":4}
{"decision":"debounced","elapsedMs":2000,"score":11.700000,"seq":5}
{"decision":"debounced","elapsedMs":2500,"score":11.700000,"seq":6}
{"decision":"skip","elapsedMs":3000,"score":0.000000,"seq":7}
{"decision":"skip","elapsedMs":3500,"score":0.000000,"seq":8}
{"decision":"skip","elapsedMs":4000,"score":0.000000,"seq":9}
{"decision":"skip","elapsedMs":4500,"score":0.000000,"seq":10}
Binary file added fixtures/tooltip-blip/000001.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000002.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000003.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000004.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000005.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000006.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000007.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000008.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000009.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added fixtures/tooltip-blip/000010.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 8 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions packages/adapters/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
Notable changes to `@framesieve/adapters`. Pre-1.0 the API may change
between minor versions.

## 0.3.0

- Requires `framesieve` `^0.4.0` (adds the opt-in reference diff mode in
core). No adapter API changes; recorder and replay pass through
`diff.mode` like any other gate option.

## 0.2.0

- The node recorder now observes the gate via `gate.tap` instead of
Expand Down
4 changes: 2 additions & 2 deletions packages/adapters/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@framesieve/adapters",
"version": "0.2.0",
"version": "0.3.0",
"description": "Capture sources (electron, browser, node) plus recorder and replay for framesieve.",
"license": "Apache-2.0",
"type": "module",
Expand Down Expand Up @@ -54,6 +54,6 @@
"access": "public"
},
"dependencies": {
"framesieve": "^0.3.0"
"framesieve": "^0.4.0"
}
}
6 changes: 6 additions & 0 deletions packages/cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
Notable changes to `@framesieve/cli`. Pre-1.0 the CLI surface may
change between minor versions.

## 0.2.0

- Requires `framesieve` `^0.4.0` and `@framesieve/adapters` `^0.3.0`
(core adds the opt-in reference diff mode). `fsieve replay --sweep`
can vary `diff.mode` like any other gate option; no new CLI flags.

## 0.1.1

- `--algorithm` now accepts `edge` (Sobel edge diff) in addition to
Expand Down
6 changes: 3 additions & 3 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@framesieve/cli",
"version": "0.1.1",
"version": "0.2.0",
"description": "fsieve command: replay recordings and sweep gate parameters offline.",
"license": "Apache-2.0",
"type": "module",
Expand All @@ -23,7 +23,7 @@
"access": "public"
},
"dependencies": {
"@framesieve/adapters": "^0.2.0",
"framesieve": "^0.3.0"
"@framesieve/adapters": "^0.3.0",
"framesieve": "^0.4.0"
}
}
16 changes: 16 additions & 0 deletions packages/core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,22 @@
Notable changes to `framesieve`. Pre-1.0 the API may change between
minor versions; breaking changes are called out here.

## 0.4.0

- New opt-in `diff.mode: "reference"` (default stays `"previous"`):
compares each frame against the last emitted frame instead of the
previous one, so a transient change that reverts to that baseline is
dropped and only a change that persists for `policy.referencePersistMs`
is emitted. The adaptive mask keeps down-weighting chronically moving
regions (it now keys off frame-to-frame motion), so reference mode
composes with busy-region suppression. It is a temporal filter, not a
semantic one: whether a persistent change matters is the caller's call.
The default `"previous"` mode decides identically to 0.3.0 - existing
recordings and fixtures are byte-for-byte unchanged.
- New `policy.referencePersistMs` (default 3000): the minimum time a
divergence from the last emitted frame must persist before it emits in
reference mode; ignored in the default previous mode.

## 0.3.0

- New `gate.tap(observer)`: a synchronous observer of (frame, decision)
Expand Down
36 changes: 31 additions & 5 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,37 @@ during long silence, and static ignore regions. Set
(reason "prime") when an observer needs the current state right away
instead of waiting for the stream to settle.

By default the gate compares downsampled luma. Set `diff.algorithm:
"edge"` to compare Sobel edge maps instead: a theme or brightness color
shift (unchanged gradients) is ignored, while text and contour changes
still register. Compare it against your own recording with `fsieve
replay <dir> --algorithm edge`.
Stage 1 exposes two independent choices - *what to measure* and *what to
compare it against* - and they combine freely (any algorithm with either
mode):

**What to measure (`diff.algorithm`).** By default the gate compares
downsampled luma. Set `diff.algorithm: "edge"` to compare Sobel edge maps
instead: a theme or brightness color shift (unchanged gradients) is
ignored, while text and contour changes still register. Compare it
against your own recording with `fsieve replay <dir> --algorithm edge`.

**What to compare against (`diff.mode`).** By default the gate compares
each frame against the immediately previous one (`diff.mode: "previous"`).
Set `diff.mode: "reference"` to compare
against the last emitted frame instead: a change that appears and then
reverts to that baseline before it persists is dropped as transient (a
hover tooltip that comes and goes), while a change that stays for
`policy.referencePersistMs` (default 3000) is emitted and becomes the new
baseline (text typed, a panel that opens and stays). In `"previous"` mode
a transient registers twice - once when it appears and once when it
disappears; `"reference"` mode registers neither. The adaptive mask still
down-weights chronically moving regions in either mode (it keys off
frame-to-frame motion), so a playing video does not defeat the
persistence check.

`"reference"` mode is a temporal filter, not a semantic one: it decides
whether a change stuck, never whether it matters. Judging importance (is
this persistent tooltip worth a model call?) is the caller's or the
downstream model's job - framesieve never makes that call. In
`"reference"` mode `policy.referencePersistMs` is the persistence window;
`policy.debounceMs` keeps its "settle after motion" meaning in the
default `"previous"` mode.

## Tuning without guesswork: record and replay

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "framesieve",
"version": "0.3.0",
"version": "0.4.0",
"description": "Deterministic frame gating for vision AI. Decides which captured frames are worth sending to a VLM.",
"license": "Apache-2.0",
"type": "module",
Expand Down
46 changes: 32 additions & 14 deletions packages/core/src/blocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,43 +47,61 @@ export class BlockGrid {
this.historySum = new Uint32Array(this.cols * this.rows);
}

step(cm: ChangeMask): BlockJudgment {
const { mask, w, h } = cm;
/**
* `score` gates the emit (changed vs the comparison baseline);
* `motion` (changed vs the previous frame) drives the adaptive
* weight, so a chronically moving region is down-weighted even in
* reference mode where a held overlay stays diverged from the
* baseline. In "previous" mode the two masks are the same buffer and
* this reduces exactly to the single-mask behavior.
*/
step(score: ChangeMask, motion: ChangeMask = score): BlockJudgment {
const { mask: sMask, w, h } = score;
const mMask = motion.mask;
const sameMask = mMask === sMask;
const changedBlocks: BlockChange[] = [];
let score = 0;
let scoreSum = 0;
for (let row = 0; row < this.rows; row++) {
const y0 = Math.floor((row * h) / this.rows);
const y1 = Math.floor(((row + 1) * h) / this.rows);
for (let col = 0; col < this.cols; col++) {
const x0 = Math.floor((col * w) / this.cols);
const x1 = Math.floor(((col + 1) * w) / this.cols);
const total = (x1 - x0) * (y1 - y0);
let changed = 0;
let sChanged = 0;
let mChanged = 0;
for (let y = y0; y < y1; y++) {
const base = y * w;
for (let x = x0; x < x1; x++) {
changed += mask[base + x]!;
sChanged += sMask[base + x]!;
if (!sameMask) mChanged += mMask[base + x]!;
}
}
const ratio = total === 0 ? 0 : changed / total;
const isChanged = changed > 0 && ratio >= this.ratioThreshold;
if (sameMask) mChanged = sChanged;
const sRatio = total === 0 ? 0 : sChanged / total;
const mRatio = total === 0 ? 0 : mChanged / total;
const isChangedScore = sChanged > 0 && sRatio >= this.ratioThreshold;
const isChangedMotion = mChanged > 0 && mRatio >= this.ratioThreshold;
const block = row * this.cols + col;
if (isChanged) {
if (isChangedScore) {
const weight = this.adaptive
? 1 - this.historySum[block]! / this.windowSize
: 1;
score += weight;
changedBlocks.push({ col, row, ratio, weight });
scoreSum += weight;
changedBlocks.push({ col, row, ratio: sRatio, weight });
}
// Update history after the weight for this frame is taken.
// Update history (drives the weight) from MOTION, after the
// weight for this frame is taken.
const idx = block * this.windowSize + this.slot;
this.historySum[block] =
this.historySum[block]! - this.history[idx]! + (isChanged ? 1 : 0);
this.history[idx] = isChanged ? 1 : 0;
this.historySum[block]! -
this.history[idx]! +
(isChangedMotion ? 1 : 0);
this.history[idx] = isChangedMotion ? 1 : 0;
}
}
this.slot = (this.slot + 1) % this.windowSize;
return { score, changedBlocks };
return { score: scoreSum, changedBlocks };
}

reset(): void {
Expand Down
14 changes: 14 additions & 0 deletions packages/core/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
BlockOptions,
CropOptions,
DiffAlgorithm,
DiffMode,
DiffOptions,
EmitTransform,
FrameGateOptions,
Expand All @@ -28,6 +29,8 @@ const DIFF_ALGORITHMS: readonly DiffAlgorithm[] = [
"edge",
];

const DIFF_MODES: readonly DiffMode[] = ["previous", "reference"];

function checkInt(name: string, value: number, min: number): number {
if (!Number.isInteger(value) || value < min) {
throw new RangeError(`${name} must be an integer >= ${min}, got ${value}`);
Expand Down Expand Up @@ -62,6 +65,10 @@ export function resolveOptions(options: FrameGateOptions = {}): ResolvedOptions
if (!DIFF_ALGORITHMS.includes(algorithm)) {
throw new RangeError(`unknown diff algorithm: ${String(algorithm)}`);
}
const mode = options.diff?.mode ?? "previous";
if (!DIFF_MODES.includes(mode)) {
throw new RangeError(`unknown diff mode: ${String(mode)}`);
}
const onNonMonotonic = options.policy?.onNonMonotonic ?? "throw";
if (onNonMonotonic !== "throw" && onNonMonotonic !== "clamp") {
throw new RangeError(
Expand All @@ -71,6 +78,7 @@ export function resolveOptions(options: FrameGateOptions = {}): ResolvedOptions
return {
diff: {
algorithm,
mode,
downsampleFactor: checkInt(
"diff.downsampleFactor",
options.diff?.downsampleFactor ?? 8,
Expand Down Expand Up @@ -127,6 +135,12 @@ export function resolveOptions(options: FrameGateOptions = {}): ResolvedOptions
Number.MAX_SAFE_INTEGER,
),
primeOnFirstFrame: options.policy?.primeOnFirstFrame ?? false,
referencePersistMs: checkNumber(
"policy.referencePersistMs",
options.policy?.referencePersistMs ?? 3000,
0,
Number.MAX_SAFE_INTEGER,
),
onNonMonotonic,
ignoreRegions: (options.policy?.ignoreRegions ?? []).map((r, i) =>
checkRegion(`policy.ignoreRegions[${i}]`, r),
Expand Down
Loading
Loading