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.
- package/README.md +2 -2
- package/VERSION +1 -1
- package/arka/SKILL.md +1 -1
- package/core/hooks/pre_tool_use.py +20 -19
- package/core/workflow/specialist_authorization.py +140 -0
- package/core/workflow/specialist_enforcer.py +103 -19
- package/core/workflow/transcript_scope.py +74 -0
- 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
|
@@ -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]]:
|