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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **The Operating System for AI Agent Teams.**
4
4
 
5
- 86 agents. 17 departments. 274 skills. Enterprise frameworks. Multi-runtime. One install.
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 205 skills, generated straight from the same sources the core uses. Inside Claude Code:
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.15.0
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. 274 skills. Multi-runtime. Dashboard. Knowledge RAG.
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.15.0",
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arkaos",
3
- "version": "4.15.0",
3
+ "version": "4.16.0",
4
4
  "description": "The Operating System for AI Agent Teams",
5
5
  "type": "module",
6
6
  "bin": {
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "arkaos-core"
3
- version = "4.15.0"
3
+ version = "4.16.0"
4
4
  description = "Core engine for ArkaOS — The Operating System for AI Agent Teams"
5
5
  readme = "README.md"
6
6
  license = {text = "MIT"}
@@ -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(src, dest_dir / resource, dirs_exist_ok=True)
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]]: