arkaos 4.15.0 → 4.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/departments/dev/skills/animated-website/SKILL.md +449 -0
- package/departments/dev/skills/animated-website/scripts/extract_frames.py +407 -0
- package/knowledge/skills-manifest.json +11 -1
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/marketplace_gen.py +4 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
**The Operating System for AI Agent Teams.**
|
|
4
4
|
|
|
5
|
-
86 agents. 17 departments.
|
|
5
|
+
86 agents. 17 departments. 275 skills. Enterprise frameworks. Multi-runtime. One install.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
8
|
npx arkaos install
|
|
@@ -95,7 +95,7 @@ npx arkaos doctor # Health check
|
|
|
95
95
|
|
|
96
96
|
### Skill packs, à la carte
|
|
97
97
|
|
|
98
|
-
The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with
|
|
98
|
+
The default install ships a curated core so your context window stays lean. Everything else lives in the ArkaOS plugin marketplace: 16 department packs with 206 skills, generated straight from the same sources the core uses. Inside Claude Code:
|
|
99
99
|
|
|
100
100
|
```
|
|
101
101
|
/plugin marketplace add andreagroferreira/arka-os
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.
|
|
1
|
+
4.16.0
|
package/arka/SKILL.md
CHANGED
|
@@ -25,7 +25,7 @@ measures citation compliance per turn.
|
|
|
25
25
|
# ArkaOS — Main Orchestrator
|
|
26
26
|
|
|
27
27
|
> **The Operating System for AI Agent Teams**
|
|
28
|
-
> 86 agents. 17 departments.
|
|
28
|
+
> 86 agents. 17 departments. 275 skills. Multi-runtime. Dashboard. Knowledge RAG.
|
|
29
29
|
|
|
30
30
|
## ⛔ Evidence flow — 4 gates (NON-NEGOTIABLE)
|
|
31
31
|
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dev/animated-website
|
|
3
|
+
description: >
|
|
4
|
+
Convert an MP4 video into a luxury scroll-animated single-file website:
|
|
5
|
+
extracts frames to optimized WebP (desktop + mobile), then builds a
|
|
6
|
+
scroll-driven canvas engine with cinematic dwell pacing, ambient particles,
|
|
7
|
+
film grain, letter-split hero, glass morphism cards, and parallax gallery.
|
|
8
|
+
TRIGGER: "animated website", "scroll animation", "video to website",
|
|
9
|
+
"Apple-style page", "site com scroll do vídeo", "transforma este vídeo num
|
|
10
|
+
site", "frame animation", "/dev animated-website". SKIP: embedding a plain
|
|
11
|
+
video player -> just use <video>; regular animated site WITHOUT a video
|
|
12
|
+
source -> frontend-design skill; video editing/production -> content/video-produce.
|
|
13
|
+
allowed-tools: [Read, Write, Edit, Bash, Grep, Glob, Agent, WebFetch]
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
# Animated Website Generator
|
|
17
|
+
|
|
18
|
+
> **ArkaOS frontend mandate:** this is UI work under `excellence-mandate` —
|
|
19
|
+
> load the frontend design skills (frontend-design, ui-ux-pro-max, project
|
|
20
|
+
> design system) at maximum effort, emit the `[arka:design]` evidence marker
|
|
21
|
+
> before editing UI files, and pass visual review against a named benchmark
|
|
22
|
+
> (Apple product pages are the default bar for this skill).
|
|
23
|
+
|
|
24
|
+
Convert video files into scroll-animated websites with a luxury, cinematic aesthetic. The user provides an MP4 video and a concept brief. You extract frames, optimize for web delivery, and generate a complete website where scrolling plays the video frame-by-frame on a canvas element — with rich overlay text, ambient effects, and gallery sections that elevate it beyond a simple scroll player.
|
|
25
|
+
|
|
26
|
+
The design language draws from high-end real estate, product launches, and editorial showcases: warm dark palettes, serif/sans-serif type pairing, ambient particles, film grain texture, glass morphism cards, and a scroll-dwell engine that creates natural pacing through content sections.
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
|
|
30
|
+
## When This Skill Applies
|
|
31
|
+
|
|
32
|
+
**This skill IS for:**
|
|
33
|
+
- Converting an MP4 video into a scroll-driven animated website
|
|
34
|
+
- Luxury showcase pages (real estate, architecture, product, portfolio)
|
|
35
|
+
- Apple product page-style frame sequences (scroll to play)
|
|
36
|
+
- Any "video → interactive scroll experience" request
|
|
37
|
+
|
|
38
|
+
**This skill is NOT for:**
|
|
39
|
+
- Embedding a video player on a page (just use `<video>`)
|
|
40
|
+
- Converting video to GIF or animated WebP
|
|
41
|
+
- Building a regular website without video-based scroll animation
|
|
42
|
+
- CSS-only scroll animations (use the `frontend-design` skill instead)
|
|
43
|
+
|
|
44
|
+
**Redirect:** If the user wants a regular animated website WITHOUT a video source, use the `frontend-design` skill.
|
|
45
|
+
|
|
46
|
+
---
|
|
47
|
+
|
|
48
|
+
## Input: What You Need From the User
|
|
49
|
+
|
|
50
|
+
**Required:**
|
|
51
|
+
1. **An MP4 video file** — absolute path to the source video
|
|
52
|
+
2. **Website concept** — what the site is about (product, brand, property, portfolio, etc.)
|
|
53
|
+
|
|
54
|
+
**Optional (skill handles defaults if not provided):**
|
|
55
|
+
- Target frame count (default: auto-calculated from video duration)
|
|
56
|
+
- Brand colors (default: warm-dark luxury palette)
|
|
57
|
+
- Section copy (headlines, body text, CTAs)
|
|
58
|
+
- Brand name and tagline
|
|
59
|
+
|
|
60
|
+
If the user gives just a video path and a vague concept, ask ONE clarifying question about the content direction, then proceed.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Process
|
|
65
|
+
|
|
66
|
+
### Step 1: Analyze the Video
|
|
67
|
+
|
|
68
|
+
Probe the video to understand what you're working with:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
ffprobe -v quiet -print_format json -show_format -show_streams "/path/to/video.mp4"
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Parse and present:
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
VIDEO ANALYSIS:
|
|
78
|
+
Duration: 12.4s
|
|
79
|
+
Resolution: 3840x2160 (4K)
|
|
80
|
+
Frame Rate: 30fps
|
|
81
|
+
Total Frames: 372
|
|
82
|
+
Codec: H.264
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Then recommend frame count:
|
|
86
|
+
|
|
87
|
+
| Video Duration | Recommended Frames | Scroll Height |
|
|
88
|
+
|---------------|-------------------|---------------|
|
|
89
|
+
| 0-5s | 60-90 | 400vh |
|
|
90
|
+
| 5-15s | 100-150 | 650vh |
|
|
91
|
+
| 15-30s | 150-200 | 800vh |
|
|
92
|
+
| 30s+ | Cap at 200 | 900vh |
|
|
93
|
+
|
|
94
|
+
**Get user confirmation before extracting.** Say: "I recommend extracting {N} frames. Sound good, or want to adjust?"
|
|
95
|
+
|
|
96
|
+
### Step 2: Extract and Optimize Frames
|
|
97
|
+
|
|
98
|
+
Run the extraction script bundled with this skill (`scripts/extract_frames.py`, relative to this SKILL.md):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python3 scripts/extract_frames.py \
|
|
102
|
+
--input "/path/to/video.mp4" \
|
|
103
|
+
--output "animated-sites/{slug}/frames" \
|
|
104
|
+
--frames {N} \
|
|
105
|
+
--quality 80
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Set `--output` to a folder inside the current working directory.
|
|
109
|
+
|
|
110
|
+
The script produces:
|
|
111
|
+
- `frames/desktop/` — 1920x1080 WebP frames
|
|
112
|
+
- `frames/mobile/` — 960x540 WebP frames
|
|
113
|
+
- `frames/manifest.json` — metadata (counts, sizes, scroll height)
|
|
114
|
+
|
|
115
|
+
Show the manifest summary to the user. If payload exceeds budget (>10MB desktop, >5MB mobile), recommend `--quality 60` or fewer frames.
|
|
116
|
+
|
|
117
|
+
### Step 3: Gather Content
|
|
118
|
+
|
|
119
|
+
Based on the user's concept, prepare content for 6 scroll-text sections. These overlay the video at different scroll positions, creating a narrative experience. The sections are:
|
|
120
|
+
|
|
121
|
+
1. **Hero** — Property/product name, tagline, key stats
|
|
122
|
+
2. **Vision** — A quote or aspirational statement about the subject
|
|
123
|
+
3. **Details** — Key specifications or features (with icon list)
|
|
124
|
+
4. **Grid** — 4-6 amenities/features in a glass grid layout
|
|
125
|
+
5. **Context** — Location, availability, or background info
|
|
126
|
+
6. **CTA** — Call to action with buttons and contact info
|
|
127
|
+
|
|
128
|
+
If the user provides copy, use it. If not, generate content that fits the concept. The content should feel editorial and refined — short sentences, evocative language.
|
|
129
|
+
|
|
130
|
+
### Step 4: Build the Website
|
|
131
|
+
|
|
132
|
+
Generate a complete single-file HTML page using the design system below. Save to: `animated-sites/{slug}/index.html`
|
|
133
|
+
|
|
134
|
+
**Adapt the content and branding to the concept** — the design patterns stay consistent but the palette, copy, and section content should fit the subject matter. A tech product might use cooler blues, a restaurant warmer golds, a real estate listing the warm-neutral default.
|
|
135
|
+
|
|
136
|
+
### Step 5: Serve and Preview
|
|
137
|
+
|
|
138
|
+
```bash
|
|
139
|
+
cd "animated-sites/{slug}"
|
|
140
|
+
python3 -m http.server 8080
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Then open `http://localhost:8080/index.html` in the browser and take screenshots at different scroll positions to verify.
|
|
144
|
+
|
|
145
|
+
Common iteration requests:
|
|
146
|
+
- "Slower scroll" → increase animation-section height (650vh → 900vh)
|
|
147
|
+
- "Faster scroll" → decrease height (650vh → 400vh)
|
|
148
|
+
- "Smoother animation" → decrease LERP_FACTOR (0.09 → 0.05)
|
|
149
|
+
- "More responsive" → increase LERP_FACTOR (0.09 → 0.15)
|
|
150
|
+
- "Change text" → edit the scroll-text overlay content
|
|
151
|
+
- "Different colors" → update CSS custom properties in `:root`
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Design System
|
|
156
|
+
|
|
157
|
+
The visual language is warm, dark, and cinematic. Every element serves the animation — surrounding effects (grain, particles, vignette) add depth without competing with the video frames.
|
|
158
|
+
|
|
159
|
+
### Color Palette (CSS Custom Properties)
|
|
160
|
+
|
|
161
|
+
```css
|
|
162
|
+
:root {
|
|
163
|
+
--concrete: #d4cfc8; /* muted warm gray */
|
|
164
|
+
--concrete-dim: #9e9890; /* secondary text */
|
|
165
|
+
--stone: #706050; /* decorative accents */
|
|
166
|
+
--charcoal: #1a1816; /* card backgrounds */
|
|
167
|
+
--ink: #0e0d0c; /* page background */
|
|
168
|
+
--warm-white: #f4f0ea; /* primary text */
|
|
169
|
+
--warm-white-dim: #c8c0b4; /* emphasized secondary */
|
|
170
|
+
--accent-blue: #4a6aff; /* accent color - adapt per brand */
|
|
171
|
+
--accent-blue-glow: rgba(74, 106, 255, 0.35);
|
|
172
|
+
--accent-blue-soft: rgba(74, 106, 255, 0.08);
|
|
173
|
+
--sunset-pink: #d4a0b0; /* secondary accent */
|
|
174
|
+
--gold-warm: #c89848; /* tertiary accent */
|
|
175
|
+
--heading: 'Playfair Display', 'Georgia', serif;
|
|
176
|
+
--body: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
177
|
+
}
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
Adapt the `--accent-blue` family for different brands. The warm-dark base (`--ink`, `--warm-white`) stays consistent — it makes the video frames pop and creates the luxury atmosphere.
|
|
181
|
+
|
|
182
|
+
### Typography
|
|
183
|
+
|
|
184
|
+
- **Headings:** Playfair Display — weight 300 for elegance, italic for emphasis
|
|
185
|
+
- **Body:** DM Sans — weight 300-500, generous letter-spacing
|
|
186
|
+
- **Labels:** DM Sans, 8-9px, weight 500, 0.25-0.35em letter-spacing, uppercase
|
|
187
|
+
- **Hero titles:** clamp(42px, 5.5vw, 76px) — big but light weight
|
|
188
|
+
|
|
189
|
+
Load from Google Fonts:
|
|
190
|
+
```html
|
|
191
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,wght@0,300;0,400;0,500;0,600;1,300;1,400&family=Playfair+Display:ital,wght@0,300;0,400;0,500;0,600;0,700;1,300;1,400&display=swap" rel="stylesheet">
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Layer Stack (z-index)
|
|
195
|
+
|
|
196
|
+
The site layers multiple visual effects. Here's the stacking order:
|
|
197
|
+
|
|
198
|
+
| Z-Index | Element | Purpose |
|
|
199
|
+
|---------|---------|---------|
|
|
200
|
+
| 9999 | Loader | Branded loading screen with progress bar |
|
|
201
|
+
| 9998 | Cursor dot | Custom cursor inner dot (mix-blend-mode: difference) |
|
|
202
|
+
| 9997 | Cursor ring | Custom cursor outer ring (expands on hover) |
|
|
203
|
+
| 100 | Film grain | SVG noise overlay at 3.5% opacity |
|
|
204
|
+
| 99 | Vignette | Radial gradient darkening edges |
|
|
205
|
+
| 50 | Chapter markers | Fixed right-side navigation dots |
|
|
206
|
+
| 15 | Particles | Ambient floating particles canvas |
|
|
207
|
+
| 10 | Scroll text | Content overlays during animation |
|
|
208
|
+
| 5 | Tint overlay | Dynamic color wash per section |
|
|
209
|
+
| 1 | Canvas overlays | Left gradient + bottom gradient for text readability |
|
|
210
|
+
| 0 | Video canvas | The scroll-driven frame animation |
|
|
211
|
+
|
|
212
|
+
### Ambient Effects
|
|
213
|
+
|
|
214
|
+
These create the cinematic atmosphere. All are pointer-events: none and purely decorative.
|
|
215
|
+
|
|
216
|
+
**Film Grain:** SVG feTurbulence fractalNoise, 256px tile, opacity 0.035. Gives the site a filmic texture.
|
|
217
|
+
|
|
218
|
+
**Vignette:** Radial gradient from transparent (center 50%) to rgba(14,13,12,0.45) at edges. Draws focus to center.
|
|
219
|
+
|
|
220
|
+
**Ambient Particles:** Fixed canvas, ~40 small dots (0.3-1.8px), slow random drift, warm-white at 5-35% opacity. Creates a living, breathing feel. Wrap at edges so they float continuously.
|
|
221
|
+
|
|
222
|
+
**Dynamic Tint:** Canvas overlay that shifts hue based on current scroll section. Subtle (6-4% opacity) gradient from accent-blue to sunset-pink. Makes each section feel distinct.
|
|
223
|
+
|
|
224
|
+
**Custom Cursor (desktop only):** Two-part cursor: a 6px dot with mix-blend-mode: difference (always visible against any background) and a 36px ring that trails the dot with LERP. Ring expands to 56px on hover over interactive elements and gains accent-blue glow. Hide on mobile.
|
|
225
|
+
|
|
226
|
+
### Scroll Dwell Engine
|
|
227
|
+
|
|
228
|
+
This is what makes the scroll feel magical instead of mechanical. Rather than mapping scroll position linearly to frames, the dwell engine creates "almost stops" at each content section — the scroll slows down there, giving users time to read, then speeds up between sections.
|
|
229
|
+
|
|
230
|
+
**How it works:**
|
|
231
|
+
1. Define dwell centers — the scroll positions where content appears (e.g., 0.065, 0.21, 0.365, 0.525, 0.685, 0.89)
|
|
232
|
+
2. At each center, a Gaussian density function peaks — meaning more scroll distance is consumed per unit of effective progress
|
|
233
|
+
3. Build a cumulative integral lookup table (forward mapping)
|
|
234
|
+
4. Invert it for the actual remap function (scroll position → effective progress)
|
|
235
|
+
|
|
236
|
+
The dwell centers should align with the `data-show-at` values of scroll-text sections. With 6 sections, space them roughly evenly across 0-1 but offset from the edges.
|
|
237
|
+
|
|
238
|
+
Parameters to tune:
|
|
239
|
+
- `DWELL_WIDTH` (0.045): How wide the slow zone is. Smaller = tighter pause.
|
|
240
|
+
- `DWELL_PEAK` (3.5): How much slower the scroll feels at dwell points. Higher = more dramatic pause.
|
|
241
|
+
- `REMAP_N` (2000): Resolution of the lookup table. 2000 is smooth enough.
|
|
242
|
+
|
|
243
|
+
### Scroll Text Overlays
|
|
244
|
+
|
|
245
|
+
Six sections that appear and disappear at specific scroll positions. Each has its own layout and style:
|
|
246
|
+
|
|
247
|
+
1. **Hero** (positioned left, 6% from edge): Property/product name with letter-split animation, tagline, price/key metric. Has a glass stat bar at bottom with 4-5 key numbers.
|
|
248
|
+
|
|
249
|
+
2. **Vision** (positioned left): Opening quotation mark as decorative element, italic serif quote, divider line, attribution.
|
|
250
|
+
|
|
251
|
+
3. **Details** (positioned left): Overline label, serif title, body text, feature list with icon items and subtle borders.
|
|
252
|
+
|
|
253
|
+
4. **Grid** (positioned left): Overline label, 2-column glass grid of 6 amenity/feature cells with icons and descriptions.
|
|
254
|
+
|
|
255
|
+
5. **Context** (positioned left): Overline label, serif title, body text, distance/detail list with dotted connecting lines.
|
|
256
|
+
|
|
257
|
+
6. **CTA** (centered): Large serif title with italic emphasis, subtitle, two buttons (primary filled, secondary outline), and agent/contact card.
|
|
258
|
+
|
|
259
|
+
Each section fades in with blur(6px→0) and translateX(-20px→0) transitions. The `.visible` class is toggled based on scroll progress matching the `data-show-at` / `data-hide-at` attributes.
|
|
260
|
+
|
|
261
|
+
### Glass Morphism Cards
|
|
262
|
+
|
|
263
|
+
Used for stat bars and amenity grids:
|
|
264
|
+
- Background: rgba(244, 240, 234, 0.03)
|
|
265
|
+
- backdrop-filter: blur(20px)
|
|
266
|
+
- Border: 1px solid rgba(244, 240, 234, 0.06)
|
|
267
|
+
- On hover: background brightens to 0.06, border gains accent-blue tint
|
|
268
|
+
|
|
269
|
+
### Chapter Markers
|
|
270
|
+
|
|
271
|
+
Fixed right-side vertical navigation showing 6 dots connected by lines. The active dot gets accent-blue glow and the connecting line fills with progress as you scroll through the section. Dots can have labels that appear on hover.
|
|
272
|
+
|
|
273
|
+
### Gallery Section
|
|
274
|
+
|
|
275
|
+
Below the scroll animation, a masonry-ish grid of frames from the video. Uses IntersectionObserver for reveal animations and requestAnimationFrame-based parallax transforms (data-parallax attribute with positive/negative px values).
|
|
276
|
+
|
|
277
|
+
Grid layout: `grid-template-columns: repeat(3, 1fr)` with some items spanning 2 rows via `.tall` class. Images have subtle hover zoom (scale 1.03) with overflow hidden.
|
|
278
|
+
|
|
279
|
+
Pick 6-7 evenly spaced frames from the video for gallery images.
|
|
280
|
+
|
|
281
|
+
### Branded Loader
|
|
282
|
+
|
|
283
|
+
Not just a progress bar — a branded experience:
|
|
284
|
+
- Centered brand mark in uppercase letter-spaced heading font
|
|
285
|
+
- Decorative lines above and below
|
|
286
|
+
- Thin 140px progress bar with gradient fill (accent-blue → sunset-pink)
|
|
287
|
+
- Percentage counter below
|
|
288
|
+
- Exits with opacity 0 + blur(8px) transition
|
|
289
|
+
|
|
290
|
+
### Footer
|
|
291
|
+
|
|
292
|
+
Minimal: brand name in heading font, legal line in small body text, dark background slightly lighter than ink.
|
|
293
|
+
|
|
294
|
+
---
|
|
295
|
+
|
|
296
|
+
## Code Architecture
|
|
297
|
+
|
|
298
|
+
The entire site is a single HTML file. Here's the structural order:
|
|
299
|
+
|
|
300
|
+
```
|
|
301
|
+
HTML:
|
|
302
|
+
1. Google Fonts link
|
|
303
|
+
2. <style> with all CSS
|
|
304
|
+
3. Custom cursor divs (#cursor-dot, #cursor-ring)
|
|
305
|
+
4. Film grain overlay div
|
|
306
|
+
5. Vignette overlay div
|
|
307
|
+
6. Particles canvas (fixed)
|
|
308
|
+
7. Loader (fixed, z-9999)
|
|
309
|
+
8. Chapter markers (fixed right)
|
|
310
|
+
9. Animation section (relative, 650vh)
|
|
311
|
+
- Canvas container (sticky)
|
|
312
|
+
- Main canvas
|
|
313
|
+
- Left gradient overlay
|
|
314
|
+
- Bottom gradient overlay
|
|
315
|
+
- Tint overlay
|
|
316
|
+
- 6 scroll-text overlays (fixed, toggled by JS)
|
|
317
|
+
10. Gallery section
|
|
318
|
+
11. Footer
|
|
319
|
+
12. <script> with all JS
|
|
320
|
+
|
|
321
|
+
JS order:
|
|
322
|
+
1. Custom cursor tracking + ring LERP
|
|
323
|
+
2. Particle system init + animation loop
|
|
324
|
+
3. Letter-split animation (data-split attribute)
|
|
325
|
+
4. Scroll dwell/remap engine (LUT construction)
|
|
326
|
+
5. Frame loading (critical first, then batches)
|
|
327
|
+
6. Scroll animation loop (remap → LERP → drawFrame)
|
|
328
|
+
7. Scroll-text visibility toggling
|
|
329
|
+
8. Chapter marker updates
|
|
330
|
+
9. Tint overlay updates
|
|
331
|
+
10. Gallery IntersectionObserver + parallax
|
|
332
|
+
11. Stat counter animation
|
|
333
|
+
12. Init: load frames → hide loader → start animation
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
### Key JavaScript Patterns
|
|
337
|
+
|
|
338
|
+
**Frame loading with progressive enhancement:**
|
|
339
|
+
```javascript
|
|
340
|
+
// Critical frames first (evenly spaced), then batches
|
|
341
|
+
// Use createImageBitmap for off-thread decode when available
|
|
342
|
+
// Show first frame immediately after critical load
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**Scroll-to-frame with dwell remap:**
|
|
346
|
+
```javascript
|
|
347
|
+
function getScrollProgress() {
|
|
348
|
+
const rect = section.getBoundingClientRect();
|
|
349
|
+
return Math.max(0, Math.min(1, -rect.top / (rect.height - window.innerHeight)));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function animate() {
|
|
353
|
+
const rawProgress = getScrollProgress();
|
|
354
|
+
const remapped = remapProgress(rawProgress); // dwell engine
|
|
355
|
+
targetFrame = Math.floor(remapped * (FRAME_COUNT - 1));
|
|
356
|
+
currentFrame += (targetFrame - currentFrame) * LERP_FACTOR;
|
|
357
|
+
drawFrame(Math.round(currentFrame));
|
|
358
|
+
// Also update scroll-text visibility, chapter markers, tint
|
|
359
|
+
requestAnimationFrame(animate);
|
|
360
|
+
}
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**Stat counter animation:**
|
|
364
|
+
```javascript
|
|
365
|
+
// When stat-bar becomes visible, animate numbers from 0 to target
|
|
366
|
+
// Use ease-out cubic easing over ~1.5s
|
|
367
|
+
// Parse target from data-target attribute, handle commas/units
|
|
368
|
+
```
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
372
|
+
## Adapting for Different Concepts
|
|
373
|
+
|
|
374
|
+
The design system is flexible. Here's how to adapt it:
|
|
375
|
+
|
|
376
|
+
**Real Estate:** Default palette works perfectly. Use property name as hero title, price and specs in stat bar, architecture details, amenity grid, location distances, viewing CTA.
|
|
377
|
+
|
|
378
|
+
**Tech Product:** Shift accent to cooler blue (#2563eb) or electric violet (#7c3aed). Hero has product name + tagline, stat bar shows key specs (battery, weight, price), Details lists tech specs, Grid shows features, CTA is "Pre-order" or "Learn More".
|
|
379
|
+
|
|
380
|
+
**Portfolio/Creative:** Use gold-warm (#c89848) as accent. Hero has creator name + discipline, Vision quotes an artistic statement, Details shows notable works, Grid shows services/capabilities, CTA is "Get in Touch".
|
|
381
|
+
|
|
382
|
+
**Restaurant/Hospitality:** Warm accent (#c89848 or deep burgundy). Hero is venue name, Vision is a chef quote, Details is cuisine philosophy, Grid is menu highlights or experiences, Context is location/hours.
|
|
383
|
+
|
|
384
|
+
**Automotive:** Keep accent-blue or shift to silver (#a8a8a8). Hero is vehicle name + starting price, stat bar shows 0-60/HP/range, Details is engineering highlights.
|
|
385
|
+
|
|
386
|
+
In all cases, the 6-section structure, ambient effects, scroll-dwell engine, and glass morphism cards remain the same — they create the luxury feel regardless of content.
|
|
387
|
+
|
|
388
|
+
---
|
|
389
|
+
|
|
390
|
+
## Quality Checklist
|
|
391
|
+
|
|
392
|
+
Before showing the site to the user, verify:
|
|
393
|
+
|
|
394
|
+
- [ ] **Smooth at 60fps** — no jank or frame drops during scroll
|
|
395
|
+
- [ ] **First frame visible in <1s** — progressive loading works
|
|
396
|
+
- [ ] **Loading bar accurate** — gradient fill, percentage counter updates
|
|
397
|
+
- [ ] **Desktop payload <10MB** — check manifest.json
|
|
398
|
+
- [ ] **Mobile payload <5MB** — check manifest.json
|
|
399
|
+
- [ ] **Reduced motion handled** — static first frame when prefers-reduced-motion
|
|
400
|
+
- [ ] **No blank frames** — nearest-neighbor fallback fills gaps
|
|
401
|
+
- [ ] **Responsive** — canvas scales on resize, mobile layout works
|
|
402
|
+
- [ ] **Custom cursor works on desktop** — dot tracks instantly, ring trails
|
|
403
|
+
- [ ] **Particles animate** — subtle floating dots visible against dark bg
|
|
404
|
+
- [ ] **Scroll text appears/disappears** — 6 sections at correct scroll positions
|
|
405
|
+
- [ ] **Letter-split hero animates** — characters appear sequentially
|
|
406
|
+
- [ ] **Glass stat bar readable** — blur backdrop works, numbers visible
|
|
407
|
+
- [ ] **Gallery loads** — images from frames directory, parallax on scroll
|
|
408
|
+
- [ ] **Dwell engine feels natural** — scroll slows at content, speeds between
|
|
409
|
+
- [ ] **Chapter markers update** — active dot highlighted, progress lines fill
|
|
410
|
+
|
|
411
|
+
---
|
|
412
|
+
|
|
413
|
+
## Output Format
|
|
414
|
+
|
|
415
|
+
```
|
|
416
|
+
animated-sites/{slug}/
|
|
417
|
+
├── frames/
|
|
418
|
+
│ ├── desktop/ # 1920x1080 WebP
|
|
419
|
+
│ │ ├── frame-0001.webp
|
|
420
|
+
│ │ └── ...
|
|
421
|
+
│ ├── mobile/ # 960x540 WebP
|
|
422
|
+
│ │ ├── frame-0001.webp
|
|
423
|
+
│ │ └── ...
|
|
424
|
+
│ └── manifest.json # Frame metadata
|
|
425
|
+
└── index.html # Complete luxury scroll site
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
To view: `cd` into the output directory and run `python3 -m http.server 8080`, then open `localhost:8080`.
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## Troubleshooting
|
|
433
|
+
|
|
434
|
+
| Issue | Fix |
|
|
435
|
+
|-------|-----|
|
|
436
|
+
| FFmpeg not found | `brew install ffmpeg` |
|
|
437
|
+
| No libwebp support | `brew reinstall ffmpeg` (includes libwebp by default) |
|
|
438
|
+
| Frames too large (>10MB) | Lower quality: `--quality 60`. Reduce count: `--frames 90` |
|
|
439
|
+
| Animation stutters | Reduce frame count. Check DPR cap is 2. Decrease particles. |
|
|
440
|
+
| White flash between frames | Nearest-frame fallback not finding frames. Check extraction. |
|
|
441
|
+
| Canvas blank on mobile | Verify mobile frames exist. Check FRAME_DIR path switch. |
|
|
442
|
+
| Loading bar stuck | A frame 404'd. Check browser console. Verify frame paths. |
|
|
443
|
+
| Scroll too fast/slow | Adjust animation-section height. 650vh = default. |
|
|
444
|
+
| Dwell feels too sticky | Decrease DWELL_PEAK (3.5 → 2.5) or increase DWELL_WIDTH |
|
|
445
|
+
| Particles too visible | Decrease particles-canvas opacity (0.4 → 0.2) |
|
|
446
|
+
| Custom cursor jittery | Increase ring LERP factor (0.15 → 0.2) |
|
|
447
|
+
| Glass blur not working | Safari needs -webkit-backdrop-filter. Already included in CSS. |
|
|
448
|
+
| CORS error locally | Serve with `python3 -m http.server 8080`. Don't double-click HTML. |
|
|
449
|
+
| Fonts not loading | Check Google Fonts link. Fallback fonts in CSS vars handle it. |
|
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Extract frames from video and convert to optimized WebP for scroll animation.
|
|
4
|
+
|
|
5
|
+
Extracts evenly-spaced frames from an MP4 video, converts to WebP at dual
|
|
6
|
+
resolutions (desktop + mobile), and generates a manifest.json with metadata.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 extract_frames.py \
|
|
10
|
+
--input /path/to/video.mp4 \
|
|
11
|
+
--output animated-sites/my-project/frames
|
|
12
|
+
|
|
13
|
+
Custom frame count: --frames 120
|
|
14
|
+
Custom quality/resolution: --quality 75 --desktop-res 1920x1080 --mobile-res 960x540
|
|
15
|
+
Single variant: --desktop-only (or --mobile-only)
|
|
16
|
+
|
|
17
|
+
Environment:
|
|
18
|
+
Requires ffmpeg and ffprobe installed (brew install ffmpeg).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import shutil
|
|
24
|
+
import subprocess
|
|
25
|
+
import sys
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
DESKTOP_BUDGET_MB = 10
|
|
30
|
+
MOBILE_BUDGET_MB = 5
|
|
31
|
+
FFMPEG_TIMEOUT_S = 300
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def parse_args():
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
description="Extract frames from video for scroll animation"
|
|
37
|
+
)
|
|
38
|
+
_add_io_args(parser)
|
|
39
|
+
_add_encoding_args(parser)
|
|
40
|
+
return parser.parse_args()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _add_io_args(parser):
|
|
44
|
+
parser.add_argument("--input", required=True, help="Path to source MP4 video file")
|
|
45
|
+
parser.add_argument("--output", required=True, help="Output directory for extracted frames")
|
|
46
|
+
parser.add_argument("--desktop-only", action="store_true", help="Skip mobile frame generation")
|
|
47
|
+
parser.add_argument("--mobile-only", action="store_true", help="Skip desktop frame generation")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _add_encoding_args(parser):
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--frames", type=int, default=0,
|
|
53
|
+
help="Target frame count (default: auto-calculated from video duration)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--quality", type=int, default=80, help="WebP quality 1-100 (default: 80)")
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--desktop-res", default="1920x1080", help="Desktop frame resolution (default: 1920x1080)"
|
|
58
|
+
)
|
|
59
|
+
parser.add_argument(
|
|
60
|
+
"--mobile-res", default="960x540", help="Mobile frame resolution (default: 960x540)"
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fail(message):
|
|
65
|
+
"""Print an error to stderr and exit 1."""
|
|
66
|
+
print(f"Error: {message}", file=sys.stderr)
|
|
67
|
+
sys.exit(1)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def validate_dependencies():
|
|
71
|
+
"""Check that ffmpeg and ffprobe are installed."""
|
|
72
|
+
for cmd in ("ffmpeg", "ffprobe"):
|
|
73
|
+
if not shutil.which(cmd):
|
|
74
|
+
fail(f"'{cmd}' not found. Install with: brew install ffmpeg")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_input(input_path):
|
|
78
|
+
"""Check the input file exists and looks like a video; return the resolved path."""
|
|
79
|
+
p = Path(input_path).resolve()
|
|
80
|
+
if not p.exists():
|
|
81
|
+
fail(f"File not found: {input_path}")
|
|
82
|
+
if p.stat().st_size < 1024:
|
|
83
|
+
fail(f"File too small to be a video: {input_path}")
|
|
84
|
+
return p
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def validate_quality(quality):
|
|
88
|
+
"""Enforce the documented WebP quality range (1-100)."""
|
|
89
|
+
if not 1 <= quality <= 100:
|
|
90
|
+
fail(f"--quality must be between 1 and 100, got {quality}")
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def parse_fps(fps_str):
|
|
94
|
+
"""Parse an ffprobe r_frame_rate value ('30/1' or '29.97') into a float."""
|
|
95
|
+
if "/" in fps_str:
|
|
96
|
+
num, den = fps_str.split("/")
|
|
97
|
+
return float(num) / float(den) if float(den) != 0 else 30.0
|
|
98
|
+
return float(fps_str)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _run_ffprobe(input_path):
|
|
102
|
+
"""Run ffprobe and return the parsed JSON document."""
|
|
103
|
+
cmd = [
|
|
104
|
+
"ffprobe", "-v", "quiet", "-print_format", "json",
|
|
105
|
+
"-show_format", "-show_streams", str(input_path),
|
|
106
|
+
]
|
|
107
|
+
try:
|
|
108
|
+
result = subprocess.run(cmd, capture_output=True, text=True, check=True)
|
|
109
|
+
return json.loads(result.stdout)
|
|
110
|
+
except subprocess.CalledProcessError as e:
|
|
111
|
+
fail(f"ffprobe failed: {e.stderr}")
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
fail("Could not parse ffprobe output")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def probe_video(input_path):
|
|
117
|
+
"""Probe the video and return duration/resolution/fps/codec metadata."""
|
|
118
|
+
data = _run_ffprobe(input_path)
|
|
119
|
+
video_stream = next(
|
|
120
|
+
(s for s in data.get("streams", []) if s.get("codec_type") == "video"), None
|
|
121
|
+
)
|
|
122
|
+
if not video_stream:
|
|
123
|
+
fail("No video stream found in file")
|
|
124
|
+
|
|
125
|
+
fps = parse_fps(video_stream.get("r_frame_rate", "30/1"))
|
|
126
|
+
duration = float(data.get("format", {}).get("duration", 0))
|
|
127
|
+
if duration == 0:
|
|
128
|
+
duration = float(video_stream.get("duration", 0))
|
|
129
|
+
|
|
130
|
+
return {
|
|
131
|
+
"duration": round(duration, 2),
|
|
132
|
+
"width": int(video_stream.get("width", 1920)),
|
|
133
|
+
"height": int(video_stream.get("height", 1080)),
|
|
134
|
+
"fps": round(fps, 2),
|
|
135
|
+
"codec": video_stream.get("codec_name", "unknown"),
|
|
136
|
+
"total_frames": round(duration * fps),
|
|
137
|
+
"filename": Path(input_path).name,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def calculate_optimal_frames(duration, user_override=0):
|
|
142
|
+
"""Calculate optimal frame count and scroll height from video duration.
|
|
143
|
+
|
|
144
|
+
Formula: min(200, max(60, duration * 10))
|
|
145
|
+
- 0-5s videos: 60-90 frames (simple reveals)
|
|
146
|
+
- 5-15s: 120-150 (standard, the sweet spot)
|
|
147
|
+
- 15-30s: 150-200 (complex sequences)
|
|
148
|
+
- 30s+: capped at 200 (increase scroll height instead)
|
|
149
|
+
"""
|
|
150
|
+
frame_count = user_override if user_override > 0 else min(200, max(60, int(duration * 10)))
|
|
151
|
+
# Scroll height: ~3.3vh per frame, minimum 300vh, rounded to nearest 50vh
|
|
152
|
+
scroll_height = round(max(300, int(frame_count * 3.3)) / 50) * 50
|
|
153
|
+
return frame_count, scroll_height
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_resolution(res_str):
|
|
157
|
+
"""Parse 'WIDTHxHEIGHT' string into (width, height) tuple."""
|
|
158
|
+
try:
|
|
159
|
+
w, h = res_str.lower().split("x")
|
|
160
|
+
return int(w), int(h)
|
|
161
|
+
except (ValueError, AttributeError):
|
|
162
|
+
fail(f"Invalid resolution format: {res_str}. Use WIDTHxHEIGHT.")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def has_libwebp():
|
|
166
|
+
"""Check if FFmpeg has libwebp encoder support."""
|
|
167
|
+
result = subprocess.run(["ffmpeg", "-encoders"], capture_output=True, text=True)
|
|
168
|
+
return "libwebp" in result.stdout
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _probe_duration(input_path):
|
|
172
|
+
"""Return the container duration in seconds (10.0 fallback)."""
|
|
173
|
+
cmd = [
|
|
174
|
+
"ffprobe", "-v", "quiet", "-show_entries", "format=duration",
|
|
175
|
+
"-of", "csv=p=0", str(input_path),
|
|
176
|
+
]
|
|
177
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
178
|
+
return float(result.stdout.strip()) if result.stdout.strip() else 10.0
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _build_extract_cmd(input_path, output_dir, target_fps, resolution, quality, use_libwebp):
|
|
182
|
+
"""Build the ffmpeg command: single-pass WebP when libwebp exists, else PNG."""
|
|
183
|
+
w, h = resolution
|
|
184
|
+
cmd = [
|
|
185
|
+
"ffmpeg", "-y", "-i", str(input_path),
|
|
186
|
+
"-vf", f"fps={target_fps:.4f},scale={w}:{h}:flags=lanczos",
|
|
187
|
+
"-an",
|
|
188
|
+
]
|
|
189
|
+
if use_libwebp:
|
|
190
|
+
cmd += ["-c:v", "libwebp", "-quality", str(quality), "-compression_level", "6"]
|
|
191
|
+
ext = "webp" if use_libwebp else "png"
|
|
192
|
+
return [*cmd, str(output_dir / f"frame-%04d.{ext}")]
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _run_ffmpeg(cmd):
|
|
196
|
+
"""Run ffmpeg with a hard timeout; exit 1 on any failure."""
|
|
197
|
+
try:
|
|
198
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=FFMPEG_TIMEOUT_S)
|
|
199
|
+
if result.returncode != 0:
|
|
200
|
+
fail(f"FFmpeg failed:\n{result.stderr[-500:]}")
|
|
201
|
+
except subprocess.TimeoutExpired:
|
|
202
|
+
fail("FFmpeg timed out (5 min limit)")
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _convert_pngs_to_webp(output_dir, quality, prefix):
|
|
206
|
+
"""Universal fallback: convert extracted PNGs to WebP via Pillow, then delete them."""
|
|
207
|
+
try:
|
|
208
|
+
from PIL import Image as PILImage
|
|
209
|
+
except ImportError:
|
|
210
|
+
fail(
|
|
211
|
+
"Neither libwebp (FFmpeg) nor Pillow is available.\n"
|
|
212
|
+
"Fix: pip install Pillow OR brew reinstall ffmpeg"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
png_files = sorted(output_dir.glob("frame-*.png"))
|
|
216
|
+
print(f"{prefix}Converting {len(png_files)} PNGs to WebP via Pillow...", file=sys.stderr)
|
|
217
|
+
for png_path in png_files:
|
|
218
|
+
img = PILImage.open(png_path)
|
|
219
|
+
if img.mode != "RGB":
|
|
220
|
+
img = img.convert("RGB")
|
|
221
|
+
img.save(str(png_path.with_suffix(".webp")), "WEBP", quality=quality, method=6)
|
|
222
|
+
png_path.unlink() # Remove PNG to save disk space
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _measure_frames(output_dir, resolution, prefix):
|
|
226
|
+
"""Count extracted WebP frames and report sizes; exit 1 when none exist."""
|
|
227
|
+
frames = sorted(output_dir.glob("frame-*.webp"))
|
|
228
|
+
if not frames:
|
|
229
|
+
fail(f"No frames extracted to {output_dir}")
|
|
230
|
+
|
|
231
|
+
total_bytes = sum(f.stat().st_size for f in frames)
|
|
232
|
+
avg_bytes = total_bytes // len(frames)
|
|
233
|
+
print(
|
|
234
|
+
f"{prefix}{len(frames)} frames extracted "
|
|
235
|
+
f"({total_bytes / 1024 / 1024:.1f}MB total, {avg_bytes / 1024:.0f}KB avg per frame)",
|
|
236
|
+
file=sys.stderr,
|
|
237
|
+
)
|
|
238
|
+
w, h = resolution
|
|
239
|
+
return {
|
|
240
|
+
"count": len(frames),
|
|
241
|
+
"total_bytes": total_bytes,
|
|
242
|
+
"avg_bytes": avg_bytes,
|
|
243
|
+
"resolution": f"{w}x{h}",
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def extract_frames(input_path, output_dir, frame_count, resolution, quality, label=""):
|
|
248
|
+
"""Extract evenly-spaced frames from video as WebP.
|
|
249
|
+
|
|
250
|
+
Strategy:
|
|
251
|
+
1. If FFmpeg has libwebp: single-pass extraction to WebP (fastest)
|
|
252
|
+
2. Otherwise: extract as PNG, then convert to WebP via Pillow (universal)
|
|
253
|
+
"""
|
|
254
|
+
output_dir = Path(output_dir)
|
|
255
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
256
|
+
|
|
257
|
+
duration = _probe_duration(input_path)
|
|
258
|
+
target_fps = max(1, min(60, frame_count / duration))
|
|
259
|
+
|
|
260
|
+
prefix = f" [{label}] " if label else " "
|
|
261
|
+
use_libwebp = has_libwebp()
|
|
262
|
+
encoder = "FFmpeg libwebp" if use_libwebp else "FFmpeg + Pillow"
|
|
263
|
+
w, h = resolution
|
|
264
|
+
print(f"{prefix}Extracting {frame_count} frames at {w}x{h} ({encoder})...", file=sys.stderr)
|
|
265
|
+
|
|
266
|
+
cmd = _build_extract_cmd(input_path, output_dir, target_fps, resolution, quality, use_libwebp)
|
|
267
|
+
_run_ffmpeg(cmd)
|
|
268
|
+
if not use_libwebp:
|
|
269
|
+
_convert_pngs_to_webp(output_dir, quality, prefix)
|
|
270
|
+
|
|
271
|
+
return _measure_frames(output_dir, resolution, prefix)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _variant_summary(info):
|
|
275
|
+
"""Shape one resolution variant's extraction info for the manifest."""
|
|
276
|
+
return {
|
|
277
|
+
"resolution": info["resolution"],
|
|
278
|
+
"actual_count": info["count"],
|
|
279
|
+
"total_bytes": info["total_bytes"],
|
|
280
|
+
"avg_frame_bytes": info["avg_bytes"],
|
|
281
|
+
"total_mb": round(info["total_bytes"] / 1024 / 1024, 2),
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _source_summary(video_info):
|
|
286
|
+
"""Shape the source-video block of the manifest."""
|
|
287
|
+
return {
|
|
288
|
+
"filename": video_info["filename"],
|
|
289
|
+
"duration": video_info["duration"],
|
|
290
|
+
"resolution": f"{video_info['width']}x{video_info['height']}",
|
|
291
|
+
"fps": video_info["fps"],
|
|
292
|
+
"codec": video_info["codec"],
|
|
293
|
+
"total_source_frames": video_info["total_frames"],
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def generate_manifest(output_dir, video_info, frame_count, scroll_height,
|
|
298
|
+
quality, desktop_info=None, mobile_info=None):
|
|
299
|
+
"""Generate manifest.json with full metadata."""
|
|
300
|
+
manifest = {
|
|
301
|
+
"source": _source_summary(video_info),
|
|
302
|
+
"frames": {
|
|
303
|
+
"target_count": frame_count,
|
|
304
|
+
"format": "webp",
|
|
305
|
+
"quality": quality,
|
|
306
|
+
"naming_pattern": "frame-{NNNN}.webp",
|
|
307
|
+
},
|
|
308
|
+
"recommended_scroll_height": f"{scroll_height}vh",
|
|
309
|
+
"created": datetime.now().isoformat(timespec="seconds"),
|
|
310
|
+
}
|
|
311
|
+
if desktop_info:
|
|
312
|
+
manifest["desktop"] = _variant_summary(desktop_info)
|
|
313
|
+
if mobile_info:
|
|
314
|
+
manifest["mobile"] = _variant_summary(mobile_info)
|
|
315
|
+
|
|
316
|
+
manifest_path = Path(output_dir) / "manifest.json"
|
|
317
|
+
with open(manifest_path, "w") as f:
|
|
318
|
+
json.dump(manifest, f, indent=2)
|
|
319
|
+
print(f"\n Manifest saved to: {manifest_path}", file=sys.stderr)
|
|
320
|
+
return manifest
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _print_budget_warnings(manifest):
|
|
324
|
+
"""Warn when a variant's payload exceeds its budget."""
|
|
325
|
+
budgets = (("desktop", DESKTOP_BUDGET_MB), ("mobile", MOBILE_BUDGET_MB))
|
|
326
|
+
for variant, budget_mb in budgets:
|
|
327
|
+
total_mb = manifest.get(variant, {}).get("total_mb", 0)
|
|
328
|
+
if total_mb > budget_mb:
|
|
329
|
+
print(
|
|
330
|
+
f"\n WARNING: {variant.capitalize()} payload ({total_mb}MB) "
|
|
331
|
+
f"exceeds {budget_mb}MB target.",
|
|
332
|
+
file=sys.stderr,
|
|
333
|
+
)
|
|
334
|
+
print(" Consider: --quality 60 or --frames (lower count)", file=sys.stderr)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def print_summary(video_info, frame_count, scroll_height, manifest):
|
|
338
|
+
"""Print a human-readable summary."""
|
|
339
|
+
err = sys.stderr
|
|
340
|
+
print("\n" + "=" * 56, file=err)
|
|
341
|
+
print(" VIDEO ANALYSIS", file=err)
|
|
342
|
+
print("=" * 56, file=err)
|
|
343
|
+
print(f" Source: {video_info['filename']}", file=err)
|
|
344
|
+
print(f" Duration: {video_info['duration']}s", file=err)
|
|
345
|
+
print(f" Resolution: {video_info['width']}x{video_info['height']}", file=err)
|
|
346
|
+
print(f" Frame Rate: {video_info['fps']}fps", file=err)
|
|
347
|
+
print(f" Codec: {video_info['codec']}", file=err)
|
|
348
|
+
print(f" Src Frames: {video_info['total_frames']}", file=err)
|
|
349
|
+
print("-" * 56, file=err)
|
|
350
|
+
print(" EXTRACTION RESULTS", file=err)
|
|
351
|
+
print("-" * 56, file=err)
|
|
352
|
+
print(f" Target: {frame_count} frames", file=err)
|
|
353
|
+
print(f" Scroll: {scroll_height}vh recommended", file=err)
|
|
354
|
+
for variant in ("desktop", "mobile"):
|
|
355
|
+
if variant in manifest:
|
|
356
|
+
v = manifest[variant]
|
|
357
|
+
print(
|
|
358
|
+
f" {variant.capitalize():<12} {v['actual_count']} frames "
|
|
359
|
+
f"@ {v['resolution']} ({v['total_mb']}MB)",
|
|
360
|
+
file=err,
|
|
361
|
+
)
|
|
362
|
+
print("=" * 56, file=err)
|
|
363
|
+
_print_budget_warnings(manifest)
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _extract_variants(args, input_path, frame_count):
|
|
367
|
+
"""Extract the desktop and/or mobile frame sets per the CLI flags."""
|
|
368
|
+
output_dir = Path(args.output)
|
|
369
|
+
desktop_info = None
|
|
370
|
+
mobile_info = None
|
|
371
|
+
if not args.mobile_only:
|
|
372
|
+
desktop_info = extract_frames(
|
|
373
|
+
input_path, output_dir / "desktop", frame_count,
|
|
374
|
+
parse_resolution(args.desktop_res), args.quality, label="desktop",
|
|
375
|
+
)
|
|
376
|
+
if not args.desktop_only:
|
|
377
|
+
mobile_info = extract_frames(
|
|
378
|
+
input_path, output_dir / "mobile", frame_count,
|
|
379
|
+
parse_resolution(args.mobile_res), args.quality, label="mobile",
|
|
380
|
+
)
|
|
381
|
+
return desktop_info, mobile_info
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def main():
|
|
385
|
+
args = parse_args()
|
|
386
|
+
validate_dependencies()
|
|
387
|
+
validate_quality(args.quality)
|
|
388
|
+
input_path = validate_input(args.input)
|
|
389
|
+
|
|
390
|
+
print("\nProbing video...", file=sys.stderr)
|
|
391
|
+
video_info = probe_video(input_path)
|
|
392
|
+
frame_count, scroll_height = calculate_optimal_frames(video_info["duration"], args.frames)
|
|
393
|
+
|
|
394
|
+
Path(args.output).mkdir(parents=True, exist_ok=True)
|
|
395
|
+
desktop_info, mobile_info = _extract_variants(args, input_path, frame_count)
|
|
396
|
+
|
|
397
|
+
manifest = generate_manifest(
|
|
398
|
+
args.output, video_info, frame_count, scroll_height,
|
|
399
|
+
args.quality, desktop_info, mobile_info,
|
|
400
|
+
)
|
|
401
|
+
print_summary(video_info, frame_count, scroll_height, manifest)
|
|
402
|
+
# Machine-readable output on stdout for the calling agent to parse
|
|
403
|
+
print(json.dumps(manifest, indent=2))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
if __name__ == "__main__":
|
|
407
|
+
main()
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"_meta": {
|
|
3
3
|
"generator": "scripts/marketplace_gen.py",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.16.0",
|
|
5
5
|
"marketplace": "arkaos"
|
|
6
6
|
},
|
|
7
7
|
"structural": {
|
|
@@ -177,6 +177,16 @@
|
|
|
177
177
|
],
|
|
178
178
|
"collision": false
|
|
179
179
|
},
|
|
180
|
+
"animated-website": {
|
|
181
|
+
"depts": [
|
|
182
|
+
"dev"
|
|
183
|
+
],
|
|
184
|
+
"curated": false,
|
|
185
|
+
"plugins": [
|
|
186
|
+
"arkaos-dev@arkaos"
|
|
187
|
+
],
|
|
188
|
+
"collision": false
|
|
189
|
+
},
|
|
180
190
|
"api-design": {
|
|
181
191
|
"depts": [
|
|
182
192
|
"dev"
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
@@ -154,7 +154,10 @@ def _emit_skill(src_dir: Path, dest_dir: Path) -> None:
|
|
|
154
154
|
for resource in _RESOURCE_DIRS:
|
|
155
155
|
src = src_dir / resource
|
|
156
156
|
if src.is_dir():
|
|
157
|
-
shutil.copytree(
|
|
157
|
+
shutil.copytree(
|
|
158
|
+
src, dest_dir / resource, dirs_exist_ok=True,
|
|
159
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"),
|
|
160
|
+
)
|
|
158
161
|
|
|
159
162
|
|
|
160
163
|
def build_plugins() -> dict[str, list[str]]:
|