fix: handle player loop and render exit (#617)

## Problem

Two newly reported runtime issues break common local workflows:

- Fixes #615: `<hyperframes-player loop>` reaches the final frame, receives a paused runtime state, and stays paused instead of wrapping.
- Fixes #616: `hyperframes render` can finish writing the output and print `Render complete`, but still remain alive when a non-essential handle keeps Node's event loop open.

The catalog block also used old VPN branding and slug/file names that should now be neutral. Renaming registry items also exposed a catalog-preview CI bug where deleted registry paths were treated as still-renderable changed items.

## What this fixes

- detects player completion from the previous playing state before mutating the parent `_paused` cache from the runtime's final state
- wraps looping players back to `0` and immediately resumes playback even when the runtime posts `isPlaying: false` at the end frame
- keeps non-looping players dispatching the existing `ended` flow
- lets the CLI command path schedule a short unref'd `process.exit(0)` after a successful local or Docker render
- keeps `renderLocal()` importable for tests and internal callers without forcing process exit unless the CLI command explicitly opts in
- adds regression coverage for the player loop end-state and successful render exit scheduling
- renames the VPN catalog block to `vpn-youtube-spot` across registry, docs route, install command, composition filename, asset filename, composition id, and timeline key
- keeps visible block/app copy friendly and named `VPN`
- updates catalog-preview CI to ignore deleted registry paths when computing changed preview items

## Root cause

The player message handler updated `_paused = !data.isPlaying` before checking for end-of-composition loop behavior. The runtime's legitimate final-frame state has `isPlaying: false`, so the existing `currentTime >= duration && !paused` loop branch was skipped.

For render completion, the CLI returned after `printRenderComplete()`, leaving process lifetime entirely to Node's active handles. Most local renders in this checkout drain cleanly, but the reported npm flow shows a sleeping parent process after output is already complete. The CLI now schedules a short unref'd successful exit only from the command path after user-visible render work has completed.

The catalog block issue was content/metadata drift: registry/docs/code identifiers still used the old slug, so the catalog route, install command, composition id, file names, and source prompt did not match the requested neutral VPN naming. The preview workflow used plain `git diff --name-only`, which includes deleted paths during renames; it now filters to added/copied/modified/renamed live paths.

## Verification

### Local checks

- `bun run build:hyperframes-runtime`
- `bun run --filter @hyperframes/player test -- src/hyperframes-player.test.ts`
- `bun run --filter @hyperframes/cli test -- src/commands/render.test.ts`
- `bun run --filter @hyperframes/player typecheck`
- `bun run --filter @hyperframes/cli typecheck`
- `bunx oxfmt --check packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bunx oxlint packages/player/src/hyperframes-player.ts packages/player/src/hyperframes-player.test.ts packages/cli/src/commands/render.ts packages/cli/src/commands/render.test.ts`
- `bun run --filter @hyperframes/player build`
- `bun run --filter @hyperframes/studio build`
- `bun run --filter @hyperframes/cli build`
- `bunx oxfmt --check registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxlint registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html registry/blocks/vpn-youtube-spot/registry-item.json registry/registry.json docs/catalog/blocks/vpn-youtube-spot.mdx docs/docs.json docs/public/catalog-index.json`
- `bunx oxfmt --check .github/workflows/catalog-previews.yml`
- `BASE_SHA=26b8e2a9853eb1a8f77c05fb0c8f0903cdb2cf18; git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ ...` returns only `vpn-youtube-spot`
- `npx tsx scripts/sync-schemas.ts --check`
- `npx mint validate` from `docs/`
- `npx mint broken-links` from `docs/`
- `git diff --check`
- Lefthook pre-commit: format pass
- Lefthook commit-msg: commitlint pass

### Browser verification

- Built the player bundle and served a real local reproduction using the built player, the built HyperFrames runtime, and GSAP.
- Used `agent-browser` to open the page, click `Seek near end`, and wait through the end-frame transition.
- Verified the browser state after playback: `stuck=false`, `looped=true`, and playback continued after wrapping from ~4s back to the start.
- Served `registry/blocks/vpn-youtube-spot/vpn-youtube-spot.html` locally, used `agent-browser` to seek the timeline, and verified `window.__timelines` contains `vpn-youtube-spot`, not `goonvpn-youtube-spot`.
- Served the docs locally with Mintlify, opened `/catalog/blocks/vpn-youtube-spot`, and verified the install command is `npx hyperframes add vpn-youtube-spot` with no old slug visible.

### Composition verification

- `bun run --filter @hyperframes/cli dev lint /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if` returned 0 errors and 1 existing large-composition warning.
- `bun run --filter @hyperframes/cli dev validate /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --timeout 5000` returned 0 console errors; it reported existing non-fatal contrast audit warnings from the block styling.
- `bun run --filter @hyperframes/cli dev render /var/folders/3n/hxk3qmnd0tl284jtcy66w6dw0000gn/T/hf-vpn-renamed-w027if --output /tmp/hf-vpn-renamed-proof.mp4 --fps 30 --quality draft --workers 1 --no-browser-gpu` completed successfully.
- `ffprobe -v error -show_entries format=duration,size -of default=noprint_wrappers=1 /tmp/hf-vpn-renamed-proof.mp4` reported `duration=7.000000`.

### Render verification

- Ran a real 1920x1080, 5-second render with `--gpu --workers 6 --quality draft --fps 24`.
- Verified the command printed `Render complete` and the parent process exited with code `0` in the wrapper: `RENDER_EXIT_PROOF code=0 signal=null sawComplete=true`.

## Notes

- I could not reproduce the exact indefinite #616 render hang on this checkout; both tiny and GPU/6-worker local renders exited cleanly before and after the patch. The CLI guard still addresses the reported leaked-handle failure mode because it fires only after successful render completion.
- Browser proof artifacts were local-only: `/tmp/hf-player-loop-proof-final.png`, `/tmp/hf-player-loop-proof-final.webm`, `/tmp/hf-vpn-code-rename-proof.png`, `/tmp/hf-vpn-code-rename-proof.webm`, `/tmp/hf-vpn-doc-route-rename-proof.png`, and `/tmp/hf-vpn-doc-route-rename-proof.webm`.
- The renamed composition render artifact was local-only: `/tmp/hf-vpn-renamed-proof.mp4`.
- The CLI exit guard is only enabled by the `render` command's top-level local/Docker calls. Direct test/internal calls to `renderLocal()` do not force process exit unless they pass `exitAfterComplete: true`.
This commit is contained in:
Miguel Ángel
2026-05-04 23:31:30 +02:00
committed by GitHub
parent f1d408eed3
commit 7affa4a4e9
14 changed files with 265 additions and 123 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
run: |
# Find which blocks/components changed in this PR
BASE_SHA=${{ github.event.pull_request.base.sha }}
CHANGED_ITEMS=$(git diff --name-only "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ \
CHANGED_ITEMS=$(git diff --name-only --diff-filter=ACMR "$BASE_SHA"...HEAD -- registry/blocks/ registry/components/ \
| grep -E '^registry/(blocks|components)/' \
| sed 's|^registry/[^/]*/\([^/]*\)/.*|\1|' \
| sort -u)
@@ -1,53 +0,0 @@
---
title: "GoonVPN YouTube Spot"
description: "Snappy Apple-style YouTube insert showing a phone finding and installing a fictional VPN app with sound effects."
---
# GoonVPN YouTube Spot
Snappy Apple-style YouTube insert showing a phone finding and installing a fictional VPN app with sound effects.
`app` `showcase` `youtube` `sfx`
Created by [Stronkter](https://x.com/Stronkter).
## Source Prompt
```text
📷HyperFrames by HeyGen make me a 7s video apple style bold font and styling a phone scrolling in a appstore clicking on a vpn app called goonVPN and installing it the app installs the phone snaps down and fades to a white backround make it snappy and pop its for a youtube video also add soundfx and make it 60fps and 1920x1080
```
<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/goonvpn-youtube-spot.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/goonvpn-youtube-spot.png" autoPlay muted loop playsInline />
## Install
<CodeGroup>
```bash Terminal
npx hyperframes add goonvpn-youtube-spot
```
</CodeGroup>
## Details
| Property | Value |
| --- | --- |
| Type | Block |
| Dimensions | 1920×1080 |
| Duration | 7s |
## Files
| File | Target | Type |
| --- | --- | --- |
| `goonvpn-youtube-spot.html` | `compositions/goonvpn-youtube-spot.html` | hyperframes:composition |
| `assets/goonvpn-sfx.wav` | `assets/goonvpn-sfx.wav` | hyperframes:asset |
## Usage
After installing, add the block to your host composition:
```html
<div data-composition-id="goonvpn-youtube-spot" data-composition-src="compositions/goonvpn-youtube-spot.html" data-start="0" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
```
+53
View File
@@ -0,0 +1,53 @@
---
title: "VPN YouTube Spot"
description: "Snappy Apple-style YouTube insert showing a phone finding and installing a friendly VPN app with sound effects."
---
# VPN YouTube Spot
Snappy Apple-style YouTube insert showing a phone finding and installing a friendly VPN app with sound effects.
`app` `showcase` `youtube` `sfx`
Created by [Stronkter](https://x.com/Stronkter).
## Source Prompt
```text
HyperFrames by HeyGen make me a 7s video with Apple-style bold font and styling: a phone scrolling in an app store, clicking on a friendly VPN app called VPN, installing it, then snapping down and fading to a white background. Make it snappy and polished for a YouTube insert, with sound effects, 60fps, and 1920x1080.
```
<video className="w-full aspect-video rounded-xl object-cover bg-zinc-100 dark:bg-zinc-800" src="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.mp4" poster="https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.png" autoPlay muted loop playsInline />
## Install
<CodeGroup>
```bash Terminal
npx hyperframes add vpn-youtube-spot
```
</CodeGroup>
## Details
| Property | Value |
| --- | --- |
| Type | Block |
| Dimensions | 1920×1080 |
| Duration | 7s |
## Files
| File | Target | Type |
| --- | --- | --- |
| `vpn-youtube-spot.html` | `compositions/vpn-youtube-spot.html` | hyperframes:composition |
| `assets/vpn-sfx.wav` | `assets/vpn-sfx.wav` | hyperframes:asset |
## Usage
After installing, add the block to your host composition:
```html
<div data-composition-id="vpn-youtube-spot" data-composition-src="compositions/vpn-youtube-spot.html" data-start="0" data-duration="7" data-track-index="1" data-width="1920" data-height="1080"></div>
```
+1 -1
View File
@@ -144,7 +144,7 @@
"pages": [
"catalog/blocks/app-showcase",
"catalog/blocks/apple-money-count",
"catalog/blocks/goonvpn-youtube-spot",
"catalog/blocks/vpn-youtube-spot",
"catalog/blocks/north-korea-locked-down",
"catalog/blocks/nyc-paris-flight",
"catalog/blocks/ui-3d-reveal"
+5 -5
View File
@@ -126,18 +126,18 @@
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/glitch.png"
},
{
"name": "goonvpn-youtube-spot",
"name": "vpn-youtube-spot",
"type": "block",
"title": "GoonVPN YouTube Spot",
"description": "Snappy Apple-style YouTube insert showing a phone finding and installing a fictional VPN app with sound effects.",
"title": "VPN YouTube Spot",
"description": "Snappy Apple-style YouTube insert showing a phone finding and installing a friendly VPN app with sound effects.",
"tags": [
"app",
"showcase",
"youtube",
"sfx"
],
"href": "/catalog/blocks/goonvpn-youtube-spot",
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/goonvpn-youtube-spot.png"
"href": "/catalog/blocks/vpn-youtube-spot",
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/blocks/vpn-youtube-spot.png"
},
{
"name": "grain-overlay",
+27
View File
@@ -47,6 +47,8 @@ describe("renderLocal browser GPU config", () => {
}
}
vi.clearAllMocks();
vi.useRealTimers();
vi.restoreAllMocks();
});
it("passes an explicit software override for --no-browser-gpu even when env requests hardware", async () => {
@@ -130,6 +132,31 @@ describe("renderLocal browser GPU config", () => {
expect(producerState.createdJobs[0]?.variables).toBeUndefined();
});
it("can force the CLI process to exit after a successful local render", async () => {
vi.useFakeTimers();
const exit = vi
.spyOn(process, "exit")
.mockImplementation((code?: string | number | null): never => {
throw new Error(`process.exit:${code ?? ""}`);
});
const { renderLocal } = await import("./render.js");
await renderLocal("/tmp/project", "/tmp/out.mp4", {
fps: 30,
quality: "standard",
format: "mp4",
gpu: false,
browserGpu: true,
hdrMode: "auto",
quiet: true,
exitAfterComplete: true,
});
expect(exit).not.toHaveBeenCalled();
expect(() => vi.advanceTimersByTime(100)).toThrow("process.exit:0");
expect(exit).toHaveBeenCalledWith(0);
});
});
describe("parseVariablesArg", () => {
+25
View File
@@ -403,6 +403,7 @@ export default defineCommand({
videoBitrate,
quiet,
variables,
exitAfterComplete: true,
});
} else {
await renderLocal(project.dir, outputPath, {
@@ -418,6 +419,7 @@ export default defineCommand({
quiet,
browserPath,
variables,
exitAfterComplete: true,
});
}
},
@@ -436,6 +438,7 @@ interface RenderOptions {
quiet: boolean;
browserPath?: string;
variables?: Record<string, unknown>;
exitAfterComplete?: boolean;
}
export type VariablesParseError =
@@ -748,6 +751,7 @@ async function renderDocker(
});
printRenderComplete(outputPath, elapsed, options.quiet);
if (options.exitAfterComplete) scheduleRenderProcessExit();
}
export async function renderLocal(
@@ -796,6 +800,27 @@ export async function renderLocal(
const elapsed = Date.now() - startTime;
trackRenderMetrics(job, elapsed, options, false);
printRenderComplete(outputPath, elapsed, options.quiet);
if (options.exitAfterComplete) scheduleRenderProcessExit();
}
type UnrefableTimer = {
unref: () => void;
};
function isUnrefableTimer(
timer: ReturnType<typeof setTimeout>,
): timer is ReturnType<typeof setTimeout> & UnrefableTimer {
return (
typeof timer === "object" &&
timer !== null &&
"unref" in timer &&
typeof timer.unref === "function"
);
}
function scheduleRenderProcessExit(): void {
const timer = setTimeout(() => process.exit(0), 100);
if (isUnrefableTimer(timer)) timer.unref();
}
function getMemorySnapshot() {
@@ -798,6 +798,89 @@ describe("HyperframesPlayer seek() sync path", () => {
});
});
describe("HyperframesPlayer loop end-state handling", () => {
type PlayerInternal = HTMLElement & {
iframe: HTMLIFrameElement;
play: () => void;
seek: (timeInSeconds: number) => void;
loop: boolean;
_duration: number;
_paused: boolean;
_onMessage: (event: MessageEvent) => void;
};
let player: PlayerInternal;
let frameWindow: Window;
beforeEach(async () => {
await import("./hyperframes-player.js");
player = document.createElement("hyperframes-player") as PlayerInternal;
frameWindow = window;
vi.spyOn(frameWindow, "postMessage").mockImplementation(() => undefined);
Object.defineProperty(player.iframe, "contentWindow", {
configurable: true,
get: () => frameWindow,
});
document.body.appendChild(player);
});
afterEach(() => {
player.remove();
vi.restoreAllMocks();
});
it("wraps and keeps playing when a looping composition posts its final paused state", () => {
const seek = vi.spyOn(player, "seek");
const play = vi.spyOn(player, "play");
player.loop = true;
player._duration = 4;
player._paused = false;
player._onMessage(
new MessageEvent("message", {
source: frameWindow,
data: {
source: "hf-preview",
type: "state",
frame: 120,
isPlaying: false,
},
}),
);
expect(seek).toHaveBeenCalledWith(0);
expect(play).toHaveBeenCalled();
expect(player._paused).toBe(false);
});
it("fires ended and stays paused when a non-looping composition posts its final paused state", () => {
const seek = vi.spyOn(player, "seek");
const play = vi.spyOn(player, "play");
const ended = vi.fn();
player.addEventListener("ended", ended);
player.loop = false;
player._duration = 4;
player._paused = false;
player._onMessage(
new MessageEvent("message", {
source: frameWindow,
data: {
source: "hf-preview",
type: "state",
frame: 120,
isPlaying: false,
},
}),
);
expect(seek).not.toHaveBeenCalled();
expect(play).not.toHaveBeenCalled();
expect(ended).toHaveBeenCalledTimes(1);
expect(player._paused).toBe(true);
});
});
describe("HyperframesPlayer srcdoc attribute", () => {
type PlayerInternal = HTMLElement & {
iframe: HTMLIFrameElement;
+17 -10
View File
@@ -429,7 +429,19 @@ class HyperframesPlayer extends HTMLElement {
if (data.type === "state") {
this._currentTime = (data.frame ?? 0) / DEFAULT_FPS;
const wasPlaying = !this._paused;
this._paused = !data.isPlaying;
const nextPaused = !data.isPlaying;
const completedPlayback =
this._duration > 0 && this._currentTime >= this._duration && (wasPlaying || data.isPlaying);
if (completedPlayback && this.loop) {
if (this._audioOwner === "parent") this._pauseParentMedia();
this._paused = nextPaused;
this.seek(0);
this.play();
return;
}
this._paused = nextPaused;
// Under parent ownership the proxies are the audible output, so they
// mirror the iframe's play/pause transitions (externally-driven pause
@@ -456,16 +468,11 @@ class HyperframesPlayer extends HTMLElement {
);
}
if (this._currentTime >= this._duration && !this._paused) {
if (completedPlayback) {
if (this._audioOwner === "parent") this._pauseParentMedia();
if (this.loop) {
this.seek(0);
this.play();
} else {
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.dispatchEvent(new Event("ended"));
}
this._paused = true;
this.controlsApi?.updatePlaying(false);
this.dispatchEvent(new Event("ended"));
}
}
@@ -1,28 +0,0 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "goonvpn-youtube-spot",
"type": "hyperframes:block",
"title": "GoonVPN YouTube Spot",
"description": "Snappy Apple-style YouTube insert showing a phone finding and installing a fictional VPN app with sound effects.",
"tags": ["app", "showcase", "youtube", "sfx"],
"author": "Stronkter",
"authorUrl": "https://x.com/Stronkter",
"sourcePrompt": "\ud83d\udcf7HyperFrames by HeyGen make me a 7s video apple style bold font and styling a phone scrolling in a appstore clicking on a vpn app called goonVPN and installing it the app installs the phone snaps down and fades to a white backround make it snappy and pop its for a youtube video also add soundfx and make it 60fps and 1920x1080",
"dimensions": {
"width": 1920,
"height": 1080
},
"duration": 7,
"files": [
{
"path": "goonvpn-youtube-spot.html",
"target": "compositions/goonvpn-youtube-spot.html",
"type": "hyperframes:composition"
},
{
"path": "assets/goonvpn-sfx.wav",
"target": "assets/goonvpn-sfx.wav",
"type": "hyperframes:asset"
}
]
}
@@ -0,0 +1,28 @@
{
"$schema": "https://hyperframes.heygen.com/schema/registry-item.json",
"name": "vpn-youtube-spot",
"type": "hyperframes:block",
"title": "VPN YouTube Spot",
"description": "Snappy Apple-style YouTube insert showing a phone finding and installing a friendly VPN app with sound effects.",
"tags": ["app", "showcase", "youtube", "sfx"],
"author": "Stronkter",
"authorUrl": "https://x.com/Stronkter",
"sourcePrompt": "HyperFrames by HeyGen make me a 7s video with Apple-style bold font and styling: a phone scrolling in an app store, clicking on a friendly VPN app called VPN, installing it, then snapping down and fading to a white background. Make it snappy and polished for a YouTube insert, with sound effects, 60fps, and 1920x1080.",
"dimensions": {
"width": 1920,
"height": 1080
},
"duration": 7,
"files": [
{
"path": "vpn-youtube-spot.html",
"target": "compositions/vpn-youtube-spot.html",
"type": "hyperframes:composition"
},
{
"path": "assets/vpn-sfx.wav",
"target": "assets/vpn-sfx.wav",
"type": "hyperframes:asset"
}
]
}
@@ -292,13 +292,13 @@
background: linear-gradient(135deg, #34c759, #ffd60a);
}
.icon-goon {
.icon-vpn {
background:
radial-gradient(circle at 72% 16%, rgba(255, 255, 255, 0.62) 0 12%, transparent 13%),
linear-gradient(145deg, #101828 0%, #007aff 64%, #34c759 100%);
}
.icon-goon::before {
.icon-vpn::before {
content: "";
position: absolute;
left: 21px;
@@ -310,7 +310,7 @@
clip-path: polygon(50% 0, 88% 13%, 88% 46%, 50% 100%, 12% 46%, 12% 13%);
}
.icon-goon::after {
.icon-vpn::after {
content: "";
position: absolute;
left: 31px;
@@ -371,7 +371,7 @@
background: #ececf1;
}
#goon-row {
#vpn-row {
position: relative;
transform-origin: center;
outline: 0 solid rgba(0, 122, 255, 0);
@@ -635,7 +635,7 @@
<body>
<div
id="root"
data-composition-id="goonvpn-youtube-spot"
data-composition-id="vpn-youtube-spot"
data-start="0"
data-duration="7"
data-width="1920"
@@ -646,17 +646,17 @@
data-start="0"
data-duration="7"
data-track-index="1"
src="assets/goonvpn-sfx.wav"
src="assets/vpn-sfx.wav"
data-volume="0.95"
></audio>
<div class="stage">
<div class="headline" id="stageHeadline">
<span>SECURE</span>
<span class="blue">INSTANTLY</span>
<div class="sub">One tap, installed, ready.</div>
<span>PRIVATE</span>
<span class="blue">SIMPLY</span>
<div class="sub">A calmer way to browse.</div>
</div>
<div class="ambient-word">VPN</div>
<div class="ambient-word">SAFE</div>
<div class="ambient-line line-a"></div>
<div class="ambient-line line-b"></div>
<div class="snap-shadow"></div>
@@ -672,7 +672,7 @@
</div>
<div class="search-pill">
<div class="search-icon"></div>
<div>vpn apps</div>
<div>privacy apps</div>
</div>
<div class="screen-window" data-layout-allow-overflow>
<div class="scroll-content">
@@ -721,11 +721,11 @@
</div>
<div class="get-button">GET</div>
</div>
<div class="store-row" id="goon-row">
<div class="app-icon icon-goon"></div>
<div class="store-row" id="vpn-row">
<div class="app-icon icon-vpn"></div>
<div class="row-copy">
<div class="row-title">goonVPN</div>
<div class="row-sub">Fast private browsing</div>
<div class="row-title">VPN</div>
<div class="row-sub">Simple private browsing</div>
<div class="stars">4.9 ★★★★★</div>
</div>
<div class="get-button">GET</div>
@@ -756,10 +756,10 @@
<section class="detail-view">
<div class="detail-card">
<div class="detail-hero">
<div class="app-icon icon-goon detail-icon"></div>
<div class="app-icon icon-vpn detail-icon"></div>
<div>
<div class="detail-title">goonVPN</div>
<div class="detail-sub">Private, fast VPN for everyday browsing.</div>
<div class="detail-title">VPN</div>
<div class="detail-sub">Friendly privacy for everyday browsing.</div>
<div class="install-button">
<span class="install-label">GET</span>
<svg class="ring" viewBox="0 0 28 28" aria-hidden="true">
@@ -796,7 +796,7 @@
</div>
<div class="shield-status">
<div class="check"></div>
<div>Installed</div>
<div>Ready to browse</div>
</div>
</div>
</section>
@@ -878,7 +878,7 @@
1.25,
)
.to(
"#goon-row",
"#vpn-row",
{
scale: 1.035,
outlineWidth: 3,
@@ -888,15 +888,15 @@
},
1.72,
)
.to("#goon-row", { scale: 1, duration: 0.22 }, 1.93)
.to("#vpn-row", { scale: 1, duration: 0.22 }, 1.93)
.fromTo(
".tap-dot",
{ scale: 0.25, opacity: 0.95 },
{ scale: 2.15, opacity: 0, duration: 0.42, ease: "power2.out" },
2.02,
)
.to("#goon-row .get-button", { scale: 0.82, duration: 0.1, ease: "power3.in" }, 2.04)
.to("#goon-row .get-button", { scale: 1, duration: 0.14, ease: "back.out(2)" }, 2.14);
.to("#vpn-row .get-button", { scale: 0.82, duration: 0.1, ease: "power3.in" }, 2.04)
.to("#vpn-row .get-button", { scale: 1, duration: 0.14, ease: "back.out(2)" }, 2.14);
tl.fromTo(
".detail-view",
@@ -981,7 +981,7 @@
.to(".speed-lines", { opacity: 0, duration: 0.2 }, 5.74)
.to(".white-wipe", { opacity: 1, duration: 0.42, ease: "power2.out" }, 5.86);
window.__timelines["goonvpn-youtube-spot"] = tl;
window.__timelines["vpn-youtube-spot"] = tl;
</script>
</body>
</html>
+1 -1
View File
@@ -100,7 +100,7 @@
"type": "hyperframes:block"
},
{
"name": "goonvpn-youtube-spot",
"name": "vpn-youtube-spot",
"type": "hyperframes:block"
},
{