diff --git a/figma-transformer/src/Scenegraph/ScenegraphFrameInspector.php b/figma-transformer/src/Scenegraph/ScenegraphFrameInspector.php index 5e5921f..001386d 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphFrameInspector.php +++ b/figma-transformer/src/Scenegraph/ScenegraphFrameInspector.php @@ -262,6 +262,73 @@ private function deviceHint(string $name, array $dimensions): string return 'unknown'; } + /** + * Memory-efficient responsive detection over a pre-extracted set of + * frame-level candidates, with NO {@see ScenegraphIndex} build. + * + * Responsive grouping is a frame-level question: it only needs the set of + * page-candidate FRAMEs and a few light attributes (name, width, height, + * device hint, and the page/section/parent ids that scope name-similarity + * relationships). It does NOT need an index of every descendant node. The + * planner already holds those frame attributes in memory after its single + * index build, so it hands them here instead of forcing this inspector to + * rebuild a second whole-scenegraph index — the build that OOMed on the + * 293MB "WP.Cloud 2.0" .fig and made #265 skip detection above a node + * ceiling. The returned detection keeps the planner's contract intact + * (`device_hint`, `sibling_group_key`, `responsive_siblings`) keyed by + * frame id, so grouping behaves identically — just without the memory cost + * of a full-node index. + * + * @param array $frames + * @return array> + */ + public function detectResponsiveFrames(array $frames): array + { + $candidates = array(); + foreach ( $frames as $frame ) { + if ( ! is_array($frame) ) { + continue; + } + + $id = isset($frame['id']) && is_scalar($frame['id']) ? (string) $frame['id'] : ''; + if ( '' === $id ) { + continue; + } + + $name = (string) ($frame['name'] ?? ''); + $dimensions = array( + 'width' => is_numeric($frame['width'] ?? null) ? (float) $frame['width'] : null, + 'height' => is_numeric($frame['height'] ?? null) ? (float) $frame['height'] : null, + ); + $pageId = isset($frame['page_id']) && is_scalar($frame['page_id']) && '' !== (string) $frame['page_id'] ? (string) $frame['page_id'] : null; + $sectionId = isset($frame['section_id']) && is_scalar($frame['section_id']) && '' !== (string) $frame['section_id'] ? (string) $frame['section_id'] : null; + $parentId = isset($frame['parent_id']) && is_scalar($frame['parent_id']) && '' !== (string) $frame['parent_id'] ? (string) $frame['parent_id'] : null; + + $candidates[$id] = array_filter( + array( + 'id' => $id, + 'name' => $name, + 'width' => $dimensions['width'], + 'height' => $dimensions['height'], + 'page' => null !== $pageId ? array('id' => $pageId) : null, + 'section' => null !== $sectionId ? array('id' => $sectionId) : null, + 'parent' => null !== $parentId ? array('id' => $parentId) : null, + 'device_hint' => $this->deviceHint($name, $dimensions), + 'sibling_group_key' => $this->siblingGroupKeyFor($name, $pageId, $sectionId), + ), + static fn (mixed $value): bool => null !== $value + ); + } + + $detection = array(); + foreach ( $candidates as $id => $candidate ) { + $candidate['responsive_siblings'] = $this->responsiveSiblings((string) $id, $candidate, $candidates); + $detection[(string) $id] = $candidate; + } + + return $detection; + } + /** * @param array $node * @param array> $nodes @@ -271,9 +338,22 @@ private function siblingGroupKey(string $id, array $node, array $nodes, array $p { $page = $this->nearestAncestor($id, array('CANVAS'), $nodes, $parentIndex); $section = $this->nearestAncestor($id, array('SECTION'), $nodes, $parentIndex); - $scope = (string) (($section['id'] ?? null) ?: ($page['id'] ?? 'root')); - return $scope . ':' . $this->normalizedPageName((string) ($node['name'] ?? '')); + return $this->siblingGroupKeyFor((string) ($node['name'] ?? ''), $page['id'] ?? null, $section['id'] ?? null); + } + + /** + * Derive a sibling-group key from frame-level data alone (name + scope + * ids), so both the full inspection path and the lightweight + * {@see ScenegraphFrameInspector::detectResponsiveFrames()} path share one + * normalization. Scope prefers the nearest SECTION, then the page (CANVAS), + * then a synthetic root. + */ + private function siblingGroupKeyFor(string $name, ?string $pageId, ?string $sectionId): string + { + $scope = (string) (($sectionId ?? '') !== '' ? $sectionId : (($pageId ?? '') !== '' ? $pageId : 'root')); + + return $scope . ':' . $this->normalizedPageName($name); } /** diff --git a/figma-transformer/src/Scenegraph/ScenegraphPagePlanner.php b/figma-transformer/src/Scenegraph/ScenegraphPagePlanner.php index e505d5f..26c20f3 100644 --- a/figma-transformer/src/Scenegraph/ScenegraphPagePlanner.php +++ b/figma-transformer/src/Scenegraph/ScenegraphPagePlanner.php @@ -10,18 +10,23 @@ final class ScenegraphPagePlanner { /** - * Node-count ceiling for planning-time responsive detection. + * Frame-candidate ceiling for planning-time responsive detection. * - * Detection re-inspects the source, which rebuilds a full - * {@see ScenegraphIndex} over the entire scenegraph. On very large designs - * (e.g. the 293MB "WP.Cloud 2.0" .fig) that second full index OOMs in - * ScenegraphIndex::build(). Above this ceiling the planner skips the - * full-node inspection and degrades to one-page-per-frame, emitting a + * Responsive grouping is a frame-level question: it only needs the small + * set of page-candidate FRAMEs the planner already holds in memory (name, + * dimensions, device hint, ancestor ids) — NOT an index of every + * descendant node. Detection therefore runs over that frame set without + * rebuilding a second whole-scenegraph {@see ScenegraphIndex}, so TOTAL + * node count no longer drives detection memory and grouping stays ON for + * large designs (e.g. the 293MB "WP.Cloud 2.0" .fig) instead of degrading + * to one-page-per-frame. The only remaining bound guards the genuinely + * pathological case of an absurd number of frame candidates, where the + * O(frames^2) sibling scan would dominate; above it the planner emits a * {@see ScenegraphPagePlanner::plan()} `responsive_detection_bounded` - * diagnostic so callers learn WHY grouping was limited. Overridable via the - * `responsive_detection_node_limit` plan option. + * diagnostic and degrades to one-page-per-frame. Overridable via the + * `responsive_detection_frame_limit` plan option. */ - private const RESPONSIVE_DETECTION_NODE_LIMIT = 25000; + private const RESPONSIVE_DETECTION_FRAME_LIMIT = 5000; /** * Minimum absolute width delta (px) for a sibling-group to count as a real @@ -161,15 +166,15 @@ public function plan(array $source, array $options = array()): array } } - $detectionResult = $this->detectResponsive($source, count($nodes), $options); + $detectionResult = $this->detectResponsive($candidates, $nodes, $parentIndex, $options); $detectionById = $detectionResult['detection']; if ( $detectionResult['bounded'] ) { $diagnostics[] = array( - 'severity' => 'info', - 'code' => 'responsive_detection_bounded', - 'message' => 'Responsive sibling detection was skipped because the scenegraph exceeded the planning node limit; emitting one page per frame.', - 'node_count' => $detectionResult['node_count'], - 'node_limit' => $detectionResult['node_limit'], + 'severity' => 'info', + 'code' => 'responsive_detection_bounded', + 'message' => 'Responsive sibling detection was skipped because the design has an unusually large number of frame candidates; emitting one page per frame.', + 'frame_candidate_count' => $detectionResult['frame_candidate_count'], + 'frame_candidate_limit' => $detectionResult['frame_candidate_limit'], ); } @@ -256,72 +261,90 @@ private function explicitFrameIds(array $options): array } /** - * Resolve responsive detection, bounding it against scenegraph size. + * Resolve responsive detection, bounding it against the NUMBER OF FRAME + * CANDIDATES — not total node count. * - * Detection re-inspects the source, which forces a second full - * {@see ScenegraphIndex} build. On unbounded scenegraphs that second index - * OOMs (ScenegraphIndex::build, observed on the 293MB "WP.Cloud 2.0" .fig). - * When the already-built node count exceeds the configured ceiling the - * inspection is SKIPPED entirely — no second index is built — and detection - * falls back to empty, which the page loop renders as one-page-per-frame. - * The `bounded` flag lets {@see ScenegraphPagePlanner::plan()} surface a - * `responsive_detection_bounded` diagnostic instead of dying silently. + * The prior implementation re-inspected the source, forcing a SECOND full + * {@see ScenegraphIndex} build over every node; on the 293MB "WP.Cloud 2.0" + * .fig that second index OOMed, so #265 skipped detection above a + * 25k-node ceiling and responsive grouping silently switched off at scale. + * Detection only needs frame-level data (name, width, height, device hint, + * ancestor ids), all of which the planner already holds in memory after its + * single index build, so it now runs regardless of total node count. The + * only remaining bound guards the genuinely pathological case of an absurd + * number of frame candidates; the `bounded` flag lets + * {@see ScenegraphPagePlanner::plan()} surface a + * `responsive_detection_bounded` diagnostic. * - * @param array $source - * @param array $options - * @return array{detection: array>, bounded: bool, node_count: int, node_limit: int} + * @param array> $candidates FRAME candidates the planner tracks. + * @param array> $nodes Already-built node map (for ancestor lookup). + * @param array $parentIndex Already-built parent index. + * @param array $options + * @return array{detection: array>, bounded: bool, frame_candidate_count: int, frame_candidate_limit: int} */ - private function detectResponsive(array $source, int $nodeCount, array $options): array + private function detectResponsive(array $candidates, array $nodes, array $parentIndex, array $options): array { - $limit = isset($options['responsive_detection_node_limit']) && is_numeric($options['responsive_detection_node_limit']) - ? max(1, (int) $options['responsive_detection_node_limit']) - : self::RESPONSIVE_DETECTION_NODE_LIMIT; + $limit = isset($options['responsive_detection_frame_limit']) && is_numeric($options['responsive_detection_frame_limit']) + ? max(1, (int) $options['responsive_detection_frame_limit']) + : self::RESPONSIVE_DETECTION_FRAME_LIMIT; + $frameCount = count($candidates); - if ( $nodeCount > $limit ) { + if ( $frameCount > $limit ) { return array( - 'detection' => array(), - 'bounded' => true, - 'node_count' => $nodeCount, - 'node_limit' => $limit, + 'detection' => array(), + 'bounded' => true, + 'frame_candidate_count' => $frameCount, + 'frame_candidate_limit' => $limit, ); } return array( - 'detection' => $this->detectionById($source, $nodeCount), - 'bounded' => false, - 'node_count' => $nodeCount, - 'node_limit' => $limit, + 'detection' => $this->detectionById($candidates, $nodes, $parentIndex), + 'bounded' => false, + 'frame_candidate_count' => $frameCount, + 'frame_candidate_limit' => $limit, ); } /** - * Consume the frame inspector's detection report keyed by frame id. + * Build the frame-level detection report (device_hint / sibling_group_key / + * responsive_siblings) WITHOUT building a second scenegraph index. * - * Detection (device_hint / sibling_group_key / responsive_siblings) lives in - * {@see ScenegraphFrameInspector}; the planner reuses it rather than - * re-deriving responsive relationships. The inspection limit is widened so - * every candidate's detection survives slicing. Callers must gate this - * behind {@see ScenegraphPagePlanner::detectResponsive()} so it never runs - * over an unbounded scenegraph. + * The planner already holds every FRAME candidate's dimensions plus the + * node/parent indexes in memory, so it extracts only the minimal + * frame-level records the detection heuristics need (name, dimensions, + * page/section/parent ids) and hands them to + * {@see ScenegraphFrameInspector::detectResponsiveFrames()}. No descendant + * node index is materialized for detection — the question is answered from + * frame-level data alone, which is what keeps grouping memory-safe at + * scale. * + * @param array> $candidates + * @param array> $nodes + * @param array $parentIndex * @return array> */ - private function detectionById(array $source, int $nodeCount): array + private function detectionById(array $candidates, array $nodes, array $parentIndex): array { - $inspection = $this->frameInspector->inspect($source, array('frame_inspection_limit' => max(1, $nodeCount))); - $candidates = is_array($inspection['candidates'] ?? null) ? $inspection['candidates'] : array(); - $detection = array(); - foreach ( $candidates as $candidate ) { - if ( ! is_array($candidate) ) { - continue; - } - $id = isset($candidate['id']) && is_scalar($candidate['id']) ? (string) $candidate['id'] : ''; - if ( '' !== $id ) { - $detection[$id] = $candidate; - } + $frames = array(); + foreach ( $candidates as $id => $candidate ) { + $id = (string) $id; + $node = is_array($candidate['node'] ?? null) ? $candidate['node'] : array(); + $page = $this->nearestAncestor($id, array('CANVAS'), $nodes, $parentIndex); + $section = $this->nearestAncestor($id, array('SECTION'), $nodes, $parentIndex); + $parentId = $parentIndex[$id] ?? null; + $frames[] = array( + 'id' => $id, + 'name' => (string) ($node['name'] ?? ''), + 'width' => $candidate['dimensions']['width'] ?? null, + 'height' => $candidate['dimensions']['height'] ?? null, + 'page_id' => $page['id'] ?? null, + 'section_id' => $section['id'] ?? null, + 'parent_id' => is_string($parentId) ? $parentId : null, + ); } - return $detection; + return $this->frameInspector->detectResponsiveFrames($frames); } /** diff --git a/figma-transformer/tests/contract/run.php b/figma-transformer/tests/contract/run.php index 32c308b..6847b84 100644 --- a/figma-transformer/tests/contract/run.php +++ b/figma-transformer/tests/contract/run.php @@ -1966,22 +1966,123 @@ $assert(4 === count($duplicateDraftDiagnostic['frame_ids'] ?? array()), 'page-plan-duplicate-drafts-diagnostic-frame-count'); $assert(null === $planDiagnosticByCode($duplicateDraftPlan, 'responsive_group_formed'), 'page-plan-duplicate-drafts-not-grouped'); -// (c) MEMORY SAFETY: when the scenegraph exceeds the responsive-detection node -// limit, detection is skipped (no second full index build), the transform -// COMPLETES, a responsive_detection_bounded diagnostic is emitted, and frames -// fall back to one-page-per-frame instead of collapsing. +// (c) FRAME-CANDIDATE BOUND: detection now scales with the number of FRAME +// candidates, not total node count. The `responsive_detection_bounded` +// diagnostic only fires in pathological cases (here forced via a frame-limit of +// 1 against 4 frames). When bounded, detection is skipped — no second index is +// built — and frames fall back to one-page-per-frame. $boundedPlan = ( new ScenegraphPagePlanner() )->plan( $responsivePagePlanSource, - array('include_all_pages' => true, 'responsive_detection_node_limit' => 3) + array('include_all_pages' => true, 'responsive_detection_frame_limit' => 1) ); $boundedDiagnostic = $planDiagnosticByCode($boundedPlan, 'responsive_detection_bounded'); $assert(null !== $boundedDiagnostic, 'page-plan-bounded-detection-diagnostic-emitted'); -$assert(3 === ($boundedDiagnostic['node_limit'] ?? null), 'page-plan-bounded-detection-node-limit'); -$assert(($boundedDiagnostic['node_count'] ?? 0) > 3, 'page-plan-bounded-detection-node-count'); +$assert(1 === ($boundedDiagnostic['frame_candidate_limit'] ?? null), 'page-plan-bounded-detection-frame-limit'); +$assert(4 === ($boundedDiagnostic['frame_candidate_count'] ?? null), 'page-plan-bounded-detection-frame-count'); $assert(4 === ($boundedPlan['page_count'] ?? null), 'page-plan-bounded-detection-one-page-per-frame'); $boundedHomePage = $responsivePageByFrame($boundedPlan, 'frame:home-desktop'); $assert(null !== $boundedHomePage && false === ($boundedHomePage['responsive'] ?? null), 'page-plan-bounded-detection-no-collapse'); +// (c2) SCALE: a design whose descendant node count is WELL ABOVE the old 25k +// ceiling — but with only a handful of FRAME candidates — must STILL run +// detection and form a genuine desktop/tablet/mobile responsive group, with NO +// `responsive_detection_bounded` skip. This proves grouping stays ON at +// "Automattic scale" and that detection reads frame-level data, not all nodes. +$largeDescendantCount = 26000; // > the retired RESPONSIVE_DETECTION_NODE_LIMIT of 25000. +$bulkChildren = static function (string $prefix, int $count): array { + $children = array(); + for ( $i = 0; $i < $count; $i++ ) { + $children[] = array( + 'id' => $prefix . ':text:' . $i, + 'type' => 'TEXT', + 'name' => 'Body ' . $i, + 'characters' => 'Lorem ipsum dolor sit amet number ' . $i, + ); + } + + return $children; +}; +$scaleSource = array( + 'nodes' => array( + array( + 'id' => 'page:scale', + 'type' => 'CANVAS', + 'name' => 'Scale Pages', + 'children' => array( + array( + 'id' => 'section:scale', + 'type' => 'SECTION', + 'name' => 'Marketing', + 'width' => 4000, + 'height' => 40000, + 'children' => array( + array( + 'id' => 'frame:landing-desktop', + 'type' => 'FRAME', + 'name' => 'Landing Page Desktop', + 'width' => 1440, + 'height' => 9000, + // This single frame alone carries more descendants + // than the entire old 25k node ceiling. + 'children' => $bulkChildren('desktop', $largeDescendantCount), + ), + array( + 'id' => 'frame:landing-tablet', + 'type' => 'FRAME', + 'name' => 'Landing Page Tablet', + 'width' => 834, + 'height' => 9000, + 'children' => array(array('id' => 'tablet:text', 'type' => 'TEXT', 'name' => 'Body', 'characters' => 'Lorem')), + ), + array( + 'id' => 'frame:landing-mobile', + 'type' => 'FRAME', + 'name' => 'Landing Page Mobile', + 'width' => 390, + 'height' => 9000, + 'children' => array(array('id' => 'mobile:text', 'type' => 'TEXT', 'name' => 'Body', 'characters' => 'Lorem')), + ), + ), + ), + ), + ), + ), +); +$scalePlan = ( new ScenegraphPagePlanner() )->plan($scaleSource, array('include_all_pages' => true)); +$assert(null === $planDiagnosticByCode($scalePlan, 'responsive_detection_bounded'), 'page-plan-scale-detection-not-bounded'); +$assert(3 === ($scalePlan['candidate_count'] ?? null), 'page-plan-scale-three-frame-candidates'); +$assert(1 === ($scalePlan['page_count'] ?? null), 'page-plan-scale-collapses-to-one-page'); +$scaleHomePage = $responsivePageByFrame($scalePlan, 'frame:landing-desktop'); +$assert(null !== $scaleHomePage, 'page-plan-scale-primary-is-desktop'); +$assert(true === ($scaleHomePage['responsive'] ?? null), 'page-plan-scale-flagged-responsive'); +$assert(3 === ($scaleHomePage['breakpoint_count'] ?? null), 'page-plan-scale-three-breakpoints'); +// The primary frame's own subtree exceeds the retired node ceiling, proving +// grouping is active at a scale that previously auto-disabled it. +$assert(($scaleHomePage['node_count'] ?? 0) > 25000, 'page-plan-scale-primary-above-old-ceiling'); +$scaleGroupDiagnostic = $planDiagnosticByCode($scalePlan, 'responsive_group_formed'); +$assert(null !== $scaleGroupDiagnostic, 'page-plan-scale-group-formed'); +$assert(in_array('device_hint_diversity', $scaleGroupDiagnostic['reasons'] ?? array(), true), 'page-plan-scale-group-device-diversity'); +$assert(array('desktop', 'tablet', 'mobile') + === array_map(static fn (array $variant): string => (string) ($variant['device_hint'] ?? ''), $scaleHomePage['variants'] ?? array()), 'page-plan-scale-variant-device-hints'); + +// (c3) DETECTION IS FRAME-LEVEL: the lightweight detection path produces the +// full detection contract (device_hint / sibling_group_key / +// responsive_siblings) from frame-level records ALONE — no source, no +// ScenegraphIndex. This is the memory-efficient primitive the planner reuses. +$frameLevelDetection = ( new Automattic\BlocksEngine\FigmaTransformer\Scenegraph\ScenegraphFrameInspector() )->detectResponsiveFrames(array( + array('id' => 'f:desktop', 'name' => 'Pricing Desktop', 'width' => 1440.0, 'height' => 3200.0, 'page_id' => 'p:1', 'section_id' => 's:1', 'parent_id' => 's:1'), + array('id' => 'f:tablet', 'name' => 'Pricing Tablet', 'width' => 834.0, 'height' => 3200.0, 'page_id' => 'p:1', 'section_id' => 's:1', 'parent_id' => 's:1'), + array('id' => 'f:mobile', 'name' => 'Pricing Mobile', 'width' => 390.0, 'height' => 3200.0, 'page_id' => 'p:1', 'section_id' => 's:1', 'parent_id' => 's:1'), +)); +$assert('desktop' === ($frameLevelDetection['f:desktop']['device_hint'] ?? null), 'frame-level-detection-desktop-hint'); +$assert('mobile' === ($frameLevelDetection['f:mobile']['device_hint'] ?? null), 'frame-level-detection-mobile-hint'); +$assert(isset($frameLevelDetection['f:desktop']['sibling_group_key']), 'frame-level-detection-sibling-group-key'); +$frameLevelSiblingIds = array_map( + static fn (array $sibling): string => (string) ($sibling['id'] ?? ''), + $frameLevelDetection['f:desktop']['responsive_siblings'] ?? array() +); +$assert(in_array('f:tablet', $frameLevelSiblingIds, true) && in_array('f:mobile', $frameLevelSiblingIds, true), 'frame-level-detection-links-siblings'); + $matrixWebsiteCandidate = array( 'id' => 'matrix:site:home', 'type' => 'FRAME',