diff --git a/.claude/skills/cut-release/SKILL.md b/.claude/skills/cut-release/SKILL.md index b75ad8e0..12af1d05 100644 --- a/.claude/skills/cut-release/SKILL.md +++ b/.claude/skills/cut-release/SKILL.md @@ -6,7 +6,7 @@ description: >- approval and Play Console upload confirmation. Use when the user says "/cut-release", "cut a release", "cut release", "ship a release", "start the release process", "bump the version and release", or wants to move a merged `main` toward a tagged Play release. Depends on - `scripts/tag-release.ps1` (tags a verified merge-commit sha, never a moving branch) and + `scripts/tag-release.ps1` (tags the verified final included merge sha, never a moving branch) and `scripts/pre-pr-sweep.ps1` (the PR gate). Does not touch Play Console directly — it hands off the signed `.aab` and drafted release-notes text for the owner to upload by hand. --- @@ -18,13 +18,15 @@ A release is one long process with two owner-only gates in the middle: a require access to Play Console exists in this session). This skill walks every mechanical step around those two gates and refuses to substitute for either one, no matter how the request is phrased. -**Ground truth this skill reads, and never duplicates:** +**Ground truth this skill reads only when the detected stage needs it, and never duplicates:** - [`docs/play-store/release-signing-and-aab.md`](../../../docs/play-store/release-signing-and-aab.md) — - the full mechanical build/sign/tag steps -- [`docs/DEFINITION_OF_DONE.md`](../../../docs/DEFINITION_OF_DONE.md) — the pre-PR sweep gate -- `scripts/pre-pr-sweep.ps1`, `scripts/tag-release.ps1` — read `Get-Help` / the header comment - before invoking either; don't reimplement what they already verify + read the relevant build/sign/tag section for the current stage +- [`docs/DEFINITION_OF_DONE.md`](../../../docs/DEFINITION_OF_DONE.md) and + `scripts/pre-pr-sweep.ps1` — read before Step 2 or when diagnosing its receipt; a merged PR has + already cleared this gate, so do not reload or rerun it during a post-merge resume +- `scripts/tag-release.ps1` — read `Get-Help` / the header comment before Step 7; don't + reimplement what it already verifies Versioning pattern observed 1.0.47 → 1.0.49: `versionName`'s last segment always equals `versionCode` (both live in `app/build.gradle.kts`). Read the current values yourself before @@ -51,20 +53,27 @@ new scheme. ## Detect where the release currently stands -Don't assume you're starting at Step 1. Work out the stage first: +Don't assume you're starting at Step 1. Work out the stage first. Version contents, commit +history, and GitHub's PR data are authoritative; branch names and PR titles are only hints. 1. Read current `versionCode`/`versionName` (`app/build.gradle.kts`) and the latest tag (`git tag --sort=-v:refname` or `gh release list`). -2. A `chore/release-` branch/PR exists and is open → resume at **Step 2** or +2. Locate the commit that changed the version after the latest tag and resolve the PR that + merged it. Do not assume the bump lives in a `chore/release-*` branch. +3. The bump PR is open → resume at **Step 2** or **Stop A**, depending on whether the sweep+PR already happened. -3. That PR is merged but `releases/openloop--.aab` doesn't exist locally → resume +4. The bump PR is merged but `releases/openloop--.aab` doesn't exist locally → resume at **Step 3/4** (capture the sha, build). -4. The `.aab` exists but no tag matches its version → you're at **Stop B**, waiting on upload +5. The `.aab` exists but no tag matches its version → you're at **Stop B**, waiting on upload confirmation. Do not tag. -5. A tag exists for the current `versionName` → already done; say so and stop. -6. Current `versionName` == latest tag and no open release PR exists → nothing in flight; a new +6. A tag exists for the current `versionName` → already done; say so and stop. +7. Current `versionName` == latest tag and no open release PR exists → nothing in flight; a new release starts at **Step 1**. +GitHub issues and PRs share one number sequence. If `gh pr view ` says the number is +not a PR, inspect the recent merged PRs and the version-bump history, report the correction, and +do not silently treat an issue number as a merge sha. + ## Step 1 — Bump the version - Next `versionCode` = current + 1. Next `versionName` = `"1.0."`. @@ -92,16 +101,22 @@ you're stopped here, waiting for a human review. ## Step 3 (after Stop A clears) — capture the real build sha -- Confirm the merge: `gh pr view --json state,mergedAt,mergeCommit --jq '.mergeCommit.oid'`. -- Use that merge commit sha — **not** `git rev-parse origin/main`. `origin/main` is only correct - if nothing else merged in the gap between this PR and now, which is exactly the race this - whole skill exists to close (the issue that prompted this skill: 1.0.49 was tagged against - `main` by luck). This is a deliberate correction from the issue's own suggested command. -- `git fetch origin`; verify the sha is an ancestor of `origin/main`. +- Confirm the bump merge with + `gh pr view --json state,mergedAt,mergeCommit --jq '.mergeCommit.oid'`. +- After `git fetch origin`, inspect merges between the bump merge and `origin/main`. If later PRs + exist, establish the release cutoff before building: + - If the owner explicitly named the latest merged PR as the release cutoff, resolve that PR's + `mergeCommit.oid` and use it after confirming it still contains the intended version. + - Otherwise ask which later PRs belong in this release. Do not silently build the older bump + merge and omit fixes, or silently build a moving `origin/main`. +- Call the chosen final included PR merge commit the **build sha**. With no later included PR, + the build sha is the bump PR's merge commit. +- Verify `app/build.gradle.kts` at the build sha contains the intended version and verify the sha + is an ancestor of `origin/main`. ## Step 4 — Build the signed AAB -- Build from that exact merge sha: `git switch --detach ` (or use a worktree). +- Build from that exact build sha: `git switch --detach ` (or use a worktree). - `.\gradlew.bat :app:bundleRelease` (`JAVA_HOME` = Android Studio's bundled JBR, per `DEFINITION_OF_DONE.md`'s environment notes). - `jarsigner -verify -verbose app/build/outputs/bundle/release/app-release.aab` — must report @@ -111,8 +126,8 @@ you're stopped here, waiting for a human review. ## Step 5 — Lesson 040 check: did a new native/JNI/reflection dependency land? -- Diff dependency surfaces between the previous tag and this merge sha: - `git diff .. -- app/build.gradle.kts gradle/libs.versions.toml`. +- Diff dependency surfaces between the previous tag and the build sha: + `git diff .. -- app/build.gradle.kts gradle/libs.versions.toml`. - New dependency shipping native code, JNI, or heavy reflection (MediaPipe, ML Kit modules, etc.) landed → [Lesson 040](../../../docs/lessons_learned/040-run-the-release-apk-when-a-native-dependency-lands.md) applies: build and install the release APK (`:app:assembleRelease`) on an emulator from the @@ -126,8 +141,10 @@ Draft two separate files (gitignored — owner-only, never commit) — mirror th owner hand-wrote for 1.0.49: 1. **GitHub release notes** (technical) — `docs/local/github-release-notes-.md` — from - merged PRs since the previous tag (`git log .. --oneline`, grouped by area). - This is only the curated alternative: `tag-release.ps1` defaults to `gh --generate-notes` in + merged PRs through the build sha since the previous tag + (`git log .. --oneline`, grouped by area). + Use the build sha as the upper bound. This is only the curated alternative: + `tag-release.ps1` defaults to `gh --generate-notes` in Step 7, which needs no draft at all. Offer this file only in case the owner wants a hand-curated summary instead. 2. **Play Console "What's new"** (user-facing) — `docs/local/play-notes-.md` — short, @@ -147,7 +164,7 @@ the owner. ## Step 7 (after Stop B clears) — cut the tag ```powershell -.\scripts\tag-release.ps1 -Version -Sha ` +.\scripts\tag-release.ps1 -Version -Sha ` [-Title ""] ` [-NotesFile docs/local/github-release-notes-.md] # omit to use --generate-notes (default) ``` @@ -170,8 +187,8 @@ Report both. Do not add a vitals/quality check here — see "Owner call" above. own (a review only a second human account can give; a Play Console upload). 2. Never type, or ask the owner to type, `versionCode`/`versionName` — read them from `app/build.gradle.kts`. -3. Never derive the merge sha from a branch name or `origin/main` — always resolve it from the - actual merged PR (`mergeCommitOid`). +3. Never derive the build sha from a branch name or `origin/main` — resolve it from the final + included merged PR (`mergeCommitOid`) and verify the intended version at that sha. 4. Never gather or gate on Play vitals numbers — out of scope for this skill per the 2026-08-28 owner call; the other docs still document them as a separate manual step. 5. Never attach the `.aab`, an unsigned APK, or any binary to the GitHub release — diff --git a/.claude/skills/verify-openloop/features/lenses.md b/.claude/skills/verify-openloop/features/lenses.md index 6c9b591b..fbebbbda 100644 --- a/.claude/skills/verify-openloop/features/lenses.md +++ b/.claude/skills/verify-openloop/features/lenses.md @@ -5,7 +5,7 @@ Live face effects on the camera viewfinder. The bottom-left drawer opens a carou ## Sub-features - `lenses-open` opens the drawer (`Lenses and Photo Booth` / `lens_button`) and shows the Lenses tab (`camera_lenses`) plus carousel (`lens_carousel`). -- `lenses-pick` selects a catalogue thumb by display name / `lens_thumb_*` (Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy). +- `lenses-pick` selects a catalogue thumb by display name / `lens_thumb_*` (Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy, Vampire). - `lenses-clear` deselects by tapping the active thumb again, or closes the drawer (`Close lenses` / `lens_close`). - `lenses-one-face` — with one detectable face, the active lens sticks to that face (preview). - `lenses-two-faces` — with two detectable faces, **both** get the same lens (preview and, if recording, the saved clip). A third face does not steal a slot. @@ -17,18 +17,16 @@ Live face effects on the camera viewfinder. The bottom-left drawer opens a carou `lenses-open` / `lenses-pick` / `lenses-clear` are driven by a loop; the rest of this file is the manual recipe for the sub-features it cannot reach. -> **Not yet seen passing.** The loop was written in a container with no Android SDK and no -> emulator, so it has proved nothing yet — `INVENTORY.md` carries it as `loop not yet run`, not -> `automated`. `scripts/run-verification-loops.py` runs it, so the first pre-PR sweep on a machine -> with an AVD is what settles it. Treat a red result there as a product bug until its evidence -> says otherwise. +> **Automated 2026-09-03.** The loop passed on the Pixel 8 API 37 AVD and `INVENTORY.md` +> records the drawer + catalogue surface as automated. It proves open, pick and clear; face +> tracking and baked pixels still need the visual checks below. ```text python .claude/skills/verify-openloop/helpers/lenses_loop.py VERIFY_LENS="Elvis" python .claude/skills/verify-openloop/helpers/lenses_loop.py ``` -It opens the drawer, scrolls the carousel to the named lens (**Cowboy** by default — the newest +It opens the drawer, scrolls the carousel to the named lens (**Vampire** by default — the newest entry, and the one a registration slip would strand off-screen), wears it, and taps it off again. Each state is read two ways out of one dump: the active-lens name pill and the thumbnail's own `selected` flag. Nothing is recorded, and it makes no claim about the lens landing on a face — see diff --git a/.claude/skills/verify-openloop/helpers/lenses_loop.py b/.claude/skills/verify-openloop/helpers/lenses_loop.py index 0c9ef489..26ff15b6 100644 --- a/.claude/skills/verify-openloop/helpers/lenses_loop.py +++ b/.claude/skills/verify-openloop/helpers/lenses_loop.py @@ -14,7 +14,7 @@ never took the lens. Both reads come from one dump, because a dump costs seconds and re-dumping between them is a race. -The lens it drives defaults to the catalogue's newest entry, **Cowboy**. That is the point: a new +The lens it drives defaults to the catalogue's newest entry, **Vampire**. That is the point: a new lens is one enum entry plus its art, and the failure this catches is that entry not reaching the tray at all — a missing drawable, a thumbnail that never composes, a name that never renders. Set `VERIFY_LENS` to drive a different one. @@ -70,7 +70,7 @@ # same row as the thumbnails, so it doubles as proof the carousel is up and as the y to swipe on. CLOSE_LENSES = "Close lenses" VIDEO_SHUTTER = "Start recording" -LENS = os.environ.get("VERIFY_LENS", "").strip() or "Cowboy" +LENS = os.environ.get("VERIFY_LENS", "").strip() or "Vampire" # The carousel is a LazyRow: entries past the fold are not composed until scrolled to, which is # exactly how LensCarouselTest started failing when the catalogue reached seven (PRD §13). diff --git a/.codex/skills/cut-release/SKILL.md b/.codex/skills/cut-release/SKILL.md index b75ad8e0..12af1d05 100644 --- a/.codex/skills/cut-release/SKILL.md +++ b/.codex/skills/cut-release/SKILL.md @@ -6,7 +6,7 @@ description: >- approval and Play Console upload confirmation. Use when the user says "/cut-release", "cut a release", "cut release", "ship a release", "start the release process", "bump the version and release", or wants to move a merged `main` toward a tagged Play release. Depends on - `scripts/tag-release.ps1` (tags a verified merge-commit sha, never a moving branch) and + `scripts/tag-release.ps1` (tags the verified final included merge sha, never a moving branch) and `scripts/pre-pr-sweep.ps1` (the PR gate). Does not touch Play Console directly — it hands off the signed `.aab` and drafted release-notes text for the owner to upload by hand. --- @@ -18,13 +18,15 @@ A release is one long process with two owner-only gates in the middle: a require access to Play Console exists in this session). This skill walks every mechanical step around those two gates and refuses to substitute for either one, no matter how the request is phrased. -**Ground truth this skill reads, and never duplicates:** +**Ground truth this skill reads only when the detected stage needs it, and never duplicates:** - [`docs/play-store/release-signing-and-aab.md`](../../../docs/play-store/release-signing-and-aab.md) — - the full mechanical build/sign/tag steps -- [`docs/DEFINITION_OF_DONE.md`](../../../docs/DEFINITION_OF_DONE.md) — the pre-PR sweep gate -- `scripts/pre-pr-sweep.ps1`, `scripts/tag-release.ps1` — read `Get-Help` / the header comment - before invoking either; don't reimplement what they already verify + read the relevant build/sign/tag section for the current stage +- [`docs/DEFINITION_OF_DONE.md`](../../../docs/DEFINITION_OF_DONE.md) and + `scripts/pre-pr-sweep.ps1` — read before Step 2 or when diagnosing its receipt; a merged PR has + already cleared this gate, so do not reload or rerun it during a post-merge resume +- `scripts/tag-release.ps1` — read `Get-Help` / the header comment before Step 7; don't + reimplement what it already verifies Versioning pattern observed 1.0.47 → 1.0.49: `versionName`'s last segment always equals `versionCode` (both live in `app/build.gradle.kts`). Read the current values yourself before @@ -51,20 +53,27 @@ new scheme. ## Detect where the release currently stands -Don't assume you're starting at Step 1. Work out the stage first: +Don't assume you're starting at Step 1. Work out the stage first. Version contents, commit +history, and GitHub's PR data are authoritative; branch names and PR titles are only hints. 1. Read current `versionCode`/`versionName` (`app/build.gradle.kts`) and the latest tag (`git tag --sort=-v:refname` or `gh release list`). -2. A `chore/release-` branch/PR exists and is open → resume at **Step 2** or +2. Locate the commit that changed the version after the latest tag and resolve the PR that + merged it. Do not assume the bump lives in a `chore/release-*` branch. +3. The bump PR is open → resume at **Step 2** or **Stop A**, depending on whether the sweep+PR already happened. -3. That PR is merged but `releases/openloop--.aab` doesn't exist locally → resume +4. The bump PR is merged but `releases/openloop--.aab` doesn't exist locally → resume at **Step 3/4** (capture the sha, build). -4. The `.aab` exists but no tag matches its version → you're at **Stop B**, waiting on upload +5. The `.aab` exists but no tag matches its version → you're at **Stop B**, waiting on upload confirmation. Do not tag. -5. A tag exists for the current `versionName` → already done; say so and stop. -6. Current `versionName` == latest tag and no open release PR exists → nothing in flight; a new +6. A tag exists for the current `versionName` → already done; say so and stop. +7. Current `versionName` == latest tag and no open release PR exists → nothing in flight; a new release starts at **Step 1**. +GitHub issues and PRs share one number sequence. If `gh pr view ` says the number is +not a PR, inspect the recent merged PRs and the version-bump history, report the correction, and +do not silently treat an issue number as a merge sha. + ## Step 1 — Bump the version - Next `versionCode` = current + 1. Next `versionName` = `"1.0."`. @@ -92,16 +101,22 @@ you're stopped here, waiting for a human review. ## Step 3 (after Stop A clears) — capture the real build sha -- Confirm the merge: `gh pr view --json state,mergedAt,mergeCommit --jq '.mergeCommit.oid'`. -- Use that merge commit sha — **not** `git rev-parse origin/main`. `origin/main` is only correct - if nothing else merged in the gap between this PR and now, which is exactly the race this - whole skill exists to close (the issue that prompted this skill: 1.0.49 was tagged against - `main` by luck). This is a deliberate correction from the issue's own suggested command. -- `git fetch origin`; verify the sha is an ancestor of `origin/main`. +- Confirm the bump merge with + `gh pr view --json state,mergedAt,mergeCommit --jq '.mergeCommit.oid'`. +- After `git fetch origin`, inspect merges between the bump merge and `origin/main`. If later PRs + exist, establish the release cutoff before building: + - If the owner explicitly named the latest merged PR as the release cutoff, resolve that PR's + `mergeCommit.oid` and use it after confirming it still contains the intended version. + - Otherwise ask which later PRs belong in this release. Do not silently build the older bump + merge and omit fixes, or silently build a moving `origin/main`. +- Call the chosen final included PR merge commit the **build sha**. With no later included PR, + the build sha is the bump PR's merge commit. +- Verify `app/build.gradle.kts` at the build sha contains the intended version and verify the sha + is an ancestor of `origin/main`. ## Step 4 — Build the signed AAB -- Build from that exact merge sha: `git switch --detach ` (or use a worktree). +- Build from that exact build sha: `git switch --detach ` (or use a worktree). - `.\gradlew.bat :app:bundleRelease` (`JAVA_HOME` = Android Studio's bundled JBR, per `DEFINITION_OF_DONE.md`'s environment notes). - `jarsigner -verify -verbose app/build/outputs/bundle/release/app-release.aab` — must report @@ -111,8 +126,8 @@ you're stopped here, waiting for a human review. ## Step 5 — Lesson 040 check: did a new native/JNI/reflection dependency land? -- Diff dependency surfaces between the previous tag and this merge sha: - `git diff .. -- app/build.gradle.kts gradle/libs.versions.toml`. +- Diff dependency surfaces between the previous tag and the build sha: + `git diff .. -- app/build.gradle.kts gradle/libs.versions.toml`. - New dependency shipping native code, JNI, or heavy reflection (MediaPipe, ML Kit modules, etc.) landed → [Lesson 040](../../../docs/lessons_learned/040-run-the-release-apk-when-a-native-dependency-lands.md) applies: build and install the release APK (`:app:assembleRelease`) on an emulator from the @@ -126,8 +141,10 @@ Draft two separate files (gitignored — owner-only, never commit) — mirror th owner hand-wrote for 1.0.49: 1. **GitHub release notes** (technical) — `docs/local/github-release-notes-.md` — from - merged PRs since the previous tag (`git log .. --oneline`, grouped by area). - This is only the curated alternative: `tag-release.ps1` defaults to `gh --generate-notes` in + merged PRs through the build sha since the previous tag + (`git log .. --oneline`, grouped by area). + Use the build sha as the upper bound. This is only the curated alternative: + `tag-release.ps1` defaults to `gh --generate-notes` in Step 7, which needs no draft at all. Offer this file only in case the owner wants a hand-curated summary instead. 2. **Play Console "What's new"** (user-facing) — `docs/local/play-notes-.md` — short, @@ -147,7 +164,7 @@ the owner. ## Step 7 (after Stop B clears) — cut the tag ```powershell -.\scripts\tag-release.ps1 -Version -Sha ` +.\scripts\tag-release.ps1 -Version -Sha ` [-Title ""] ` [-NotesFile docs/local/github-release-notes-.md] # omit to use --generate-notes (default) ``` @@ -170,8 +187,8 @@ Report both. Do not add a vitals/quality check here — see "Owner call" above. own (a review only a second human account can give; a Play Console upload). 2. Never type, or ask the owner to type, `versionCode`/`versionName` — read them from `app/build.gradle.kts`. -3. Never derive the merge sha from a branch name or `origin/main` — always resolve it from the - actual merged PR (`mergeCommitOid`). +3. Never derive the build sha from a branch name or `origin/main` — resolve it from the final + included merged PR (`mergeCommitOid`) and verify the intended version at that sha. 4. Never gather or gate on Play vitals numbers — out of scope for this skill per the 2026-08-28 owner call; the other docs still document them as a separate manual step. 5. Never attach the `.aab`, an unsigned APK, or any binary to the GitHub release — diff --git a/.codex/skills/verify-openloop/features/lenses.md b/.codex/skills/verify-openloop/features/lenses.md index 4dc74cb8..685597d6 100644 --- a/.codex/skills/verify-openloop/features/lenses.md +++ b/.codex/skills/verify-openloop/features/lenses.md @@ -5,7 +5,7 @@ Live face effects on the camera viewfinder. The bottom-left drawer opens a carou ## Sub-features - `lenses-open` opens the drawer (`Lenses and Photo Booth` / `lens_button`) and shows the Lenses tab (`camera_lenses`) plus carousel (`lens_carousel`). -- `lenses-pick` selects a catalogue thumb by display name / `lens_thumb_*` (Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy). +- `lenses-pick` selects a catalogue thumb by display name / `lens_thumb_*` (Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy, Vampire). - `lenses-clear` deselects by tapping the active thumb again, or closes the drawer (`Close lenses` / `lens_close`). - `lenses-one-face` — with one detectable face, the active lens sticks to that face (preview). - `lenses-two-faces` — with two detectable faces, **both** get the same lens (preview and, if recording, the saved clip). A third face does not steal a slot. @@ -17,18 +17,16 @@ Live face effects on the camera viewfinder. The bottom-left drawer opens a carou `lenses-open` / `lenses-pick` / `lenses-clear` are driven by a loop; the rest of this file is the manual recipe for the sub-features it cannot reach. -> **Not yet seen passing.** The loop was written in a container with no Android SDK and no -> emulator, so it has proved nothing yet — `INVENTORY.md` carries it as `loop not yet run`, not -> `automated`. `scripts/run-verification-loops.py` runs it, so the first pre-PR sweep on a machine -> with an AVD is what settles it. Treat a red result there as a product bug until its evidence -> says otherwise. +> **Automated 2026-09-03.** The loop passed on the Pixel 8 API 37 AVD and `INVENTORY.md` +> records the drawer + catalogue surface as automated. It proves open, pick and clear; face +> tracking and baked pixels still need the visual checks below. ```text python .codex/skills/verify-openloop/helpers/lenses_loop.py VERIFY_LENS="Elvis" python .codex/skills/verify-openloop/helpers/lenses_loop.py ``` -It opens the drawer, scrolls the carousel to the named lens (**Cowboy** by default — the newest +It opens the drawer, scrolls the carousel to the named lens (**Vampire** by default — the newest entry, and the one a registration slip would strand off-screen), wears it, and taps it off again. Each state is read two ways out of one dump: the active-lens name pill and the thumbnail's own `selected` flag. Nothing is recorded, and it makes no claim about the lens landing on a face — see diff --git a/.codex/skills/verify-openloop/helpers/lenses_loop.py b/.codex/skills/verify-openloop/helpers/lenses_loop.py index bdf6ce01..2a3348b3 100644 --- a/.codex/skills/verify-openloop/helpers/lenses_loop.py +++ b/.codex/skills/verify-openloop/helpers/lenses_loop.py @@ -14,7 +14,7 @@ never took the lens. Both reads come from one dump, because a dump costs seconds and re-dumping between them is a race. -The lens it drives defaults to the catalogue's newest entry, **Cowboy**. That is the point: a new +The lens it drives defaults to the catalogue's newest entry, **Vampire**. That is the point: a new lens is one enum entry plus its art, and the failure this catches is that entry not reaching the tray at all — a missing drawable, a thumbnail that never composes, a name that never renders. Set `VERIFY_LENS` to drive a different one. @@ -70,7 +70,7 @@ # same row as the thumbnails, so it doubles as proof the carousel is up and as the y to swipe on. CLOSE_LENSES = "Close lenses" VIDEO_SHUTTER = "Start recording" -LENS = os.environ.get("VERIFY_LENS", "").strip() or "Cowboy" +LENS = os.environ.get("VERIFY_LENS", "").strip() or "Vampire" # The carousel is a LazyRow: entries past the fold are not composed until scrolled to, which is # exactly how LensCarouselTest started failing when the catalogue reached seven (PRD §13). diff --git a/.cursor/skills/cut-release/SKILL.md b/.cursor/skills/cut-release/SKILL.md index b75ad8e0..12af1d05 100644 --- a/.cursor/skills/cut-release/SKILL.md +++ b/.cursor/skills/cut-release/SKILL.md @@ -6,7 +6,7 @@ description: >- approval and Play Console upload confirmation. Use when the user says "/cut-release", "cut a release", "cut release", "ship a release", "start the release process", "bump the version and release", or wants to move a merged `main` toward a tagged Play release. Depends on - `scripts/tag-release.ps1` (tags a verified merge-commit sha, never a moving branch) and + `scripts/tag-release.ps1` (tags the verified final included merge sha, never a moving branch) and `scripts/pre-pr-sweep.ps1` (the PR gate). Does not touch Play Console directly — it hands off the signed `.aab` and drafted release-notes text for the owner to upload by hand. --- @@ -18,13 +18,15 @@ A release is one long process with two owner-only gates in the middle: a require access to Play Console exists in this session). This skill walks every mechanical step around those two gates and refuses to substitute for either one, no matter how the request is phrased. -**Ground truth this skill reads, and never duplicates:** +**Ground truth this skill reads only when the detected stage needs it, and never duplicates:** - [`docs/play-store/release-signing-and-aab.md`](../../../docs/play-store/release-signing-and-aab.md) — - the full mechanical build/sign/tag steps -- [`docs/DEFINITION_OF_DONE.md`](../../../docs/DEFINITION_OF_DONE.md) — the pre-PR sweep gate -- `scripts/pre-pr-sweep.ps1`, `scripts/tag-release.ps1` — read `Get-Help` / the header comment - before invoking either; don't reimplement what they already verify + read the relevant build/sign/tag section for the current stage +- [`docs/DEFINITION_OF_DONE.md`](../../../docs/DEFINITION_OF_DONE.md) and + `scripts/pre-pr-sweep.ps1` — read before Step 2 or when diagnosing its receipt; a merged PR has + already cleared this gate, so do not reload or rerun it during a post-merge resume +- `scripts/tag-release.ps1` — read `Get-Help` / the header comment before Step 7; don't + reimplement what it already verifies Versioning pattern observed 1.0.47 → 1.0.49: `versionName`'s last segment always equals `versionCode` (both live in `app/build.gradle.kts`). Read the current values yourself before @@ -51,20 +53,27 @@ new scheme. ## Detect where the release currently stands -Don't assume you're starting at Step 1. Work out the stage first: +Don't assume you're starting at Step 1. Work out the stage first. Version contents, commit +history, and GitHub's PR data are authoritative; branch names and PR titles are only hints. 1. Read current `versionCode`/`versionName` (`app/build.gradle.kts`) and the latest tag (`git tag --sort=-v:refname` or `gh release list`). -2. A `chore/release-` branch/PR exists and is open → resume at **Step 2** or +2. Locate the commit that changed the version after the latest tag and resolve the PR that + merged it. Do not assume the bump lives in a `chore/release-*` branch. +3. The bump PR is open → resume at **Step 2** or **Stop A**, depending on whether the sweep+PR already happened. -3. That PR is merged but `releases/openloop--.aab` doesn't exist locally → resume +4. The bump PR is merged but `releases/openloop--.aab` doesn't exist locally → resume at **Step 3/4** (capture the sha, build). -4. The `.aab` exists but no tag matches its version → you're at **Stop B**, waiting on upload +5. The `.aab` exists but no tag matches its version → you're at **Stop B**, waiting on upload confirmation. Do not tag. -5. A tag exists for the current `versionName` → already done; say so and stop. -6. Current `versionName` == latest tag and no open release PR exists → nothing in flight; a new +6. A tag exists for the current `versionName` → already done; say so and stop. +7. Current `versionName` == latest tag and no open release PR exists → nothing in flight; a new release starts at **Step 1**. +GitHub issues and PRs share one number sequence. If `gh pr view ` says the number is +not a PR, inspect the recent merged PRs and the version-bump history, report the correction, and +do not silently treat an issue number as a merge sha. + ## Step 1 — Bump the version - Next `versionCode` = current + 1. Next `versionName` = `"1.0."`. @@ -92,16 +101,22 @@ you're stopped here, waiting for a human review. ## Step 3 (after Stop A clears) — capture the real build sha -- Confirm the merge: `gh pr view --json state,mergedAt,mergeCommit --jq '.mergeCommit.oid'`. -- Use that merge commit sha — **not** `git rev-parse origin/main`. `origin/main` is only correct - if nothing else merged in the gap between this PR and now, which is exactly the race this - whole skill exists to close (the issue that prompted this skill: 1.0.49 was tagged against - `main` by luck). This is a deliberate correction from the issue's own suggested command. -- `git fetch origin`; verify the sha is an ancestor of `origin/main`. +- Confirm the bump merge with + `gh pr view --json state,mergedAt,mergeCommit --jq '.mergeCommit.oid'`. +- After `git fetch origin`, inspect merges between the bump merge and `origin/main`. If later PRs + exist, establish the release cutoff before building: + - If the owner explicitly named the latest merged PR as the release cutoff, resolve that PR's + `mergeCommit.oid` and use it after confirming it still contains the intended version. + - Otherwise ask which later PRs belong in this release. Do not silently build the older bump + merge and omit fixes, or silently build a moving `origin/main`. +- Call the chosen final included PR merge commit the **build sha**. With no later included PR, + the build sha is the bump PR's merge commit. +- Verify `app/build.gradle.kts` at the build sha contains the intended version and verify the sha + is an ancestor of `origin/main`. ## Step 4 — Build the signed AAB -- Build from that exact merge sha: `git switch --detach ` (or use a worktree). +- Build from that exact build sha: `git switch --detach ` (or use a worktree). - `.\gradlew.bat :app:bundleRelease` (`JAVA_HOME` = Android Studio's bundled JBR, per `DEFINITION_OF_DONE.md`'s environment notes). - `jarsigner -verify -verbose app/build/outputs/bundle/release/app-release.aab` — must report @@ -111,8 +126,8 @@ you're stopped here, waiting for a human review. ## Step 5 — Lesson 040 check: did a new native/JNI/reflection dependency land? -- Diff dependency surfaces between the previous tag and this merge sha: - `git diff .. -- app/build.gradle.kts gradle/libs.versions.toml`. +- Diff dependency surfaces between the previous tag and the build sha: + `git diff .. -- app/build.gradle.kts gradle/libs.versions.toml`. - New dependency shipping native code, JNI, or heavy reflection (MediaPipe, ML Kit modules, etc.) landed → [Lesson 040](../../../docs/lessons_learned/040-run-the-release-apk-when-a-native-dependency-lands.md) applies: build and install the release APK (`:app:assembleRelease`) on an emulator from the @@ -126,8 +141,10 @@ Draft two separate files (gitignored — owner-only, never commit) — mirror th owner hand-wrote for 1.0.49: 1. **GitHub release notes** (technical) — `docs/local/github-release-notes-.md` — from - merged PRs since the previous tag (`git log .. --oneline`, grouped by area). - This is only the curated alternative: `tag-release.ps1` defaults to `gh --generate-notes` in + merged PRs through the build sha since the previous tag + (`git log .. --oneline`, grouped by area). + Use the build sha as the upper bound. This is only the curated alternative: + `tag-release.ps1` defaults to `gh --generate-notes` in Step 7, which needs no draft at all. Offer this file only in case the owner wants a hand-curated summary instead. 2. **Play Console "What's new"** (user-facing) — `docs/local/play-notes-.md` — short, @@ -147,7 +164,7 @@ the owner. ## Step 7 (after Stop B clears) — cut the tag ```powershell -.\scripts\tag-release.ps1 -Version -Sha ` +.\scripts\tag-release.ps1 -Version -Sha ` [-Title ""] ` [-NotesFile docs/local/github-release-notes-.md] # omit to use --generate-notes (default) ``` @@ -170,8 +187,8 @@ Report both. Do not add a vitals/quality check here — see "Owner call" above. own (a review only a second human account can give; a Play Console upload). 2. Never type, or ask the owner to type, `versionCode`/`versionName` — read them from `app/build.gradle.kts`. -3. Never derive the merge sha from a branch name or `origin/main` — always resolve it from the - actual merged PR (`mergeCommitOid`). +3. Never derive the build sha from a branch name or `origin/main` — resolve it from the final + included merged PR (`mergeCommitOid`) and verify the intended version at that sha. 4. Never gather or gate on Play vitals numbers — out of scope for this skill per the 2026-08-28 owner call; the other docs still document them as a separate manual step. 5. Never attach the `.aab`, an unsigned APK, or any binary to the GitHub release — diff --git a/.cursor/skills/verify-openloop/features/lenses.md b/.cursor/skills/verify-openloop/features/lenses.md index 6edfcf00..61ffcb35 100644 --- a/.cursor/skills/verify-openloop/features/lenses.md +++ b/.cursor/skills/verify-openloop/features/lenses.md @@ -5,7 +5,7 @@ Live face effects on the camera viewfinder. The bottom-left drawer opens a carou ## Sub-features - `lenses-open` opens the drawer (`Lenses and Photo Booth` / `lens_button`) and shows the Lenses tab (`camera_lenses`) plus carousel (`lens_carousel`). -- `lenses-pick` selects a catalogue thumb by display name / `lens_thumb_*` (Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy). +- `lenses-pick` selects a catalogue thumb by display name / `lens_thumb_*` (Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy, Vampire). - `lenses-clear` deselects by tapping the active thumb again, or closes the drawer (`Close lenses` / `lens_close`). - `lenses-one-face` — with one detectable face, the active lens sticks to that face (preview). - `lenses-two-faces` — with two detectable faces, **both** get the same lens (preview and, if recording, the saved clip). A third face does not steal a slot. @@ -17,18 +17,16 @@ Live face effects on the camera viewfinder. The bottom-left drawer opens a carou `lenses-open` / `lenses-pick` / `lenses-clear` are driven by a loop; the rest of this file is the manual recipe for the sub-features it cannot reach. -> **Not yet seen passing.** The loop was written in a container with no Android SDK and no -> emulator, so it has proved nothing yet — `INVENTORY.md` carries it as `loop not yet run`, not -> `automated`. `scripts/run-verification-loops.py` runs it, so the first pre-PR sweep on a machine -> with an AVD is what settles it. Treat a red result there as a product bug until its evidence -> says otherwise. +> **Automated 2026-09-03.** The loop passed on the Pixel 8 API 37 AVD and `INVENTORY.md` +> records the drawer + catalogue surface as automated. It proves open, pick and clear; face +> tracking and baked pixels still need the visual checks below. ```text python .cursor/skills/verify-openloop/helpers/lenses_loop.py VERIFY_LENS="Elvis" python .cursor/skills/verify-openloop/helpers/lenses_loop.py ``` -It opens the drawer, scrolls the carousel to the named lens (**Cowboy** by default — the newest +It opens the drawer, scrolls the carousel to the named lens (**Vampire** by default — the newest entry, and the one a registration slip would strand off-screen), wears it, and taps it off again. Each state is read two ways out of one dump: the active-lens name pill and the thumbnail's own `selected` flag. Nothing is recorded, and it makes no claim about the lens landing on a face — see diff --git a/.cursor/skills/verify-openloop/helpers/lenses_loop.py b/.cursor/skills/verify-openloop/helpers/lenses_loop.py index e8a997c9..7d62ed8d 100644 --- a/.cursor/skills/verify-openloop/helpers/lenses_loop.py +++ b/.cursor/skills/verify-openloop/helpers/lenses_loop.py @@ -14,7 +14,7 @@ never took the lens. Both reads come from one dump, because a dump costs seconds and re-dumping between them is a race. -The lens it drives defaults to the catalogue's newest entry, **Cowboy**. That is the point: a new +The lens it drives defaults to the catalogue's newest entry, **Vampire**. That is the point: a new lens is one enum entry plus its art, and the failure this catches is that entry not reaching the tray at all — a missing drawable, a thumbnail that never composes, a name that never renders. Set `VERIFY_LENS` to drive a different one. @@ -70,7 +70,7 @@ # same row as the thumbnails, so it doubles as proof the carousel is up and as the y to swipe on. CLOSE_LENSES = "Close lenses" VIDEO_SHUTTER = "Start recording" -LENS = os.environ.get("VERIFY_LENS", "").strip() or "Cowboy" +LENS = os.environ.get("VERIFY_LENS", "").strip() or "Vampire" # The carousel is a LazyRow: entries past the fold are not composed until scrolled to, which is # exactly how LensCarouselTest started failing when the catalogue reached seven (PRD §13). diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index a33dbf50..1ca83c3d 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -154,6 +154,7 @@ foldable foldables foojay + formalwear fossdroid fourcc freezedetect diff --git a/README.md b/README.md index ef8246d1..89d5bf78 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Built with Google's latest Android libraries. All video processing runs on your - **Capture** — Hold the shutter for a clip (up to 30 s), or import one from your library - **Seamless Loops** — Forward, reverse, or either bounce, generated entirely on-device via Media3 Transformer - **Speed Control** — Real-time playback speed from 0.5x to 3.0x before you save: a constant slider, or a **custom speed curve** you draw over the whole loop (tap to add a point, drag to bend it, presets to start from) so a ramp can peak right on the direction turn — the preview plays the curve live and the export honors it exactly -- **Face Lenses** — seven of them: Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue and Elvis. They render live on the viewfinder and record into the clip, tracked on-device with ML Kit — some react to you, like the tongue that hangs further out the wider you open your mouth. Two people in the shot? Both get the lens +- **Face Lenses** — nine of them: Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy and Vampire. They render live on the viewfinder and record into the clip, tracked on-device with ML Kit — some react to you, including Vampire's fangs, which lengthen when you open your mouth. Two people in the shot? Both get the lens - **Photo Mode** — Flip the shutter to stills, lenses included - **Photo Booth** — a self-driving 5-4-3-2-1 countdown ×3 composited into a classic vertical strip (white borders, OpenLoop + date footer, color or B&W) — the countdown is your window to swap lenses between shots - **Gallery** — Browse, replay, and manage all your loops in a slick grid @@ -309,10 +309,10 @@ When a PR gets review feedback, open a new session with the OpenLoop folder moun - Photo-booth strip: 5-4-3-2-1 ×3 capture composited into a printed strip (color or B&W) - Loop generation — forward, reverse and both bounces — via Media3 Transformer - Trim, speed control (0.5x–3.0x, constant or a custom curve) and Looks, all previewed before you save -- Seven face lenses, tracked with ML Kit and baked into the recording — on up to two faces at once +- Nine face lenses, tracked with ML Kit and baked into the recording — on up to two faces at once - Gallery with delete, full-screen playback and a share sheet - In-app updates and a Play review prompt -- 574 unit tests + 122 instrumented tests +- 647 unit tests + 123 instrumented tests **What's next:** diff --git a/app/src/main/java/io/github/stozo04/openloop/camera/lens/Lens.kt b/app/src/main/java/io/github/stozo04/openloop/camera/lens/Lens.kt index 30ec816f..9879255c 100644 --- a/app/src/main/java/io/github/stozo04/openloop/camera/lens/Lens.kt +++ b/app/src/main/java/io/github/stozo04/openloop/camera/lens/Lens.kt @@ -592,6 +592,67 @@ enum class Lens( ), ), ), + + /** + * Vampire — a romantic-goth **prop** that keeps the subject's real face and expression visible. + * A lacquer-black widow's-peak cowl and oxblood velvet collar frame the head, while a separate + * tuxedo and cape continue over the shoulders; two ivory upper canines extend when the mouth + * opens. The product choice and research are in + * `docs/PRD-camera-lenses.md` §17. + * + * The torso and frame scale from [LensAnchorPoint.FACE]; separating them lets the garment reach + * the chest without enlarging the face opening. The fangs are on [LensAnchorPoint.MOUTH], with + * their top edge authored at the top of the bitmap and [MouthOpenSpec] scaling the offset and + * size together. With `upInUnits` equal to negative half-height, that edge stays on the mouth + * while the tips grow downward. + * + * All WebPs are rendered from the committed sources in `swarm/art/` by + * `swarm/tools/render_lens_art.py`; the encoded dimensions below are the measured ratios. + */ + Vampire( + displayName = "Vampire", + // The cowl, collar and fangs are worn props. A spin would tear them off the face. + interaction = LensInteraction.NONE, + thumbnailRes = R.drawable.lens_vampire, + art = listOf( + LensArt( + drawableRes = R.drawable.lens_vampire_torso_art, + placement = LensPlacement( + // Broad enough to cover both shoulders while preserving the face opening. + widthInUnits = 4.8f, + // Encoded 1024x585. + artAspect = 585f / 1024f, + // Top -1.13 overlaps the collar; bottom -3.87 carries the shirt down the chest. + upInUnits = -2.5f, + ), + ), + LensArt( + drawableRes = R.drawable.lens_vampire_frame_art, + placement = LensPlacement( + // 3.0 units frames a 1.55-unit head without swallowing the central face opening. + widthInUnits = 3.0f, + // Encoded 954x1024. + artAspect = 1024f / 954f, + // Top +1.26 clears the +1.25 crown; the widow's point lands above the brow. + upInUnits = -0.35f, + ), + ), + LensArt( + drawableRes = R.drawable.lens_vampire_fangs_art, + placement = LensPlacement( + // The pair spans the 0.8-unit resting mouth at full reveal. + widthInUnits = 1.0f, + // Encoded 1024x642. + artAspect = 642f / 1024f, + // Negative half-height pins the authored top edge to the mouth anchor. + upInUnits = -(642f / 1024f) / 2f, + anchor = LensAnchorPoint.MOUTH, + // Hidden at rest so opening the mouth is a reveal, not merely a size change. + mouthOpen = MouthOpenSpec(restFraction = 0f), + ), + ), + ), + ), ; /** diff --git a/app/src/main/res/drawable-nodpi/lens_vampire.webp b/app/src/main/res/drawable-nodpi/lens_vampire.webp new file mode 100644 index 00000000..4402e40a Binary files /dev/null and b/app/src/main/res/drawable-nodpi/lens_vampire.webp differ diff --git a/app/src/main/res/drawable-nodpi/lens_vampire_fangs_art.webp b/app/src/main/res/drawable-nodpi/lens_vampire_fangs_art.webp new file mode 100644 index 00000000..1acbdcdc Binary files /dev/null and b/app/src/main/res/drawable-nodpi/lens_vampire_fangs_art.webp differ diff --git a/app/src/main/res/drawable-nodpi/lens_vampire_frame_art.webp b/app/src/main/res/drawable-nodpi/lens_vampire_frame_art.webp new file mode 100644 index 00000000..898e961c Binary files /dev/null and b/app/src/main/res/drawable-nodpi/lens_vampire_frame_art.webp differ diff --git a/app/src/main/res/drawable-nodpi/lens_vampire_torso_art.webp b/app/src/main/res/drawable-nodpi/lens_vampire_torso_art.webp new file mode 100644 index 00000000..89b273fb Binary files /dev/null and b/app/src/main/res/drawable-nodpi/lens_vampire_torso_art.webp differ diff --git a/app/src/test/java/io/github/stozo04/openloop/camera/lens/LensAnchorTest.kt b/app/src/test/java/io/github/stozo04/openloop/camera/lens/LensAnchorTest.kt index 8e86ac28..55a0a26c 100644 --- a/app/src/test/java/io/github/stozo04/openloop/camera/lens/LensAnchorTest.kt +++ b/app/src/test/java/io/github/stozo04/openloop/camera/lens/LensAnchorTest.kt @@ -1161,6 +1161,48 @@ class LensAnchorTest { ) } + // ---------------------------------------------------------------- Vampire lens geometry + + @Test + fun vampire_isAPropWithMeasuredLayersOnTheRightAnatomy() { + val torso = Lens.Vampire.art[0] + val costume = Lens.Vampire.art[1] + val fangs = Lens.Vampire.art[2] + val torsoExtent = extentInUnits(torso) + val costumeExtent = extentInUnits(costume) + + assertEquals("Vampire", Lens.Vampire.displayName) + assertNull("Vampire frames the subject's real face", Lens.Vampire.features) + assertEquals(LensInteraction.NONE, Lens.Vampire.interaction) + assertEquals(3, Lens.Vampire.art.size) + assertEquals(LensAnchorPoint.FACE, torso.placement.anchor) + assertEquals(LensAnchorPoint.FACE, costume.placement.anchor) + assertEquals(LensAnchorPoint.MOUTH, fangs.placement.anchor) + assertEquals("encoded 1024x585", 585f / 1024f, torso.placement.artAspect, 1e-4f) + assertEquals("encoded 954x1024", 1024f / 954f, costume.placement.artAspect, 1e-4f) + assertEquals("encoded 1024x642", 642f / 1024f, fangs.placement.artAspect, 1e-4f) + assertTrue("the cape must span wider than the face frame", torso.placement.widthInUnits > costume.placement.widthInUnits) + assertTrue("the torso must begin below the mouth", torsoExtent.endInclusive <= -1f) + assertTrue("the shirt must continue down the chest", torsoExtent.start <= -3.8f) + assertTrue("the cowl stops below the $CROWN_UNITS crown", costumeExtent.endInclusive >= CROWN_UNITS) + assertTrue("the collar stops above the $CHIN_UNITS chin", costumeExtent.start <= CHIN_UNITS) + } + + @Test + fun vampire_fangsHideAtRest_thenExtendWithoutMovingTheirRoots() { + val subject = face() + val frame = frameOf(subject) + val fangs = Lens.Vampire.art[2] + val shut = LensAnchor.sticker(subject, frame, fangs.placement, frameAspect, openFraction = 0f) + val open = LensAnchor.sticker(subject, frame, fangs.placement, frameAspect, openFraction = 1f) + val shutRoot = shut.centerY - shut.halfHeight + val openRoot = open.centerY - open.halfHeight + + assertEquals("a closed mouth must show no fang pixels", 0f, shut.halfHeight, tolerance) + assertTrue("opening the mouth must reveal the fangs", open.halfHeight > 0f) + assertEquals("the upper roots must stay attached to the mouth", shutRoot, openRoot, tolerance) + } + // ---------------------------------------------------------------- character head coverage @Test diff --git a/cspell.json b/cspell.json index f711cf47..c0d984b4 100644 --- a/cspell.json +++ b/cspell.json @@ -176,6 +176,7 @@ "foldable", "Foldables", "foojay", + "formalwear", "fossdroid", "fourcc", "freezedetect", diff --git a/docs/DEFINITION_OF_DONE.md b/docs/DEFINITION_OF_DONE.md index 09afb81e..841e8afb 100644 --- a/docs/DEFINITION_OF_DONE.md +++ b/docs/DEFINITION_OF_DONE.md @@ -295,7 +295,7 @@ A command finishing is **not** a passed build. Confirm all three: - [ ] No `xmlns:android` outside `app/src/*/res/` (AndroidManifest excepted): an XML file the app never packages cannot resolve that schema in any IDE, and reports `URI is not registered` on every open. Generator input carries plain attribute names. Checked by sweep gate 8c -- [ ] Generated art regenerates byte-identically: a change to `swarm/art/*.xml` or +- [ ] Generated art regenerates byte-identically: a change to `swarm/art/*` or `swarm/tools/render_lens_art.py` means re-running `python swarm/tools/render_lens_art.py` (needs `numpy`, `Pillow`, `scipy`) and confirming `git status` on `drawable-nodpi/` is clean, or committing the regenerated WebP. The provenance claim in PRD §16 is only true if this runs diff --git a/docs/PRD-aso-discoverability.md b/docs/PRD-aso-discoverability.md index 61a8e723..85ed15e8 100644 --- a/docs/PRD-aso-discoverability.md +++ b/docs/PRD-aso-discoverability.md @@ -483,7 +483,7 @@ Ordered. **Batch steps 2–4 into a single submission** to avoid the back-of-que > not loops alone. Verified: > > - **Seven lenses ship** — Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis (`camera/lens/Lens.kt`; Big Mouth and Bug Eyes were removed in #134). -> *(Count as of 2026-09-03: **eight**, with Cowboy — [`PRD-camera-lenses.md`](PRD-camera-lenses.md) §16. The 2026-08-09 note is left as written; only the tally moves, and none of the title reasoning below turns on it.)* +> *(Count as of 2026-09-03: **nine**, with Cowboy and Vampire — [`PRD-camera-lenses.md`](PRD-camera-lenses.md) §§16–17. The 2026-08-09 note is left as written; only the tally moves, and none of the title reasoning below turns on it.)* > - **Photo capture is built** — [PR #120, `feat(camera): photo capture mode (v1.0.37)`](https://github.com/stozo04/OpenLoop/pull/120), > merged 2026-08-09, adding a photo mode across `CameraScreen`, `OpenLoopUiState`, > `VideoStorageRepository` and `MediaStoreVideoPublisher`, with its own diff --git a/docs/PRD-camera-lenses.md b/docs/PRD-camera-lenses.md index cb3e9123..b220a5c3 100644 --- a/docs/PRD-camera-lenses.md +++ b/docs/PRD-camera-lenses.md @@ -827,3 +827,246 @@ separate commit from §16.6's placement fix and can be reverted on its own. an attempt to composite them onto the owner's capture required inpainting the shipped art out of it first, and that destroyed his beard, so it was thrown away rather than presented as evidence. What the art looks like on a real bearded face at real size remains the owner's check. + +--- + +## 17. 2026-09-03 — PRD: Vampire, the spooky lens + +> **Status: approved and implemented on 2026-09-03.** + +### 17.1 Problem and product answer + +OpenLoop needs one Halloween lens that is recognizable in a thumbnail, becomes more fun in motion, +and is polished enough to record and share rather than merely try once. The answer is **Vampire**: +a romantic-goth prop lens with a lacquer-black widow's-peak cowl, a black-and-burgundy velvet high +collar, and ivory fangs that lengthen when the wearer opens their mouth. + +The tone is glamorous and playful, not gory. The wearer's eyes, nose, skin and expression remain +theirs. At rest it reads as a complete costume; opening the mouth supplies the reveal that makes a +short loop worth sending. + +This is evidence-led rather than a seasonal guess: + +* Pinterest's 2026 global trend report calls out **vampire beauty +90%**, **dark romantic make-up + +160%**, and **gothic coffin nails +180%**, led by Gen Z and Millennials + ([Pinterest Predicts 2026](https://business.pinterest.com/pdf/pinterest-predicts/2026-trend-report/), + English-language search comparison through August 2025). +* The US National Retail Federation's 2025 survey still ranked vampire second among adult costumes, + planned by 2.7 million adults. That gives the concept durable recognition beyond one licensed + character or one year's meme + ([NRF Halloween survey](https://nrf.com/media-center/press-releases/nrf-consumer-survey-finds-halloween-spending-to-reach-record-13-1-billion)). +* Snap's current face-effects model explicitly treats mouth-open state and 2D face attachments as + native building blocks, while its optimization guidance ties broad reach to few resources, + compressed textures and cross-device testing + ([Face Effects](https://developers.snap.com/lens-studio/4.55.1/references/guides/lens-features/tracking/face/face-effects-overview), + [Performance and Optimization](https://developers.snap.com/lens-studio/publishing/optimization/performance-optimization-guide)). + +### 17.2 Why this concept wins + +| Candidate | What works | Why it loses here | +| ----------------------- | ---------------------------------- | ------------------------------------------------------- | +| Romantic vampire | Trend-backed, iconic, mouth reveal | **Chosen** | +| Witch | Broadly recognizable | Another hat is too close to Cowboy; little video payoff | +| Pumpkin or skull head | Strong silhouette, easy thumbnail | Repeats the existing opaque character-head pattern | +| Horror clown | Snap has seasonal precedent | Face paint needs a new mask path to look convincing | +| Ghost or background VFX | Atmospheric | Needs segmentation or particles outside this lens slice | + +Vampire is the only option that adds a new kind of moment while reusing proven machinery. It is +the Elvis pattern—photographic props around a real face—plus the existing Twisted Tongue mouth +response. No renderer work is required. + +### 17.3 Experience and art direction + +The first frame must read in this order: **face → fangs → velvet silhouette**. The lens must not +bleach skin, recolor eyes, cover brows, or prescribe gender. Its drama comes from materials and +silhouette: cool black lacquer, charcoal velvet, a deep oxblood satin lining, an aged-silver ruby +bow-tie clasp, a narrow white shirtfront, warm ivory teeth and one restrained crimson glint. + +Three transparent WebP layers keep the face frame independent from the upper-body garment: + +1. `lens_vampire_torso_art.webp` on `FACE`: a broad velvet cape, lapels, bow tie and pleated tuxedo + shirt extending from the base of the neck down the chest and across both shoulders. +2. `lens_vampire_frame_art.webp` on `FACE`: widow's-peak cowl above the brow and raised collar + outside the cheeks, with the entire central face left transparent. +3. `lens_vampire_fangs_art.webp` on `MOUTH`: two upper canines whose top edge stays at the mouth + anchor while `MouthOpenSpec` extends them downward. + +`lens_vampire.webp` is a 320x320 carousel chip composed from those exact layers, never redrawn. +The final art is original image-generated material, not copied character or costume photography. +The prompt and generated source are recorded with the implementation; alpha is inspected after +encoding rather than inferred from how an image viewer displays it. + +### 17.4 Scope, constraints and non-goals + +In scope: + +* one `Lens.Vampire` catalogue entry and four original WebP assets; +* measured face-unit placement, including the brow and chin rows already established here; +* a visible mouth-open fang response using the current easing and tracker; +* existing two-face, front/back, preview/recording and carousel behavior; +* focused catalogue/geometry assertions, installed-app evidence, store copy and README alignment. + +Not in scope: + +* a new shader, face mesh, segmentation, particles, audio, skin smoothing or eye recoloring; +* hand-flick interaction—the costume and fangs are worn props, so `interaction = NONE`; +* pose tracking or body segmentation; the garment follows the existing face scale and rotation; +* licensed Dracula, Nosferatu or film likenesses; +* a category row or seasonal carousel architecture for a single lens. + +The implementation must add no dependency, camera rebind or GL program. Each texture stays within +the renderer's 1024 px cap and is cropped to useful pixels. Existing catalogue-driven paths remain +the source of truth. + +### 17.5 Success criteria + +1. A person can identify “vampire” from the 56 dp unselected carousel chip. +2. At rest the cowl and collar frame the face while the bow tie sits at the neck and the shirt, + lapels and cape continue across the chest and both shoulders. +3. Opening the mouth visibly lengthens both fangs from their upper attachment without sliding the + roots, inflating around the lips or covering the chin. +4. A head tilt and open-mouth reveal remain steady for both tracked faces, on front and back camera, + and the recorded MP4 matches the live preview. +5. The effect adds no renderer or dependency work and preserves the no-lens frame rate behavior. +6. Final-head `pre-pr-sweep.ps1` is green; installed-art hashes match the repo assets; the PR carries + a real-face screenshot or video-frame proof plus an explicit hardware QA checklist. + +### 17.6 Minimal implementation and verification plan + +1. Generate separate frame, torso and fang cut-outs, inspect them at actual lens scale, crop and + encode to WebP, then compose the chip from those same layers. +2. Measure encoded alpha bounds. Solve `widthInUnits`, `artAspect`, `upInUnits` and fang + `restFraction` from anatomy and the anchored-edge equation; do not nudge against a screenshot. +3. Add `Lens.Vampire` only. Reuse `FACE`, `MOUTH` and `MouthOpenSpec`; do not touch + `LensSurfaceProcessor`. +4. Add the smallest geometry checks that existing catalogue tests cannot prove: the torso spans the + shoulders and chest, the face opening stays clear, and fang roots stay attached from shut to open. +5. Run the focused JVM checks, generate a schematic preview, then run the full final-commit sweep and + the installed lens verifier. Judge tracking and shareability only from a real face, never from the + schematic or a painting. + +### 17.7 Open question for sign-off + +Approve **Vampire** as described: romantic-goth, real face visible, three generated photographic +layers, and a mouth-open fang reveal. The default display name is the direct, searchable +**“Vampire”** rather than a branded name. + +### 17.8 Implementation record + +The owner approved the concept on 2026-09-03. The shipped implementation is one catalogue entry +using the existing renderer: `FACE` for the upper-body costume and face frame, `MOUTH` plus +`MouthOpenSpec(0)` for the fangs, and no interaction or new dependency. The first installed portrait +pass used `0.58`, then the emulator pass shortened the rest state to `0.40`. Owner hardware on +2026-09-04 showed that `0.40` still read as always-on fangs with a closed mouth, so the final state +hides them completely. The full open state is unchanged and the tested upper edge remains fixed, +making the jaw drop a reveal rather than merely a size change. + +The same hardware review rejected a compact formalwear revision because its bow tie and shirt looked +attached to the chin. The final design therefore separates the raised face collar from a broad +upper-body layer. The bow now sits at the base of the neck, the pleated shirt continues down the +chest, and the cape and lapels span both shoulders. The body layer follows face scale and rotation; +there is deliberately no pose-tracking dependency, so independent head-versus-torso motion remains +a hardware QA boundary. + +| Asset | Encoded size | File size | Source | +| ---------------------------------- | ------------ | --------- | ------------------------------------------- | +| `lens_vampire_torso_art.webp` | 1024×585 | 65.3 KB | `swarm/art/lens_vampire_torso_source.png` | +| `lens_vampire_frame_art.webp` | 954×1024 | 108.6 KB | `swarm/art/lens_vampire_frame_source.png` | +| `lens_vampire_fangs_art.webp` | 1024×642 | 57.8 KB | `swarm/art/lens_vampire_fangs_source.png` | +| `lens_vampire.webp` carousel chip | 320×320 | 18.7 KB | composed from the three encoded layers | + +`python swarm/tools/render_lens_art.py` removes only border-connected light backdrop pixels from +the generated costume sources, thresholds near-invisible alpha below 8, crops with an 8 px margin, +fits the longest edge to the renderer's 1024 px cap, and encodes WebP at quality 90. Its built-in +self-check proves that a closed central face opening clears while a white shirt reaching the bottom +edge remains opaque. The frame +measures x ±1.50 face units and y +1.260 through −1.960. The torso measures x ±2.40 and y −1.129 +through −3.871, overlapping the collar while continuing well below the chin. + +The production sources were generated with the built-in image generator. The committed PNGs, not +the transient generation folder, are the reproducible inputs. The torso and revised frame arrived +as opaque RGB checkerboard renders despite explicit alpha requests, so the deterministic render +step performs the connected-background cut-out and preserves enclosed costume whites. + +**Costume frame prompt:** + +> Use case: stylized-concept. Asset type: production AR face-lens transparent bitmap layer for an +> Android camera app. Create one original, premium romantic-goth vampire costume frame that +> surrounds but does not cover a real human face. Combine a sculpted lacquer-black widow's-peak +> cowl above the forehead with a dramatic high black velvet collar around the lower sides, deep +> oxblood satin lining, and one small aged-silver clasp below the chin. Costume pieces only; +> absolutely no person, face, skin, eyes, mouth, teeth, head, shoulders, mannequin, or background. +> Photoreal front-facing studio product render, Snapchat-quality baked 3D prop, detailed hair +> strands, velvet nap, satin sheen, subtle aged metal, glamorous and playful rather than gory. +> Perfectly symmetrical straight-on orthographic view; cowl fills the upper third, collar rises +> along both outer cheek areas and meets below the chin; a large clean transparent opening occupies +> the entire center for the user's real face; all costume parts fully inside the canvas with modest +> transparent margin; no perspective or three-quarter angle. Soft cool key light from upper left +> with restrained warm rim light; rich dimensional materials readable over both bright and dark +> camera scenes. Near-black, charcoal, deep oxblood burgundy, restrained aged silver. Genuinely +> transparent RGBA background and central face opening; clean anti-aliased edges; no green-screen +> background, detached drop shadow, text, logo, watermark, bats, blood, jewelry, makeup, or extra +> objects; original design with no recognizable film character likeness; designed to be cropped +> and downscaled to 1024 pixels without losing material detail. Avoid cartoon, flat vector, +> illustration, Halloween clip art, gore, white skin, red eyes, face mask, horns, cape shoulders +> extending beyond frame, and asymmetric hair. + +**Rejected 2026-09-04 compact-formalwear iteration:** + +> Edit the FIRST image only; it is the production transparent RGBA face-overlay source. Preserve +> its canvas, exact lacquer-black widow's-peak hair, oxblood velvet high collar, silhouette, +> lighting, textures, central transparent face opening, and overall photoreal quality. Use the +> SECOND image only as a simple silhouette reference for Dracula formalwear. At the bottom center +> of the first overlay, replace the existing small jeweled throat bow/clasp treatment with a +> clearly recognizable elegant black velvet bow tie with subtle oxblood satin inner highlights. +> Add only a SHORT, narrow formal shirtfront directly below the chin: a small crisp white triangular +> shirt opening, two tiny dark buttons, and restrained black velvet lapel edges. Keep this +> formalwear centered and symmetrical, cropped well before the shoulders, and contained inside the +> existing lower collar footprint so it stays believable when anchored to a tracked face. Retain a +> small aged-silver ruby clasp as the bow-tie knot detail. Do not add a torso, broad shoulders, +> jacket sleeves, skin, face, facial features, ears, fangs, mouth, bat, text, watermark, opaque +> background, or white background. The entire central head/face opening and all space outside the +> costume must remain truly transparent alpha. Output a single high-resolution RGBA PNG suitable +> as a production AR overlay, matching the first image's dimensions and composition. + +The owner rejected that result after seeing it on hardware: keeping the formalwear inside the face +frame made it look attached to the chin. The final art direction split the costume into two assets. +The face-frame edit removed the clasp and bow while preserving the exact widow's-peak hair, raised +black-and-oxblood collar, face opening, material finish and symmetry. The new upper-body brief was: + +> Create a separate front-facing production AR costume overlay that begins at the base of the neck +> and continues down the upper chest. Use a large black velvet bow tie with oxblood accents and an +> aged-silver ruby clasp, a crisp pleated white tuxedo shirt with three black buttons, black satin +> lapels, and a sweeping black velvet cape with deep oxblood lining spread naturally across both +> shoulders. Leave a broad transparent U-shaped opening above the bow for the wearer's real neck +> and face. Costume only: no person, skin, head, hands, fangs, text, logo or licensed likeness. +> Premium photoreal baked-3D materials, symmetrical straight-on view, clean anti-aliased edges, +> glamorous rather than gory, and enough shoulder width and chest depth to feel worn instead of +> floating below the chin. + +Repeated alpha-cleanup generations still returned opaque checkerboards. Rather than accepting a +visual simulation of transparency, the committed renderer cuts out only the exterior-connected +light backdrop. That deterministic rule leaves the enclosed white tuxedo shirt intact. + +**Fang prompt:** + +> Use case: stylized-concept. Asset type: production AR face-lens transparent bitmap layer for an +> Android camera app. Create a matched pair of elegant vampire upper canine fangs as one transparent +> asset. The two fangs hang straight downward from an invisible upper gum line and are separated by +> a wide empty center so the user's real front teeth and mouth remain visible. Exactly two upper +> canine teeth only; no mouth, lips, gums, face, skin, lower teeth, tongue, blood drip, or background. +> Photoreal baked 3D dental prop matching a premium romantic-goth vampire costume; warm ivory enamel +> with subtle translucency at the tips, tiny natural surface detail, soft wet gloss, restrained cool +> shadow at each root, one extremely subtle dark-crimson specular glint but no gore. Perfectly +> symmetrical straight-on orthographic view; both roots aligned on the same horizontal top line; +> fangs point downward and slightly inward; generous transparent gap between them; the entire asset +> is wide and shallow with tight transparent margins; roots are the topmost visible pixels so scaling +> from the top edge makes the fangs extend out of the mouth. Same soft cool upper-left key and warm +> rim as a polished studio product render. Warm ivory, pearl white, faint cool gray, one restrained +> deep-crimson reflection. Genuinely transparent RGBA background; crisp anti-aliased edges; no green +> screen, text, logo, watermark, extra teeth, mouth anatomy, jaw, gums, blood, drops, splatter, +> Halloween clip art, or recognizable character likeness; optimized to downscale cleanly for a 2D +> face attachment. Avoid cartoon fangs, plastic novelty teeth, full dentures, four fangs, tusks, +> horns, red background, gore, asymmetry, and perspective angle. + +Installed evidence is in [`e2e/2026-09-03-vampire-lens-proof.md`](e2e/2026-09-03-vampire-lens-proof.md). diff --git a/docs/e2e/2026-09-03-vampire-baked-trim.webp b/docs/e2e/2026-09-03-vampire-baked-trim.webp new file mode 100644 index 00000000..8aeabad3 Binary files /dev/null and b/docs/e2e/2026-09-03-vampire-baked-trim.webp differ diff --git a/docs/e2e/2026-09-03-vampire-closed-fixture.webp b/docs/e2e/2026-09-03-vampire-closed-fixture.webp new file mode 100644 index 00000000..f7ba2205 Binary files /dev/null and b/docs/e2e/2026-09-03-vampire-closed-fixture.webp differ diff --git a/docs/e2e/2026-09-03-vampire-lens-proof.md b/docs/e2e/2026-09-03-vampire-lens-proof.md new file mode 100644 index 00000000..f76dde36 --- /dev/null +++ b/docs/e2e/2026-09-03-vampire-lens-proof.md @@ -0,0 +1,66 @@ +# E2E proof — Vampire lens + +**Feature:** [`PRD-camera-lenses.md` §17](../PRD-camera-lenses.md#17-2026-09-03--prd-vampire-the-spooky-lens) + +| Run | Date | Device | +| ------------- | ---------- | --------------------------------------------- | +| Installed APK | 2026-09-03 | Pixel_8 AVD, Android 16 / API 37, back camera | +| Full-body APK | 2026-09-04 | Pixel_8 AVD, Android 16 / API 37, back camera | + +## What passed + +| Check | Result | +| ----------------------------------- | --------------------------------------------------------------------------------------------------------------- | +| Focused geometry tests | `LensAnchorTest`: hidden rest state, full mouth-open reveal and fixed fang roots | +| Full JVM suite | 647 tests, 0 failures, 0 errors, 0 skipped | +| Connected instrumentation | 123 tests, 0 failures, 0 errors, 1 Samsung-only skip | +| Installed catalogue loop | Final full-body APK: `PASS ... lens=Vampire drawer=open pick->clear took=61s` | +| Encoded art in APK | SHA-256 matched all four final repository WebPs byte-for-byte | +| Live closed-mouth bind | Public-domain Mona Lisa fixture: pre-hardware `0.40` fang scale, retained as iteration evidence | +| Mouth-open response | Original fictional generated portrait: long fangs, roots at the upper mouth, eyes/brows/nose/expression visible | +| Recording bake | 5 s capture reached Trim as a 9.20 s virtual-camera clip with Vampire in preview and every filmstrip tile | + +The AVD reported the same-shape geometry expected by the lens renderer: + +```text +OpenLoopLens: Lens output targets=3 size=1280x960 inputDet=-1.0 outputDet=-1.0 +``` + +## Screenshots + +| File | What it proves | +| -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| [`2026-09-03-vampire-closed-fixture.webp`](2026-09-03-vampire-closed-fixture.webp) | Superseded `restFraction = 0.40` emulator pass; owner hardware later showed the closed-mouth fangs were still too prominent | +| [`2026-09-03-vampire-open-mouth-generated-fixture.webp`](2026-09-03-vampire-open-mouth-generated-fixture.webp) | Wide-open mouth drives the full fang reveal without covering the eyes, brows, nose, or expression; the portrait is fictional and verification-only | +| [`2026-09-03-vampire-baked-trim.webp`](2026-09-03-vampire-baked-trim.webp) | The effect is baked into the captured clip and its Trim filmstrip, not limited to the live preview | +| [`2026-09-04-vampire-full-body-fixture.webp`](2026-09-04-vampire-full-body-fixture.webp) | Final installed APK on the public-domain fixture: raised collar, neck-level bow, shoulder cape and shirt continuing down the chest | + +Owner front-camera QA on 2026-09-04 confirmed the raised collar and full-open fang treatment on a +real face. It also rejected the `0.40` closed-mouth state as visibly always-on, so the follow-up sets +`restFraction = 0`. A second hardware composite rejected the compact bow and shirt because they +looked attached to the chin. The final revision uses a separate upper-body layer spanning both +shoulders and extending down the chest. The personal QA photos were reviewed locally and are +intentionally not committed to this public repository. The zero-rest behavior and full-body costume +still require one owner retest. + +The closed fixture is a flat public-domain painting already committed as +`app/src/androidTest/assets/face_fixture.jpg`. The open-mouth fixture was generated specifically for +this run and was not shipped or committed. Both are static posters on the emulator's virtual-scene +wall; neither is evidence of real-person motion quality. + +## Honest residual hardware QA + +The emulator proves catalogue reachability, decode, tracking on static portrait-like images, +mouth-state response, CameraEffect attachment, and recording bake. It does **not** prove the +following, which needs a real person on a real phone: + +- [ ] front-camera mirroring and rear-camera placement on a live face; +- [ ] bow tie at the neck and cape/shirt coverage during independent head and shoulder movement; +- [ ] steadiness during head turns, tilt, motion blur, and changing light; +- [ ] two live faces at once and a third face not stealing a slot; +- [ ] sustained frame rate on mid-range hardware; +- [ ] subjective shareability across different face shapes and skin tones. + +No physical phone was attached, and the host webcam was not activated without the owner's explicit +camera consent. These checks remain the PR's human QA checklist rather than being overstated as +automated evidence. diff --git a/docs/e2e/2026-09-03-vampire-open-mouth-generated-fixture.webp b/docs/e2e/2026-09-03-vampire-open-mouth-generated-fixture.webp new file mode 100644 index 00000000..20e0b04d Binary files /dev/null and b/docs/e2e/2026-09-03-vampire-open-mouth-generated-fixture.webp differ diff --git a/docs/e2e/2026-09-04-vampire-full-body-fixture.webp b/docs/e2e/2026-09-04-vampire-full-body-fixture.webp new file mode 100644 index 00000000..1f88d9b5 Binary files /dev/null and b/docs/e2e/2026-09-04-vampire-full-body-fixture.webp differ diff --git a/docs/index.html b/docs/index.html index ccd48aa8..13d608d9 100644 --- a/docs/index.html +++ b/docs/index.html @@ -107,7 +107,7 @@

OpenLoop
Loops, lenses & photos — on you

What it does

  • Speed-controlled loopsPick forward, reverse, or either bounce, then set playback speed with a live preview — not three fixed presets.
  • -
  • Live face lensesSeven of them — Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue and Elvis — render on the camera preview in real time and record into your clip. Two people in the shot? Both get the lens.
  • +
  • Live face lensesNine of them — Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy and Vampire — render on the camera preview in real time and record into your clip. Open your mouth and Vampire's fangs grow. Two people in the shot? Both get the lens.
  • Photo modeTap the shutter for a still instead of a clip — lenses included.
  • Trim, look, shareA two-handle trim bar, color looks, and your phone's normal share sheet.
  • On-device processingEvery frame is decoded, reversed and re-encoded on your phone. Your video is never uploaded.
  • diff --git a/docs/play-store/store-listing.md b/docs/play-store/store-listing.md index 8c93c4ee..1329fd1a 100644 --- a/docs/play-store/store-listing.md +++ b/docs/play-store/store-listing.md @@ -77,7 +77,7 @@ OpenLoop turns a quick clip into a smooth, speed-controlled video loop — a "bo WHAT YOU CAN DO • Capture a clip with the built-in camera, or import one you already have. -• Add a live face lens — Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue or Elvis — they track your face as you record, and some react to you. Two of you in the shot? You both get the lens. +• Add a live face lens — Broccoli, Shades, Pizza Face, Football, Dog, Twisted Tongue, Elvis, Cowboy or Vampire — they track your face as you record, and some react to you. Open your mouth and Vampire's fangs grow. Two of you in the shot? You both get the lens. • Take photos too, lenses included, with the same shutter. • Run the Photo Booth: a 5-4-3-2-1 countdown ×3, printed into a classic vertical strip — color or black & white. • Trim to the exact moment with a simple two-handle bar. diff --git a/swarm/art/lens_vampire_fangs_source.png b/swarm/art/lens_vampire_fangs_source.png new file mode 100644 index 00000000..37803ab5 Binary files /dev/null and b/swarm/art/lens_vampire_fangs_source.png differ diff --git a/swarm/art/lens_vampire_frame_source.png b/swarm/art/lens_vampire_frame_source.png new file mode 100644 index 00000000..3b97b07f Binary files /dev/null and b/swarm/art/lens_vampire_frame_source.png differ diff --git a/swarm/art/lens_vampire_torso_source.png b/swarm/art/lens_vampire_torso_source.png new file mode 100644 index 00000000..8fae27b5 Binary files /dev/null and b/swarm/art/lens_vampire_torso_source.png differ diff --git a/swarm/tools/render_lens_art.py b/swarm/tools/render_lens_art.py index ffde6928..aca2594f 100644 --- a/swarm/tools/render_lens_art.py +++ b/swarm/tools/render_lens_art.py @@ -1,12 +1,16 @@ #!/usr/bin/env python -"""Render Cowboy's shipped WebP art from its committed silhouettes. +"""Render the shipped Cowboy and Vampire WebP lens art from committed sources. Elvis's photoreal assets arrived as opaque binaries: nobody can re-derive them, and a change means re-running a pipeline that is not in this repo. Cowboy's are **generated here**, so the art has the same provenance as the code — the silhouettes in `swarm/art/` carry the tracing that produced them (`docs/PRD-camera-lenses.md` §16.1), and everything below turns them into lit, textured surfaces. -Run it from the repo root; it overwrites the three assets in `drawable-nodpi/`: +Vampire's image-generated sources are committed beside those silhouettes. This tool removes either +near-invisible alpha or the border-connected light backdrop, crops the useful pixels, fits each +layer under the renderer's 1024 px cap, and composes the carousel chip from the shipped layers. + +Run it from the repo root; it overwrites the seven assets in `drawable-nodpi/`: python swarm/tools/render_lens_art.py @@ -47,6 +51,7 @@ from scipy.ndimage import ( binary_dilation, binary_erosion, + binary_propagation, distance_transform_edt, gaussian_filter, gaussian_filter1d, @@ -62,6 +67,7 @@ SS = 3 # supersample factor; the alpha edge is what needs it OUT_W = 1024 # LensSurfaceProcessor.MAX_ART_PX QUALITY = 90 +ALPHA_FLOOR = 8 # ------------------------------------------------------------------ silhouette rasterising @@ -188,6 +194,90 @@ def save(rgb, alpha, out_w, path: Path): return img +def cut_out_connected_light_background( + image: Image.Image, + *, + clear_center: bool = False, +) -> Image.Image: + """Turn a generated light checkerboard backdrop into alpha without touching enclosed whites.""" + rgb = np.array(image.convert("RGB")) + channel_spread = rgb.max(axis=2) - rgb.min(axis=2) + light_neutral = (channel_spread <= 24) & (rgb.min(axis=2) >= 170) + seeds = np.zeros(light_neutral.shape, dtype=bool) + seeds[0] = light_neutral[0] + seeds[:, 0] = light_neutral[:, 0] + seeds[:, -1] = light_neutral[:, -1] + if clear_center: + center = image.height // 2, image.width // 2 + seeds[center] = light_neutral[center] + background = binary_propagation(seeds, mask=light_neutral) + + # Eat two neutral antialias pixels at the garment boundary, then rebuild a soft alpha edge. + neutral_fringe = (channel_spread <= 30) & (rgb.min(axis=2) >= 70) + for _ in range(2): + background |= binary_dilation(background) & neutral_fringe + foreground = ~binary_dilation(background) + alpha = np.clip(gaussian_filter(foreground.astype(float), 0.65), 0, 1) + + rgba = np.dstack((rgb, np.round(alpha * 255).astype(np.uint8))) + return Image.fromarray(rgba, "RGBA") + + +def check_light_background_cutout() -> None: + """Prove that a face hole clears while a white shirt remains opaque.""" + pixels = np.full((11, 11, 3), 240, dtype=np.uint8) + pixels[3, 3:8] = 0 + pixels[3:, 3] = 0 + pixels[3:, 7] = 0 + pixels[4:, 4:7] = 255 + alpha = np.array(cut_out_connected_light_background(Image.fromarray(pixels)).getchannel("A")) + assert alpha[0, 0] == 0 + assert alpha[-1, 5] > 240 + + ring = np.full((11, 11, 3), 240, dtype=np.uint8) + ring[2, 2:9] = 0 + ring[8, 2:9] = 0 + ring[2:9, 2] = 0 + ring[2:9, 8] = 0 + ring_alpha = np.array( + cut_out_connected_light_background( + Image.fromarray(ring), + clear_center=True, + ).getchannel("A"), + ) + assert ring_alpha[5, 5] == 0 + + +def prepare_generated( + source: str, + output: str, + *, + cutout_light_background: bool = False, + clear_center: bool = False, +) -> Image.Image: + """Crop and encode an image-generated source without inventing costume pixels.""" + image = Image.open(SILHOUETTES / source) + if cutout_light_background: + image = cut_out_connected_light_background(image, clear_center=clear_center) + pixels = np.array(image.convert("RGBA")) + pixels[..., 3] = np.where(pixels[..., 3] >= ALPHA_FLOOR, pixels[..., 3], 0) + image = Image.fromarray(pixels) + bbox = image.getchannel("A").getbbox() + if bbox is None: + raise ValueError(f"{source} has no visible pixels") + left, top, right, bottom = bbox + pad = 8 + image = image.crop((max(0, left - pad), max(0, top - pad), + min(image.width, right + pad), min(image.height, bottom + pad))) + scale = min(1.0, OUT_W / max(image.size)) + if scale < 1.0: + image = image.resize((round(image.width * scale), round(image.height * scale)), Image.LANCZOS) + out = DRAWABLES / output + image.save(out, format="WEBP", quality=QUALITY, method=6) + print(f" {out.name} {image.width}x{image.height} {out.stat().st_size / 1024:.1f} KB") + return image + + # ------------------------------------------------------------------ the hat def render_hat() -> Image.Image: @@ -338,6 +428,45 @@ def render_thumbnail(hat: Image.Image, mustache: Image.Image) -> None: print(f" {out.name} {size}x{size} {out.stat().st_size / 1024:.1f} KB") +def render_vampire() -> None: + torso = prepare_generated( + "lens_vampire_torso_source.png", + "lens_vampire_torso_art.webp", + cutout_light_background=True, + ) + frame = prepare_generated( + "lens_vampire_frame_source.png", + "lens_vampire_frame_art.webp", + cutout_light_background=True, + clear_center=True, + ) + fangs = prepare_generated("lens_vampire_fangs_source.png", "lens_vampire_fangs_art.webp") + + size = 320 + chip = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + torso_width = 318 + torso_height = round(torso.height * torso_width / torso.width) + torso_scaled = torso.resize((torso_width, torso_height), Image.LANCZOS) + chip.alpha_composite(torso_scaled, ((size - torso_width) // 2, 102)) + + frame_height = 244 + frame_width = round(frame.width * frame_height / frame.height) + frame_scaled = frame.resize((frame_width, frame_height), Image.LANCZOS) + chip.alpha_composite(frame_scaled, ((size - frame_width) // 2, 8)) + + fang_width = 92 + fang_height = round(fangs.height * fang_width / fangs.width) + fangs_scaled = fangs.resize((fang_width, fang_height), Image.LANCZOS) + chip.alpha_composite(fangs_scaled, ((size - fang_width) // 2, 177)) + + out = DRAWABLES / "lens_vampire.webp" + chip.save(out, format="WEBP", quality=QUALITY, method=6) + print(f" {out.name} {size}x{size} {out.stat().st_size / 1024:.1f} KB") + + if __name__ == "__main__": + check_light_background_cutout() print("rendering Cowboy art:") render_thumbnail(render_hat(), render_mustache()) + print("rendering Vampire art:") + render_vampire()