arkaos 4.14.4 → 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.
@@ -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.14.3",
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.14.4",
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.14.4"
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]]: