Skip to content

feat: improve grouping perf in edgeless#14442

Merged
darkskygit merged 5 commits intocanaryfrom
darksky/edgeless-grouping-perf
Feb 14, 2026
Merged

feat: improve grouping perf in edgeless#14442
darkskygit merged 5 commits intocanaryfrom
darksky/edgeless-grouping-perf

Conversation

@darkskygit
Copy link
Member

@darkskygit darkskygit commented Feb 14, 2026

fix #14433

PR Dependency Tree

This tree was auto-generated by Charcoal

Summary by CodeRabbit

  • New Features

    • Level-of-detail thumbnails for large images.
    • Adaptive pacing for snapping, distribution and other alignment work.
    • RAF coalescer utility to batch high-frequency updates.
    • Operation timing utility to measure synchronous work.
  • Improvements

    • Batch group/ungroup reparenting that preserves element order and selection.
    • Coalesced panning and drag updates to reduce jitter.
    • Connector/group indexing for more reliable updates, deletions and sync.
    • Throttled viewport refresh behavior.
  • Documentation

    • Docs added for RAF coalescer and measureOperation.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 14, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces per-element reparenting with batchAddChildren/batchRemoveChildren, adds fractional-index ordering for group/ungroup, instruments operations with measureOperation and store.transact, introduces RAF coalescers and adaptive throttling for pointer/snap/pan, builds group-child and connector indexes, and adds image LOD thumbnail generation and viewport refresh throttling.

Changes

Cohort / File(s) Summary
Group command orchestration
blocksuite/affine/gfx/group/src/command/group-api.ts
Group/ungroup flows rewritten to use measureOperation, batchRemoveChildren/batchAddChildren, transact mutations, and fractional-index generation (generateKeyBetween/generateNKeysBetween); selection updated to ordered element IDs.
Batch group APIs (models & utils)
blocksuite/affine/model/src/elements/group/group.ts, blocksuite/affine/model/src/blocks/frame/frame-model.ts, blocksuite/framework/std/src/utils/tree.ts, blocksuite/framework/std/src/gfx/index.ts
Adds addChildren/removeChildren on models and exported batchAddChildren/batchRemoveChildren utilities with dedupe/guards; FrameBlockModel.removeChild delegates to removeChildren; re-exports added.
Ordering helpers dependency & tests
blocksuite/affine/gfx/group/package.json, blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts
Adds dependency fractional-indexing; unit tests for ungroupCommand exercise generateKeyBetween/generateNKeysBetween fallback strategies.
Group & surface indexing
blocksuite/framework/std/src/gfx/model/surface/surface-model.ts, blocksuite/affine/blocks/surface/src/surface-model.ts, blocksuite/framework/std/src/gfx/layer.ts
Introduces parent-group and group-child indexes, centralizes element update emission, rebuild/sync helpers, and integrates index maintenance into add/update/delete and deletion flows to keep ordering and parent maps consistent.
Perf & RAF utilities + docs
blocksuite/framework/std/src/gfx/perf.ts, blocksuite/framework/std/src/gfx/raf-coalescer.ts, blocksuite/docs/...
Adds measureOperation (performance marks) and a generic createRafCoalescer/RafCoalescer interface; docs added for both.
Interactivity & pan coalescing
blocksuite/framework/std/src/gfx/interactivity/manager.ts, blocksuite/affine/gfx/pointer/src/tools/pan-tool.ts
Adds RAF-based drag-move coalescing and PanTool delta coalescer: accumulates deltas, schedules via RAF, flushes on drag end/unmount to reduce per-event work.
Adaptive snapping & cooldowns
blocksuite/affine/gfx/pointer/src/snap/adaptive-load-controller.ts, blocksuite/affine/gfx/pointer/src/snap/snap-manager.ts, blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts
Adds AdaptiveStrideController and AdaptiveCooldownController; integrates stride-based skipping and cooldown gating into snap alignment/distribute flows and reports measured costs to controllers.
Pointer tests & configs
blocksuite/affine/gfx/pointer/src/__tests__/*, blocksuite/affine/gfx/pointer/vitest.config.ts, blocksuite/affine/gfx/group/vitest.config.ts, blocksuite/affine/gfx/group/vitest.config.ts
Adds unit tests for adaptive controllers and PanTool; vitest configs added for pointer and group packages.
Image LOD handling
blocksuite/affine/blocks/image/src/image-edgeless-block.ts
Adds large-image detection, thumbnail generation/cancellation, viewport-driven LOD decisioning and thumbnail usage in render path (private implementation details).
Viewport refresh throttling
blocksuite/framework/std/src/gfx/viewport-element.ts
Adds throttled viewport refresh logic based on pixel movement, zoom delta, and elapsed time with trailing refresh scheduling.
Connector & watcher updates
blocksuite/affine/gfx/connector/src/connector-watcher.ts, blocksuite/affine/blocks/root/src/edgeless/configs/toolbar/misc.ts
Connector watcher teardown clears pending flags/lists; toolbar code replaced direct add/remove with batch helpers and guards.
Tests for framework tree & surface
blocksuite/framework/std/src/__tests__/gfx/tree.unit.spec.ts, blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts, blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts
Adds tests for tree utilities (batch ops, getTopElements, descendant handling) and surface group-index/cache behavior and connector endpoint updates.

Sequence Diagram(s)

mermaid
sequenceDiagram
participant UI as Client
participant Cmd as GroupCommand
participant Perf as measureOperation
participant Batch as batchAddChildren/batchRemoveChildren
participant Store as std.store.transact
participant Model as GroupModel
participant Sel as Selection

UI->>Cmd: createGroupFromSelectedCommand()
Cmd->>Perf: measureOperation("createGroup", fn)
Perf->>Batch: batchRemoveChildren(originalParent, elements)
Batch->>Store: transact(remove children)
Perf->>Model: create group and set child indices
Model->>Batch: batchAddChildren(newParent, [newGroup])
Batch->>Store: transact(add group)
Perf->>Sel: update selection to newGroupId
Perf-->>Cmd: done
Cmd-->>UI: selection updated

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Poem

🐰 I nibble on batches, tidy and bright,

I stitch many children into order at night.
I hop with raf and time each small chore,
Throttle and thumbnail — I hustle, then soar.
Hooray for neat rabbits who batch and restore! 🥕

🚥 Pre-merge checks | ✅ 5 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat: improve grouping perf in edgeless' is clear, concise, and directly reflects the main objective of addressing grouping and ungrouping performance issues in Edgeless mode.
Linked Issues check ✅ Passed The PR addresses all primary requirements from #14433: batch operations reduce per-element overhead, adaptive controls throttle expensive alignment/distribution, LOD image handling reduces render overhead, and connector indexing optimizes lookup performance for high element counts.
Out of Scope Changes check ✅ Passed All changes are scoped to the stated objective: performance optimization via batch grouping operations, adaptive cost-based throttling, LOD image handling, and optimized indexing—no unrelated feature additions or refactoring detected.
Merge Conflict Detection ✅ Passed ✅ No merge conflicts detected when merging into canary

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch darksky/edgeless-grouping-perf

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov
Copy link

codecov bot commented Feb 14, 2026

Codecov Report

❌ Patch coverage is 66.97966% with 211 lines in your changes missing coverage. Please review.
✅ Project coverage is 53.93%. Comparing base (c0694c5) to head (1a32e4f).
⚠️ Report is 1 commits behind head on canary.

Files with missing lines Patch % Lines
...te/affine/blocks/image/src/image-edgeless-block.ts 0.00% 89 Missing ⚠️
...ocksuite/affine/gfx/group/src/command/group-api.ts 60.00% 27 Missing and 5 partials ⚠️
...amework/std/src/gfx/model/surface/surface-model.ts 74.48% 14 Missing and 11 partials ⚠️
...cksuite/affine/blocks/surface/src/surface-model.ts 80.26% 7 Missing and 8 partials ⚠️
...ksuite/affine/gfx/pointer/src/snap/snap-overlay.ts 54.54% 7 Missing and 3 partials ⚠️
...suite/affine/model/src/blocks/frame/frame-model.ts 0.00% 8 Missing ⚠️
blocksuite/framework/std/src/gfx/layer.ts 87.27% 2 Missing and 5 partials ⚠️
blocksuite/framework/std/src/utils/tree.ts 76.66% 3 Missing and 4 partials ⚠️
...e/blocks/root/src/edgeless/configs/toolbar/misc.ts 0.00% 6 Missing ⚠️
blocksuite/framework/std/src/gfx/raf-coalescer.ts 91.42% 3 Missing ⚠️
... and 5 more
Additional details and impacted files
@@            Coverage Diff             @@
##           canary   #14442      +/-   ##
==========================================
+ Coverage   53.88%   53.93%   +0.05%     
==========================================
  Files        2832     2835       +3     
  Lines      153319   153853     +534     
  Branches    22942    23065     +123     
==========================================
+ Hits        82615    82984     +369     
- Misses      67291    67458     +167     
+ Partials     3413     3411       -2     
Flag Coverage Δ
server-test 75.24% <ø> (-0.01%) ⬇️
unittest 33.74% <66.82%> (+0.22%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@github-actions github-actions bot added the docs Improvements or additions to documentation label Feb 14, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Fix all issues with AI agents
In `@blocksuite/framework/std/src/gfx/layer.ts`:
- Line 104: When unmounted() currently disposes subscriptions it fails to clear
the cached map _groupChildSnapshot, so leftover state causes incorrect deltas on
re-mount; update the unmounted() method to call this._groupChildSnapshot.clear()
after (or alongside) the existing disposable cleanup so the snapshot is reset
before a subsequent mounted() call.

In `@blocksuite/framework/std/src/gfx/viewport-element.ts`:
- Around line 126-161: _refreshViewportByViewportUpdate currently compares the
incoming update only to the previous update and can drop a final small-movement
update when no further events arrive; add a deferred guaranteed refresh:
introduce a timer field (e.g. _deferredViewportRefreshTimer) and on every call
to _refreshViewportByViewportUpdate clear that timer, then if the refresh
conditions are met do the immediate refresh as now and clear the timer,
otherwise schedule the timer via setTimeout to call this._refreshViewport()
after GfxViewportElement.VIEWPORT_REFRESH_MAX_INTERVAL (and update
_lastViewportRefreshTime inside that callback), ensuring the timeout is canceled
by subsequent updates so a final refresh runs when movement stops.
🧹 Nitpick comments (14)
blocksuite/affine/gfx/pointer/src/snap/snap-overlay.ts (2)

687-705: Short-circuit in shouldTryDistribute prevents cooldown tick-down when candidates are over the limit.

Due to && short-circuiting, when all.length > DISTRIBUTE_ALIGN_MAX_CANDIDATES, shouldRun() is never called and _remainingFrames won't decrement. This means the cooldown can persist longer than DISTRIBUTE_ALIGN_COOLDOWN_FRAMES actual align calls if the candidate count fluctuates above/below the threshold.

With DISTRIBUTE_ALIGN_COOLDOWN_FRAMES = 2, the practical impact is negligible — at most one extra skipped frame. If you want the cooldown to always tick down regardless, swap the evaluation order or always call shouldRun():

Optional: always tick cooldown
-    const shouldTryDistribute =
-      this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
-      this._distributeCooldown.shouldRun();
+    const cooldownReady = this._distributeCooldown.shouldRun();
+    const shouldTryDistribute =
+      this._referenceBounds.all.length <= DISTRIBUTE_ALIGN_MAX_CANDIDATES &&
+      cooldownReady;

40-42: Candidate limit constant is a reasonable heuristic.

DISTRIBUTE_ALIGN_MAX_CANDIDATES = 160 bounds the O(n²) distribute-alignment loops. Depending on typical scene sizes, this could be worth making configurable or documenting why 160 was chosen (e.g., empirical profiling). Not blocking.

blocksuite/affine/blocks/surface/src/surface-model.ts (1)

61-68: Consider using instanceof or a more reliable type guard.

The duck-typing check ('type' in model && model.type === 'connector') will match any object with a type property equal to 'connector'. Since getElementById returns models from a known registry, instanceof ConnectorElementModel would be safer and more maintainable. If there's a specific reason instanceof doesn't work here (e.g., cross-realm or module duplication), a brief comment would help future readers.

blocksuite/affine/blocks/image/src/image-edgeless-block.ts (4)

277-309: Side-effects during render may cause extra re-render cycles.

_resetLodSource(blobUrl) (line 280) and _ensureLodThumbnail(blobUrl) (line 305) perform state mutations and kick off async work inside renderGfxBlock. While the guards prevent infinite loops, the async thumbnail generation calls this.requestUpdate() on completion (line 214), which can trigger another render. Additionally, _lastShouldUseLod is written both here (line 307) and in _updateLodFromViewport (line 236), so each path can trigger a redundant update from the other.

Consider moving the LOD orchestration logic entirely into reactive callbacks (e.g., the blobUrl$ subscription and viewport subscription already in connectedCallback) and have renderGfxBlock simply read the current _lodThumbnailUrl state without mutating it.


162-183: Consider JPEG format for smaller LOD thumbnails.

canvas.toBlob(resolve) defaults to PNG. At 256×256, a JPEG thumbnail at moderate quality would be significantly smaller in memory, which matters since the goal is performance improvement for large images.

♻️ Suggested change
-    return new Promise<Blob | null>(resolve => {
-      canvas.toBlob(resolve);
-    });
+    return new Promise<Blob | null>(resolve => {
+      canvas.toBlob(resolve, 'image/jpeg', 0.7);
+    });

259-267: _lastShouldUseLod initialized before blobUrl is likely resolved.

At line 266, this._shouldUseLod(this.blobUrl) is called synchronously in connectedCallback. Since blobUrl comes from resourceController.blobUrl$, which is populated asynchronously after refreshData() / subscribe(), this will almost always evaluate to false at this point. This isn't a bug (the viewport subscription will correct it later), but makes the initialization misleading. Consider removing it or adding a subscription on blobUrl$ to properly react when the URL becomes available.


152-160: Thumbnail generation loads the full-resolution image on the main thread.

_createImageElement decodes the full source image to produce a 256px thumbnail. For large images (≥1 MP / ≥1 MB), this defeats the purpose during the decoding step. Consider using createImageBitmap with resizeWidth/resizeHeight options, which lets the browser decode directly to the target size without materializing the full-resolution bitmap. This is well-supported (Chrome 54+, Firefox 98+, Safari 15+, Edge 79+). You could consolidate _createImageElement and _createThumbnailBlob into a single method that takes the blobUrl directly, fetches the blob, resizes via createImageBitmap, and renders to OffscreenCanvas.

blocksuite/framework/std/src/gfx/raf-coalescer.ts (1)

63-66: Nit: flush calls cancelFrame even when rafId is null.

Unlike cancel() which guards if (rafId !== null), flush unconditionally calls cancelFrame(rafId). While browsers handle cancelAnimationFrame(null) / clearTimeout(null) gracefully, adding the same guard would be more consistent.

♻️ Suggested change
     flush() {
-      if (rafId !== null) cancelFrame(rafId);
+      if (rafId !== null) {
+        cancelFrame(rafId);
+        rafId = null;
+      }
       run();
     },
blocksuite/framework/std/src/gfx/viewport-element.ts (1)

44-46: Consider documenting the rationale for the magic numbers.

VIEWPORT_REFRESH_PIXEL_THRESHOLD = 18 and VIEWPORT_REFRESH_MAX_INTERVAL = 120 are tuning constants that affect perceived responsiveness. A brief inline comment explaining why these values were chosen (e.g., "18 px ≈ smallest perceptible block-edge shift" or "120 ms ≈ ~8 fps cap for block visibility") would help future maintainers.

blocksuite/framework/std/src/gfx/layer.ts (1)

529-550: O(n × m) refresh for group children — acceptable for user-initiated ops but worth noting.

_refreshElementsInLayer does three passes over uniqueElements, each calling removeFromOrderedArray / insertToOrderedArray / _removeFromLayer / _insertIntoLayer — all of which scan the full canvasElements, blocks, or layers arrays. For a group with k children in a canvas of m total elements this is O(k × m) per pass.

This is fine for typical user-initiated group/ungroup operations, but could become a bottleneck if called during bulk operations with hundreds of elements. If this proves hot, a batched rebuild (similar to _initLayers) would be more efficient.

blocksuite/affine/gfx/group/src/command/group-api.ts (1)

183-195: Naming inversion between beforeSiblingIndex/afterSiblingIndex and afterIndex/beforeIndex parameters is confusing.

beforeSiblingIndex (the sibling before the group, i.e., lower index) is passed as afterIndex, and afterSiblingIndex (the sibling after the group, i.e., upper index) is passed as beforeIndex. The semantics are correct (lower bound / upper bound), but the swapped naming between call site and parameter could easily mislead a future reader.

Consider renaming buildUngroupIndexes parameters to lowerBound / upperBound for clarity.

blocksuite/framework/std/src/gfx/model/surface/surface-model.ts (2)

591-598: Minor redundancy: _rebuildGroupChildrenIndex re-does work from per-element _syncGroupChildrenIndex calls.

During _initElementModels, each group model triggers _syncGroupChildrenIndex (lines 326, 441-442). Then _rebuildGroupChildrenIndex at line 445 clears everything and rebuilds from scratch. The rebuild ensures consistency, but the prior per-element calls are wasted work. Consider moving the _syncGroupChildrenIndex calls inside the rebuild, or removing the early per-element calls.

Also applies to: 445-446


636-654: _syncGroupChildrenIndex silently overwrites parent mappings for contested children.

If child X is listed in two different groups (a data integrity issue), the second group's _syncGroupChildrenIndex call will overwrite the parent mapping for X, and the first group's entry won't be corrected. This is acceptable since multi-parent membership shouldn't happen, but a debug warning could help surface data corruption.

blocksuite/framework/std/src/gfx/interactivity/manager.ts (1)

62-71: Use the existing isGfxGroupCompatibleModel helper for container checks instead of duck-typing childIds.

The 'childIds' in model check is fragile because it relies on property presence rather than explicit model type marking. isGfxGroupCompatibleModel uses a Symbol marker (gfxGroupCompatibleSymbol) to reliably identify containers, and is already widely used throughout the codebase for this purpose.

Proposed improvement
+import { isGfxGroupCompatibleModel } from '../model/base.js';
+
 const shouldAllowDragMoveCoalescing = (
   elements: { model: GfxModel }[]
 ): boolean => {
   return elements.every(({ model }) => {
     const isConnector = 'type' in model && model.type === 'connector';
-    const isContainer = 'childIds' in model;
+    const isContainer = isGfxGroupCompatibleModel(model);
 
     return !isConnector && !isContainer;
   });
 };

@github-actions github-actions bot added the test Related to test cases label Feb 14, 2026
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts`:
- Around line 402-406: The test contains a vacuous assertion:
_parentGroupMap.set(shapeId, 'stale-group-id') stores 'stale-group-id' as a
value, but the assertion uses _parentGroupMap.has('stale-group-id') (which
checks keys), so it never meaningfully verifies cache cleanup; fix by either
removing that assertion or replacing it with a proper check—e.g., after calling
surfaceModel.getGroup(shapeId) assert that model._parentGroupMap.get(shapeId)
!== 'stale-group-id' or assert that the entry for shapeId equals fakeGroup.id
(use symbols _parentGroupMap, shapeId, surfaceModel.getGroup, and fakeGroup.id
to locate the code).
🧹 Nitpick comments (5)
blocksuite/integration-test/src/__tests__/edgeless/surface-model.spec.ts (1)

276-299: Consider exposing a test-only seam instead of as any casts on private fields.

Accessing _connectorIdsByEndpoint and _connectorEndpoints via as any couples this test to internal field names. If those maps are renamed or restructured, this test silently breaks without a compile error. A lightweight alternative: expose a package-internal helper (e.g., /** @internal */ _seedEndpointCacheForTest(...)) or use a friend-pattern so TypeScript can still catch renames.

That said, the test logic itself is correct and the purge assertions are valuable.

blocksuite/framework/std/src/__tests__/gfx/surface.unit.spec.ts (1)

359-417: Tests access private internals via as any — acceptable for white-box cache tests but fragile.

These tests directly manipulate private maps (_parentGroupMap, _groupChildIdsMap, _groupLikeModels) and call private methods. This is fine for verifying internal cache invariants, but any internal rename will silently break these tests at runtime rather than at compile time. A brief comment noting the intentional coupling to internals would help future maintainers.

blocksuite/affine/gfx/pointer/src/__tests__/pan-tool.unit.spec.ts (1)

14-32: Consider cleaning up the unused getCallback helper.

mockRaf returns getCallback (Line 28) which is never used in any of the three tests. If it's not needed for future tests in this file, removing it reduces noise.

blocksuite/affine/gfx/group/src/__tests__/group-api.unit.spec.ts (2)

35-84: Fixture uses identical indices for left/right siblings — intentional but worth a brief comment.

Both left and right are created with index 'a0' (Lines 37–38), which deliberately triggers the "invalid interval" fallback in buildUngroupIndexes. This is the core setup for Test 1 but could confuse readers unfamiliar with the fallback logic. A short inline comment (e.g., // same index to trigger invalid-interval fallback) would clarify intent.


130-151: Test asserts exact call count (4) for internal retry strategies — tightly coupled to implementation.

expect(mockedGenerateNKeysBetween).toHaveBeenCalledTimes(4) (Line 147) couples this test to the exact number of batch fallback strategies inside buildUngroupIndexes. If a new strategy is added or one is removed, this test breaks without any behavioral change. Consider asserting that the key-by-key fallback was reached (i.e., generateKeyBetween was called with expected args) rather than pinning the batch attempt count.

@darkskygit darkskygit merged commit 25227a0 into canary Feb 14, 2026
59 checks passed
@darkskygit darkskygit deleted the darksky/edgeless-grouping-perf branch February 14, 2026 19:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs Improvements or additions to documentation test Related to test cases

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Edgless board lagging when 3000 pictures involved

1 participant