Skip to content

feat(cli): check warns when text leaves the 9:16 social safe areas - #7680

Merged
miguel-heygen merged 7 commits into
mainfrom
feat/check-safe-zones
Nov 14, 2026
Merged

miguel-heygen merged 7 commits into
mainfrom
feat/check-safe-zones

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Nov 14, 2026 •

Copy link
Copy Markdown
Collaborator

What

hyperframes check now tells you which visible text, captions included, sits outside the TikTok, Instagram Reels and YouTube Shorts safe areas on a 9:16 composition. Each finding names the element, the first time it was seen and the edge it crosses.

  • On by default for 9:16 compositions, as outside_safe_zone warnings. They never fail the check, --strict included.
  • --safe-zone tiktok|reels|shorts checks one platform only. --safe-zone off turns the check off.
  • --safe-zone standard checks the title-safe margins (10% in from each edge, the preview's Standard margins) on any aspect ratio. It's opt-in only: the 9:16 default stays the three platforms.
  • Other aspect ratios: nothing is reported and nothing errors, unless standard is asked for.
  • data-layout-allow-safe-zone opts out an element and everything inside it, the same way data-layout-allow-caption-zone works.

Why

An agent making a vertical video can't see where the platform buttons and caption will cover it. A preview can draw these areas, but check couldn't report them, so a caption under the buttons passed every check.

Related work

None.

How

  • @hyperframes/core/safe-areas (packages/core/src/safeAreas.ts) owns the areas, so there is one copy of the data:
    • SOCIAL_SAFE_AREAS: each clear area as a list of horizontal bands in that platform's reference frame, scaled to the composition. TikTok has two bands, for its stepped right edge beside the action buttons.
      • TikTok: 720x1280, clear from (80,160) to (640,560), then narrowing to x 520 down to y 840.
      • Instagram Reels: bottom 35%, top 14% and 6% sides excluded.
      • YouTube Shorts: 1080x1920, excluding 48px left, 192px right, 288px top and 672px bottom.
      • Sources: TikTok's in-feed ads template, Meta's Reels ads guide and Google's video ad specs, checked 2026-10-13.
    • The action-safe and title-safe constants (90% and 80%) that Studio's preview guides draw. They move here from packages/studio/src/utils/previewSafeMargins.ts, which now re-exports them, so the preview and check read the same numbers.
    • Other previews that draw these areas can move onto this export later. That is a separate change.
  • packages/cli/src/utils/safeZones.ts keeps only the geometry: which edges of an area a box crosses, and which areas apply to a frame. The social areas apply only to 9:16; standard applies to any frame.
  • It reuses the --caption-zone path: same geometry collector, same sampling, same finding plumbing. There is no second pipeline. A new blocks request makes the collector return one text box per reading block, so a headline split into word spans is one finding. A caption stage (.caption-layer, #caption-stage, [data-composition-id="captions"]) counts as one block. That collector skips caption stages today, because captions belong in the caption band, but for safe zones captions are the main thing to check, so blocks includes them. The existing text and media candidates are unchanged.
  • Blocks are judged at every layout sample. The existing collapse keeps the first time and lists every time it was seen.
  • --strict subtracts safe-zone warnings the same way it already subtracts craft warnings. These areas are platform guidance, not defects. isGuidanceIssue in layoutAudit.ts is the one place that says so.
  • When --max-issues truncates, guidance ranks after defect warnings of the same severity. So a safe-zone warning can never push a real warning out of the report, which would otherwise let --strict pass.
  • Complexity gate: the per-candidate findings in collectGeometryAt are one flatMap, and the sample sets are built in their own buildSampleSets. applyPersistenceTier's held promotions move into heldPromotion, with the same behaviour; layoutAudit.test.ts passes unchanged.

Test plan

  • Unit tests added/updated
  • Manual testing performed
  • Documentation updated (if applicable)
  • Comments follow CONTRIBUTING.md "Comments": they say why, not what, and a bug fix says what the code must do and how to reproduce the bug

fails without the fix: packages/cli/src/commands/check.test.ts "social safe zones" covers these cases:

  • "warns once, at the first time, for a caption below every platform's safe area": a caption in the bottom 30% of 1080x1920 is reported for all three platforms. The centred title is not. It also asserts --strict still passes.
  • "follows TikTok's stepped right edge": a box under the step is flagged on the right for TikTok only, and the same box higher up is not.
  • "narrows to one platform".
  • "skips text marked data-layout-allow-safe-zone".
  • "reports nothing when off or when the frame is not 9:16".
  • "never lets a safe-zone warning push a defect warning past --max-issues": with --max-issues 1 --strict, a caption-band warning is kept over earlier safe-zone warnings, and the check fails.
  • "checks title-safe margins on any frame when asked for standard": on 1920x1080, a title 96px from the left edge is flagged on the left, and a centred one is not.
  • "parses --safe-zone".

packages/cli/src/commands/layout-audit.browser.test.ts "groups text into reading blocks, caption layers included, for the safe-zone check" checks that word spans join into one block, that the caption layer is one block and that opted-out text is dropped.

Each test was checked against a break on Linux, and each break turned exactly that one test red and left the rest passing:

  • TikTok using only its first band failed "follows TikTok's stepped right edge".
  • Caption stages skipped again failed the browser grouping test.
  • Safe-zone warnings counted toward --strict failed "warns once…".
  • standard limited to 9:16 frames failed "checks title-safe margins on any frame…".
  • standard not accepted by the parser failed "parses --safe-zone".
  • Sorting at the cap without the guidance rank failed "never lets a safe-zone warning push…".

check.test.ts + layout-audit.browser.test.ts + layoutAudit.test.ts on Linux: exit 0, 461 passed. bunx fallow audit --base origin/main: exit 0, dead code 0, complexity 0, duplication 0. Core lightEntries.test.ts, with ./safe-areas added to the entries that must load none of the parsers: exit 0, 4 passed. Studio previewSafeMargins.test.ts, now reading the constants through core: exit 0, 2 passed. oxlint and oxfmt are clean on the changed files. tsc shows only errors from packages not built in that checkout (@hyperframes/inspector/live, core/audio-fx-runtime), none in the changed files. CI runs the full type check.

Real run of the CLI from source on Linux, on a 1080x1920 fixture with these elements:

  • a centred title;
  • a "Shop now" badge at x 800, y 1000;
  • a caption layer at y 1420 that fades in at t=2;
  • a @handle at the bottom marked data-layout-allow-safe-zone.

Commands used: check --no-contrast --no-craft --no-cache, the same with --safe-zone standard, the same with --strict --json, and --safe-zone instagram.

Default (exit 0):

Layout
  ⚠ t=0.333, 1, 1.667, 2.333, 3, 3.667, 4.333, 5, 5.667s outside_safe_zone #badge "Shop now" — <div> "Shop now" is outside the safe area: TikTok (right edge), YouTube Shorts (right edge).
        Fix: Move the text inside the safe area, or mark text meant to sit there with data-layout-allow-safe-zone.
  ⚠ t=2.333, 3, 3.667, 4.333, 5, 5.667s outside_safe_zone #captions "Turn off screens" — <div> "Turn off screens" is outside the safe area: TikTok (bottom edge), Instagram Reels (bottom edge), YouTube Shorts (bottom edge).
        Fix: Move the text inside the safe area, or mark text meant to sit there with data-layout-allow-safe-zone.
  0 error(s), 2 warning(s), 0 info(s)

--safe-zone standard (exit 0): the caption sits inside the 10% margins, and the badge does not.

Layout
  ⚠ t=0.333, 1, 1.667, 2.333, 3, 3.667, 4.333, 5, 5.667s outside_safe_zone #badge "Shop now" — <div> "Shop now" is outside the safe area: title-safe margins (right edge).
  0 error(s), 1 warning(s), 0 info(s)

--strict --json (exit 0): "ok": true, "strict": true, with both warnings in layout.findings (code outside_safe_zone, time, rect, times).

--safe-zone instagram (exit 2): ✗ Check failed: Invalid --safe-zone; use tiktok, reels, shorts, standard or off.

An earlier run with --safe-zone off printed Layout ◇ 0 issues across 9 sample(s).

No visible change

packages/studio/src/utils/previewSafeMargins.ts now re-exports the same four values from @hyperframes/core/safe-areas. They're the same numbers, so the preview guides draw exactly as before.

Not checked: a real caption component from the registry, and right-to-left text.

@mintlify

mintlify Bot commented Nov 14, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated
hyperframes 🟢 Ready View Preview Nov 14, 2026, 1:09 AM

💡 Tip: Enable Automations to automatically generate PRs for you.

@github-actions

github-actions Bot commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

Edit accuracy: accurate 267 (base branch 267), smooth 104 of those

The gate passes.
Smoothness is reported in the artifact, not gated. A case fails only if it fails 2 of 3 runs.

Quarantined, measured but not gated (8)

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes at d201a5a4 for one blocker: CI's Fallow audit job fails because of this change. The feature itself looks right.

Blocker

  • Fallow reports two new complexity findings in functions this PR grows. Recent merged PRs (#7663, #7659) pass this gate.
    • checkPipeline.ts:473 collectGeometryAt: cognitive complexity 17, threshold 15. The new request.blocks branch pushes it over.
    • checkPipeline.ts:584 collectGridSamples: CRAP score 43.1, threshold 30. The new geometryEnabled and safeZones conditions push it over.
    • Fallow also flags layoutAudit.ts:343 applyPersistenceTier at 31.6. This PR only adds a union member to that file, so it may clear once the other two do.
    • Suggested fix: move each candidate's findings into a helper such as geometryFindings(candidate, request, …), and build SampleSets.safeZones in its own function.

What I checked

  • At this head on Linux, check.test.ts and layout-audit.browser.test.ts pass: 417 of 417.
  • The three breaks listed in the body each fail exactly the test named for them (1 failed, 416 passed):
    • TikTok using only its first band
    • caption stages skipped again
    • safe-zone warnings counted toward --strict
  • Other CI at this head is green: Typecheck, Build, Test, regression, Windows render verification, preview-regression, Docs, CodeQL, Player perf, PR captures.
  • The table math matches the body. Shorts' clear area ends at x 888 and y 1248, which is 1080−192 and 1920−672. The caption band and frame checks only read kind === "text" and kind === "media" candidates, so block candidates can't reach them. Grouping keys on selector and code, so a caption stage whose text changes still yields one finding.

Non-blocking

  • strictWarningCount subtracts safe-zone warnings counted after the maxIssues cap (80). Findings are sorted by severity, then time. If there are more than 80 layout findings, early safe-zone warnings can push real warnings past the cap, and --strict then passes. That is most likely with --no-collapse-static. Counting safe-zone warnings before truncation would close this.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed at c320c8d:

  • Fallow blocker:
    • collectGeometryAt now builds its findings with one flatMap.
    • The sample sets are built in buildSampleSets.
    • applyPersistenceTier's held promotions moved into heldPromotion, with no change in behaviour; layoutAudit.test.ts passes unchanged.
    • bunx fallow audit --base origin/main at this head: exit 0, dead code 0, complexity 0, duplication 0.
  • --strict past --max-issues: fixed at the cap. Guidance ranks after defect warnings of the same severity, so a safe-zone warning can no longer push a real warning out. isGuidanceIssue is the single owner of that rule. New test: "never lets a safe-zone warning push a defect warning past --max-issues". It fails when the ranking is removed.
  • Also in this head:
    • The platform table and the title-safe and action-safe constants now live in @hyperframes/core/safe-areas. Studio's preview guides re-export the same values.
    • New opt-in --safe-zone standard: title-safe margins, 10% from each edge, on any frame.

Tests on Linux: check.test.ts + layout-audit.browser.test.ts + layoutAudit.test.ts, 461 passed; core lightEntries.test.ts, 4 passed; studio previewSafeMargins.test.ts, 2 passed.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Requesting changes at c320c8d2. The required Test check fails because of this change. Both earlier points are fixed.

Blocker

  • In CI run 43538992900, test #501 "the entry skill's safe boxes equal the preview's" fails at scripts/creator-editing-recipes.test.mjs:62. The "Test (support)" shard fails, and the required "Test" job fails with it.
    • The test reads packages/studio/src/utils/previewSafeMargins.ts and regex-matches ACTION_SAFE_PERCENT = (\d+) and TITLE_SAFE_PERCENT = (\d+). That file now only re-exports the constants from @hyperframes/core/safe-areas, so both matches come back undefined and the test asserts /undefined% action-safe/ against the entry skill.
    • Fix: point the test at packages/core/src/safeAreas.ts, which is now where the values live, and run node --test scripts/creator-editing-recipes.test.mjs.

Fixed since my last review

  • Fallow: collectGeometryAt is now a flat map, and the sample sets are built in buildSampleSets. The CI run no longer has a Fallow failure.
  • --max-issues: limitLayoutIssues sorts guidance findings after defects of the same severity. The new test "never lets a safe-zone warning push a defect warning past --max-issues" fails when I take that sort out (1 failed, 418 passed).
  • --safe-zone standard: "checks title-safe margins on any frame when asked for standard" fails when standard is limited to 9:16 frames again. The earlier --strict break still fails only "warns once…".
  • Locally at this head, check.test.ts and layout-audit.browser.test.ts pass 419/419. Core lightEntries.test.ts, which now includes ./safe-areas, passes 4/4. Studio previewSafeMargins.test.ts passes 2/2.

Note on PR captures

  • The pull_request run of "Studio and player captures" (43538992684) failed. It read the body from before ## No visible change was added, and re-running it reuses that same old body.
  • I started a workflow_dispatch run on the branch, which reads the current body. It passed (43540461596). The gate script also passes locally against the current body.

@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Fixed at e585f25. scripts/creator-editing-recipes.test.mjs ("the entry skill's safe boxes equal the preview's") now reads the constants from packages/core/src/safeAreas.ts, where they live.

On Linux, node --import tsx --test scripts/creator-editing-recipes.test.mjs passes 8/8. Pointing it back at the studio re-export fails exactly that test (7 pass, 1 fail).

No other file reads those constants by path. A grep for ACTION_SAFE_PERCENT, TITLE_SAFE_PERCENT and previewSafeMargins across the repo finds only imports, which still resolve.

The "Studio: edit accuracy" red at c320c8d came from run 43538795881, which was cancelled when a newer run on the same head replaced it.

@heygengenesis heygengenesis Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve at e585f25f.

  • The only change since c320c8d2 is the one-line path fix in scripts/creator-editing-recipes.test.mjs. The entry-skill safe-box test now reads packages/core/src/safeAreas.ts.
  • Locally, with node --import tsx --test scripts/creator-editing-recipes.test.mjs: the new head passes 8/8. With the old path restored, only "the entry skill's safe boxes equal the preview's" fails (7 passed, 1 failed).
  • Every Actions run at this head is green. That includes CI, where the Test (support) shard and Fallow both pass, plus PR captures on the current body, regression, Windows render verification, preview-regression, Docs, CodeQL and Player perf.
  • The rest of the PR is unchanged from what I checked at c320c8d2: 419/419 CLI tests, and the --max-issues, standard and --strict breaks each fail only their named test.

@miguel-heygen
miguel-heygen added this pull request to the merge queue Nov 14, 2026
Merged via the queue into main with commit 4588202 Nov 14, 2026
99 checks passed
@miguel-heygen
miguel-heygen deleted the feat/check-safe-zones branch November 14, 2026 02:18
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.

1 participant