Skip to content

Accept step output images in video-state blocks so they work in embedded workflows - #3296

Merged
dkosowski87 merged 5 commits into
mainfrom
claude/embedded-workflows-blocks-85c33f
Oct 30, 2026
Merged

dkosowski87 merged 5 commits into
mainfrom
claude/embedded-workflows-blocks-85c33f

Conversation

@hansent

@hansent hansent commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

What

Video-state blocks typed image as WorkflowImageSelector, which only matches $inputs.*. They now use Selector(kind=[IMAGE_KIND]), the same type the newer tracker blocks (trackers/*/v1, byte_tracker@v3, stabilize_detections@v1) already use, so the image can come from a Workflow input or a step output.

Affected blocks (and their _tensor siblings): time_in_zone@v2/@v3, velocity@v1, line_counter@v2, path_deviation_analytics@v2, detection_event_log@v1, byte_tracker@v2, cache_get@v1, cache_set@v1, delta_filter@v1, and rate_limiter@v1 (video_reference_image).

Why

Before compiling, embedded inner_workflow@v1 steps are inlined, which replaces the child's $inputs.image with the parent's binding. If the parent binds image to a step output (for example a preprocessing block), each of these blocks fails selector validation. The whole Workflow then fails with Could not parse workflow definition, even though both the parent and the child compile on their own.

This took down all 4 GPA Fontana streams (renaissant). The shared inner Workflow started passing $steps.standard_image.image into the nested vehicle-event-cache Workflow, and time_in_zone@v3, velocity@v1, cache_get@v1 and cache_set@v1 rejected it.

Note: the fifth failing block in that Workflow, VehicleLifecycleBuffer, is a customer custom Python block. Its manifest is defined in the customer's Workflow, so this PR doesn't change it.

Compatibility

  • This only loosens validation. Any $inputs.* selector that was accepted before is still accepted.
  • The blocks still read video_metadata from the image. Step outputs built with WorkflowImageData.copy_and_replace (preprocessing, grayscale, etc.) keep it, so per-video state keys don't change. The new test checks this end to end for the cache namespace.
  • No execution engine changes.

Tests

  • New workflows/tests/unit_tests/execution_engine/inner_workflow/test_embedded_video_blocks_with_step_output_image.py:
    • A parent with convert_grayscale → inner_workflow whose child uses every affected block compiles. Without the fix it fails with 11 string_pattern_mismatch errors, the same failure as in production.
    • An embedded cache_get/cache_set round trip with a step-output image keeps a separate cache namespace per video_identifier.
  • cd workflows && pytest tests/unit_tests/execution_engine/inner_workflow tests/unit_tests/core_steps/{cache,analytics,control_flow} → 307 passed. The new test also passes with ENABLE_TENSOR_DATA_REPRESENTATION=True.
  • cd workflows && pytest tests/unit_tests tests/isolation: no new failures. The 75 failures with this change are a subset of the 77 on main in my local env (CPU-only GPU parity tests, missing fastapi, enterprise census); the 2 extra on main are the new tests.
  • black / isort run on the changed files.
  • Not run: the server-side tests/workflows/ suites.

🤖 Generated with Claude Code

Embedded inner workflows are inlined before compilation, which replaces the
child's `$inputs.image` with the parent binding. When that binding is a step
output (e.g. a preprocessing block), blocks typed with WorkflowImageSelector
rejected it and the merged definition failed to parse, although each workflow
compiled on its own. Use Selector(kind=[IMAGE_KIND]) like the newer tracker
blocks so the image can come from an input or a step.

Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

👋 Thanks for the pull request! Here is how automated Claude review works here, so you spend credits (and reviewer time) wisely.

🚦 This PR is marked Ready for review, so automated Claude review will run — and every pass spends real credits.

Warning

💸 The Claude reviewer bills in credits, not vibes

Automated review spins up a real agent that reads real code and spends real credits on every pass. It is glad to help — but it is not a rubber duck, a linter you poke in a loop, or a substitute for reading the contributing guide. Treat it like an expensive senior reviewer whose time you booked, and show up prepared.

Draft when unsure, Ready when you mean it:

  • 🌱 Not sure the PR is in good shape yet? Keep it (or set it back) as a draft — drafts pause review, so you can push and iterate without burning credits on a moving target.
  • 💪 Feel strong about the contents? Mark it Ready for review and the reviewer will take a look.

However you get there, arrive prepared:

  • 🧱 Bring a SOLID, thorough PR. Point your local agent at our skills/ to tune it to our guidelines first — or, if you are one of those fabled carbon-based contributors, read them yourself. A half-baked diff costs exactly the same to review as a finished one.
  • ✅ Resolve every comment before you re-request review. Re-requesting with threads still open means paying twice for the same conversation.
  • 🔁 Do not use CI review as an inner loop for a local agent. The reviewer is not a step-by-step debugger — do the unfolding locally and arrive with the answer, not the search.
  • 🙋 If something looks off, ask a human. One question to a maintainer is cheaper and faster than three rounds of agent re-review chasing a misread.

Reviews are not free. A draft costs nothing to review; a Ready PR is a promise that it is worth reviewing.

  • Prefer to skip automated review entirely? Add the skip-claude-review label.

@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude review started at commit 5b4d3893758bed5f6a9214297afd094dde870267.

New commits are not auto-reviewed. Add the claude-review label to request a re-review — the label is consumed when the review starts, so just add it again next time.

@github-actions

Copy link
Copy Markdown
Contributor

Claude review summary

Skills: review-workflows-blocks, review-topic-workflow-state-management, review-topic-backward-compat-and-versioning, review-topic-test-hygiene

No blocking findings. What I checked:

  • Contract loosening. WorkflowImageSelector only matched ^\$inputs.…$. Selector(kind=[IMAGE_KIND]) matches that pattern and also $steps.<name>.<output>, so every manifest that was valid before is still valid. The kind is still image. The selector's batch flag changes from True to "dynamic", which is the same as the existing byte_tracker@v3, trackers/* and stabilize_detections@v1 blocks.
  • Completeness and sibling parity. No WorkflowImageSelector usages are left under core_steps/. Each changed numpy block has the same edit in its _tensor sibling. cache_get, cache_set, rate_limiter and delta_filter have no tensor siblings, and the IMAGE_KIND/Selector imports they need are present.
  • State keying. The blocks read image.video_metadata. convert_grayscale@v1 (the reproducer) builds its output with WorkflowImageData.copy_and_replace, which keeps _video_metadata, so per-video state keys stay the same.
  • Tests. The new test compiles a parent whose inner_workflow child uses every affected block with a step-output image. It also runs a cache_get/cache_set round trip that checks per-video_identifier namespaces.
  • Changelog. There is an entry under ## Unreleased → ### Fixed in workflows/CHANGELOG.md. The execution engine is not changed.
Minor doubts (non-blocking)
  • A few step outputs build WorkflowImageData from scratch and drop video_metadata: stitch_images@v1, camera_calibration@v1, the grid visualization and stability_ai/image_gen. If one of these feeds a video-state block, the block falls back to parent_id, frame 0, datetime.now() and fps 30. That affects state keys and rate-limiter timing. The newer tracker blocks already allow the same thing, so this PR does not introduce a new class of problem.
  • The new cache test clears the WorkflowMemoryCache class state at the start but does not restore it afterwards.

Commands that informed the review: gh pr diff 3296, gh pr view 3296, the read-only gh api comment/review listings, plus grep/reads of review-source/workflows/roboflow_workflows/execution_engine/entities/{types,base}.py and the changed block manifests.

Reviewed at HEAD: 5b4d389

@github-actions

Copy link
Copy Markdown
Contributor

Release coordination

  • Contributor: the workflows/CHANGELOG.md ## Unreleased entry is present. Nothing else is needed from you.
  • Maintainers: roboflow-workflows needs a version change at release, because block manifests under workflows/roboflow_workflows/core_steps/ changed. The Execution Engine does not need a bump.

Reviewed at HEAD: 5b4d389

@github-actions

Copy link
Copy Markdown
Contributor

😎 PR passes the vibe-check and trust-me-bro verification.

@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Maintainer review requested.

Final approval and merge remain in GitHub.

@dkosowski87 dkosowski87 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two reproduced P2 integration risks in the newly accepted step-output connections.

)
type: Literal["roboflow_core/cache_get@v1"]
image: WorkflowImageSelector = Field(
image: Selector(kind=[IMAGE_KIND]) = Field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep a stable video identity when accepting cropped images

This now allows a static-crop output to feed Cache Get/Set, including through an embedded workflow. However, static cropping generates a new crop ID on every frame, and create_crop appends it to video_metadata.video_identifier. The cache uses that video identifier as its namespace, so successive frames from the same camera and the same crop region cannot read each other's cached values.

flowchart TD
    A["Camera A, frame 1"] --> B["Same static crop: new ID crop-1"]
    B --> C["Cache Set writes under camera-A + crop-1"]
    D["Camera A, frame 2"] --> E["Same static crop: new ID crop-2"]
    E --> F["Cache Get reads camera-A + crop-2"]
    F --> G["Cache miss: previous value is in a different namespace"]
    C -. "Value remains here" .-> G
Loading

I reproduced this by replacing grayscale with absolute_static_crop@v1 in the new embedded-cache test scenario. Three frames from one camera produced three cache misses and three retained namespaces. The grayscale control produced one namespace and hits after the first frame. On a long-running stream, the crop path also keeps adding namespaces until the cache blocks are closed.

The crop behavior predates this PR; the newly enabled connection is what exposes it. Previously this workflow failed selector validation rather than running with disconnected state. Please preserve a stable source-video identity, with a stable crop-region identity where separate crop state is needed, and add a multi-frame regression test. The root image's parent ID alone is not necessarily a camera identity.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 978e5c2. create_crop / create_crop_from_tensor take an optional video_crop_identifier. When it's set, it's used for the | crop: suffix in place of crop_identifier. The absolute and relative static crops pass their clamped pixel box (absolute_static_crop:<x_min>,<y_min>,<x_max>,<y_max>). The source video ID is unchanged, the crop region gets an ID that's the same on every frame, and parent_id is still a unique UUID per crop. Other callers (dynamic crop, slicer) don't change.

Regression test: test_embedded_cache_keeps_one_namespace_per_video_across_frames, parametrized over grayscale, absolute and relative static crop. It runs 3 frames from cam-a and 1 from cam-b, then asserts cache hits after the first frame and exactly one retained namespace per camera. Without the fix, both crop cases fail.

examples=[["$steps.upload"]],
)
video_reference_image: Optional[WorkflowImageSelector] = Field(
video_reference_image: Optional[Selector(kind=[IMAGE_KIND])] = Field(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Keep video time advancing for calibration outputs

This now allows Camera Calibration's output as video_reference_image, including through an embedded workflow. Calibration creates its output without the source video metadata. Reading image.video_metadata then supplies fallback values of frame number 0 and FPS 30. The rate limiter calculates time as frame_number / fps, so every frame appears to be at time zero.

flowchart TD
    A["Video frames with advancing frame numbers"] --> B["Camera Calibration drops video metadata"]
    B --> C["Fallback metadata: frame 0, FPS 30"]
    C --> D["Rate limiter sees time 0 on every frame"]
    D --> E["First frame: allow and save time 0"]
    E --> F["Later frames: elapsed time = 0 - 0"]
    F --> G["Positive cooldown never expires: suppress downstream steps"]
Loading

I reproduced this with frames 1, 3, and 5 at 1 FPS and a one-second cooldown. All three should pass. With calibration, only the first frame reached the downstream Cache Set; the grayscale control passed all three. The system-clock fallback does not help: reading the default metadata succeeds, so the limiter overwrites system time with zero without raising an exception.

The metadata loss predates this PR; previously this connection failed selector validation. It now compiles but silently stops downstream processing after the first frame. Please preserve the original video metadata through calibration, or explicitly handle missing metadata in the limiter, and cover this with a multi-frame regression test.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 978e5c2 by keeping the metadata at the source. Camera Calibration now builds its output with WorkflowImageData.copy_and_replace(origin_image_data=image, numpy_image=dst), so it keeps video_metadata. It also keeps workflow_root_ancestor_metadata, which it used to reset to the input's parent. That's the same pattern grayscale and the other single-image transforms use.

Regression test: test_embedded_rate_limiter_follows_video_time_of_step_output_image, parametrized over grayscale and calibration. It runs frames 1, 2, 3 and 5 at 1 FPS with a 1 s cooldown, and every frame reaches the downstream step. The calibration case fails without the fix.

Not changed here: the rate limiter's fallback still never fires when an image has no video metadata. The video_metadata property synthesizes frame 0 / 30 FPS instead of raising, so the except meant to fall back to wall-clock time never runs. Outputs that build a fresh WorkflowImageData, like stitch_images and grid visualization, still hit that path. I'd fix it in a separate PR, since it also changes how still images behave.

Static crops appended the per-frame crop UUID to video_identifier, so stateful
blocks fed by a crop saw a new video every frame. They now pass a stable region
identifier via create_crop's new video_crop_identifier, keeping a unique
parent_id. Camera Calibration rebuilt its output without video metadata, which
pinned the rate limiter's video time at zero; it now uses copy_and_replace.

Co-Authored-By: Claude <noreply@anthropic.com>
@hansent hansent added the claude-review Use to trigger AI review manually label Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude review started at commit 978e5c248040d1a4381712e5fbf9bf921436eb40.

New commits are not auto-reviewed. Add the claude-review label to request a re-review — the label is consumed when the review starts, so just add it again next time.

@github-actions github-actions Bot removed the claude-review Use to trigger AI review manually label Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Claude review summary (re-review: 5b4d389..978e5c2)

Skills: review-workflows-blocks, review-workflows-execution-engine, review-topic-workflow-state-management, review-topic-backward-compat-and-versioning, review-topic-test-hygiene

No blocking findings. I checked both of @dkosowski87's P2 threads against the code, and both are fixed.

  • Static crop video identity (cache_get/v1.py:101 thread): fixed. WorkflowImageData.create_crop and create_crop_from_tensor (execution_engine/entities/base.py) take an optional video_crop_identifier, and the | crop: suffix uses it when it is set. absolute_static_crop@v1, both branches of its _tensor sibling, and relative_statoic_crop@v1 pass <block>:<x_min>,<y_min>,<x_max>,<y_max>, which is the same on every frame. parent_id is still a per-frame uuid4. Dynamic crop, slicer and other callers don't pass the argument, so they behave as before. The existing static-crop unit tests (startswith("some") and != "some") still hold. The new parametrized test covers 3 frames from cam-a plus 1 from cam-b and checks that later frames hit the cache and that there are exactly 2 namespaces.
  • Camera Calibration drops video metadata (rate_limiter/v1.py:115 thread): fixed. Both return paths in remove_distortions now use WorkflowImageData.copy_and_replace(origin_image_data=image, numpy_image=dst). That carries over _video_metadata and _workflow_root_ancestor_metadata. test_embedded_rate_limiter_follows_video_time_of_step_output_image runs frames 1, 2, 3 and 5 at 1 FPS with a 1 s cooldown and checks that every frame passes.
  • Execution engine. The base.py change adds an optional keyword argument. The default (None) keeps the old crop_identifier suffix. It doesn't change compilation, scheduling or input assembly, so it is a block-level behavior change, and no ### Execution engine entry or engine bump is needed. The ### Fixed changelog entries describe both fixes. The earlier release-coordination notice still applies: roboflow-workflows needs a version change at release.
Minor doubts (non-blocking)
  • Camera Calibration now keeps the input's workflow_root_ancestor_metadata. Before, it was reset to the input's parent_metadata. For a calibrated image built from a crop of a crop, downstream predictions now map to the real Workflow root, and root_parent_id changes with it. This is consistent with the other single-image transforms and the changelog mentions it, but an integration test with a crop as input would pin the behavior.
  • The rate limiter still never falls back to wall-clock time for images that have no video metadata, because the video_metadata property synthesizes frame 0 at 30 FPS instead of raising. The contributor has explicitly deferred this to a separate PR. It still affects stitch_images and grid visualization outputs.

Commands that informed the review: gh pr diff 3296, gh pr view 3296, read-only gh api listings of issue comments, review comments, reviews and commits (paginated), plus reads and greps of review-source/workflows/roboflow_workflows/execution_engine/entities/base.py, the static-crop and camera-calibration blocks, the new tests, and the existing static-crop and camera-calibration tests.

Reviewed at HEAD: 978e5c2

@github-actions

Copy link
Copy Markdown
Contributor

😎 PR passes the vibe-check and trust-me-bro verification.

@dkosowski87
dkosowski87 merged commit ae5360a into main Oct 30, 2026
29 of 38 checks passed
@dkosowski87
dkosowski87 deleted the claude/embedded-workflows-blocks-85c33f branch October 30, 2026 16:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants