@conceptcraft/mindframes 0.1.5 → 0.1.7

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/dist/index.js CHANGED
@@ -946,12 +946,21 @@ async function generateSpeech(ttsRequest) {
946
946
  const cost = parseFloat(response.headers.get("X-Cost-USD") || "0");
947
947
  const provider = response.headers.get("X-Provider") || "unknown";
948
948
  const format = response.headers.get("X-Audio-Format") || "mp3";
949
+ let timestamps;
950
+ const timestampsHeader = response.headers.get("X-Timestamps");
951
+ if (timestampsHeader) {
952
+ try {
953
+ timestamps = JSON.parse(timestampsHeader);
954
+ } catch {
955
+ }
956
+ }
949
957
  return {
950
958
  audioData,
951
959
  duration,
952
960
  cost,
953
961
  provider,
954
- format
962
+ format,
963
+ timestamps
955
964
  };
956
965
  }
957
966
  async function getVoices() {
@@ -1016,7 +1025,7 @@ async function pollForCompletion(checkFn, maxAttempts = 60, intervalMs = 2e3) {
1016
1025
  if (result.status === "completed" || result.status === "failed") {
1017
1026
  return result;
1018
1027
  }
1019
- await new Promise((resolve4) => setTimeout(resolve4, intervalMs));
1028
+ await new Promise((resolve6) => setTimeout(resolve6, intervalMs));
1020
1029
  }
1021
1030
  throw new ApiError("Operation timed out", 408, 1);
1022
1031
  }
@@ -1123,10 +1132,10 @@ function generateState() {
1123
1132
  async function findAvailablePort(start, end) {
1124
1133
  for (let port = start; port <= end; port++) {
1125
1134
  try {
1126
- await new Promise((resolve4, reject) => {
1135
+ await new Promise((resolve6, reject) => {
1127
1136
  const server = http.createServer();
1128
1137
  server.listen(port, () => {
1129
- server.close(() => resolve4());
1138
+ server.close(() => resolve6());
1130
1139
  });
1131
1140
  server.on("error", reject);
1132
1141
  });
@@ -1184,7 +1193,7 @@ async function exchangeCodeForTokens(tokenEndpoint, code, codeVerifier, redirect
1184
1193
  return response.json();
1185
1194
  }
1186
1195
  function startCallbackServer(port, expectedState) {
1187
- return new Promise((resolve4, reject) => {
1196
+ return new Promise((resolve6, reject) => {
1188
1197
  let timeoutId;
1189
1198
  let settled = false;
1190
1199
  const cleanup = () => {
@@ -1270,7 +1279,7 @@ function startCallbackServer(port, expectedState) {
1270
1279
  </html>
1271
1280
  `);
1272
1281
  cleanup();
1273
- resolve4({ code, state });
1282
+ resolve6({ code, state });
1274
1283
  });
1275
1284
  server.listen(port);
1276
1285
  process.once("SIGINT", onCancel);
@@ -2413,21 +2422,21 @@ Uploading ${options.file.length} file(s)...`));
2413
2422
  }
2414
2423
  });
2415
2424
  async function readStdin() {
2416
- return new Promise((resolve4) => {
2425
+ return new Promise((resolve6) => {
2417
2426
  let data = "";
2418
2427
  process.stdin.setEncoding("utf8");
2419
2428
  if (process.stdin.isTTY) {
2420
- resolve4("");
2429
+ resolve6("");
2421
2430
  return;
2422
2431
  }
2423
2432
  process.stdin.on("data", (chunk) => {
2424
2433
  data += chunk;
2425
2434
  });
2426
2435
  process.stdin.on("end", () => {
2427
- resolve4(data.trim());
2436
+ resolve6(data.trim());
2428
2437
  });
2429
2438
  setTimeout(() => {
2430
- resolve4(data.trim());
2439
+ resolve6(data.trim());
2431
2440
  }, 100);
2432
2441
  });
2433
2442
  }
@@ -3118,44 +3127,59 @@ var whoamiCommand = new Command13("whoami").description("Show current user and t
3118
3127
  }
3119
3128
  });
3120
3129
 
3121
- // src/commands/skill.ts
3130
+ // src/commands/skill/index.ts
3122
3131
  init_output();
3123
3132
  import { Command as Command14 } from "commander";
3124
3133
  import chalk12 from "chalk";
3125
- import { mkdirSync, writeFileSync, existsSync as existsSync2 } from "fs";
3126
- import { join } from "path";
3127
- import { homedir } from "os";
3128
- function generateSkillContent(b) {
3129
- const cmd2 = b.name;
3130
- const pkg = b.packageName;
3131
- const url = b.apiUrl;
3132
- const name = b.displayName;
3133
- return `---
3134
- name: ${cmd2}
3135
- description: Create AI-powered presentations from code, documentation, files, or any content. Use when the user wants to generate slides, presentations, or decks about their project, codebase, research, or ideas.
3136
- ---
3137
3134
 
3138
- # ${name} CLI
3135
+ // src/commands/skill/sections/frontmatter.ts
3136
+ var frontmatter = {
3137
+ title: "Frontmatter",
3138
+ render: (ctx) => `---
3139
+ name: ${ctx.cmd}
3140
+ description: Use when user asks to create presentations (slides, decks, pitch decks) or videos (product demos, explainers, social content, promos). Also handles voiceover and music generation.
3141
+ ---`
3142
+ };
3143
+
3144
+ // src/commands/skill/sections/header.ts
3145
+ var header3 = {
3146
+ title: "Header",
3147
+ render: (ctx) => `# ${ctx.name} CLI
3139
3148
 
3140
- Create professional presentations directly from your terminal. The CLI generates AI-powered slides from any context you provide - text, files, URLs, or piped content.
3149
+ Create professional presentations directly from your terminal. The CLI generates AI-powered slides from any context you provide - text, files, URLs, or piped content.`
3150
+ };
3141
3151
 
3142
- ## Prerequisites
3152
+ // src/commands/skill/sections/prerequisites.ts
3153
+ var prerequisites = {
3154
+ title: "Prerequisites",
3155
+ render: (ctx) => `## Prerequisites
3143
3156
 
3144
3157
  \`\`\`bash
3145
- # Install globally
3146
- npm install -g ${pkg}
3147
-
3148
- # Configure API key (get from ${url}/settings/api-keys)
3149
- ${cmd2} config init
3158
+ npm install -g ${ctx.pkg}
3159
+ ${ctx.cmd} login # Authenticate (opens browser)
3160
+ ${ctx.cmd} whoami # Verify auth
3150
3161
  \`\`\`
3151
3162
 
3152
- ## Core Workflow
3163
+ ### Authentication
3164
+
3165
+ If not authenticated, run \`${ctx.cmd} login\` - it opens browser for OAuth.
3166
+ If login fails or user declines, fall back to API key: \`${ctx.cmd} config init\``
3167
+ };
3168
+
3169
+ // src/commands/skill/sections/workflow.ts
3170
+ var workflow = {
3171
+ title: "Core Workflow",
3172
+ render: (ctx) => `## Core Workflow
3153
3173
 
3154
3174
  1. **Gather context** - Read relevant files, code, or documentation
3155
- 2. **Create presentation** - Pass context to \`${cmd2} create\`
3156
- 3. **Share URL** - Return the presentation link to the user
3175
+ 2. **Create presentation** - Pass context to \`${ctx.cmd} create\`
3176
+ 3. **Share URL** - Return the presentation link to the user`
3177
+ };
3157
3178
 
3158
- ## Commands
3179
+ // src/commands/skill/sections/create-command.ts
3180
+ var createCommand2 = {
3181
+ title: "Create Command",
3182
+ render: (ctx) => `## Commands
3159
3183
 
3160
3184
  ### Create Presentation
3161
3185
 
@@ -3163,27 +3187,31 @@ Context is **required**. Provide it via one of these methods:
3163
3187
 
3164
3188
  \`\`\`bash
3165
3189
  # Upload files (PDFs, PPTX, images, docs)
3166
- ${cmd2} create "Product Overview" --file ./deck.pptx --file ./logo.png
3190
+ ${ctx.cmd} create "Product Overview" --file ./deck.pptx --file ./logo.png
3167
3191
 
3168
3192
  # Direct text context
3169
- ${cmd2} create "Topic Title" --context "Key points, data, facts..."
3193
+ ${ctx.cmd} create "Topic Title" --context "Key points, data, facts..."
3170
3194
 
3171
3195
  # From a text file
3172
- ${cmd2} create "Topic Title" --context-file ./notes.md
3196
+ ${ctx.cmd} create "Topic Title" --context-file ./notes.md
3173
3197
 
3174
3198
  # Pipe content (auto-detected)
3175
- cat README.md | ${cmd2} create "Project Overview"
3199
+ cat README.md | ${ctx.cmd} create "Project Overview"
3176
3200
 
3177
3201
  # From URLs (scraped automatically)
3178
- ${cmd2} create "Competitor Analysis" --sources https://example.com/report
3202
+ ${ctx.cmd} create "Competitor Analysis" --sources https://example.com/report
3179
3203
 
3180
3204
  # Combine multiple sources
3181
- cat src/auth/*.ts | ${cmd2} create "Auth System" \\
3205
+ cat src/auth/*.ts | ${ctx.cmd} create "Auth System" \\
3182
3206
  --file ./architecture.png \\
3183
3207
  --context "Focus on security patterns"
3184
- \`\`\`
3208
+ \`\`\``
3209
+ };
3185
3210
 
3186
- ### Create Options
3211
+ // src/commands/skill/sections/create-options.ts
3212
+ var createOptions = {
3213
+ title: "Create Options",
3214
+ render: (_ctx) => `### Create Options
3187
3215
 
3188
3216
  | Option | Description | Default |
3189
3217
  |--------|-------------|---------|
@@ -3197,43 +3225,51 @@ cat src/auth/*.ts | ${cmd2} create "Auth System" \\
3197
3225
  | \`-f, --file <paths...>\` | Files to upload (PDF, PPTX, images, docs) | - |
3198
3226
  | \`-l, --language <lang>\` | Output language | en |
3199
3227
  | \`-b, --brand <id>\` | Branding ID to apply | - |
3200
- | \`-o, --output <format>\` | Output: \`human\`, \`json\`, \`quiet\` | human |
3228
+ | \`-o, --output <format>\` | Output: \`human\`, \`json\`, \`quiet\` | human |`
3229
+ };
3201
3230
 
3202
- ### Other Commands
3231
+ // src/commands/skill/sections/other-commands.ts
3232
+ var otherCommands = {
3233
+ title: "Other Commands",
3234
+ render: (ctx) => `### Other Commands
3203
3235
 
3204
3236
  \`\`\`bash
3205
3237
  # Check authentication
3206
- ${cmd2} whoami
3238
+ ${ctx.cmd} whoami
3207
3239
 
3208
3240
  # List presentations
3209
- ${cmd2} list
3210
- ${cmd2} list --format json
3241
+ ${ctx.cmd} list
3242
+ ${ctx.cmd} list --format json
3211
3243
 
3212
3244
  # Get presentation details
3213
- ${cmd2} get <id-or-slug>
3245
+ ${ctx.cmd} get <id-or-slug>
3214
3246
 
3215
3247
  # Export to ZIP
3216
- ${cmd2} export <id-or-slug> -o presentation.zip
3248
+ ${ctx.cmd} export <id-or-slug> -o presentation.zip
3217
3249
 
3218
3250
  # Import presentation
3219
- ${cmd2} import ./presentation.zip
3251
+ ${ctx.cmd} import ./presentation.zip
3220
3252
 
3221
3253
  # Manage branding
3222
- ${cmd2} branding list
3223
- ${cmd2} branding extract https://company.com
3254
+ ${ctx.cmd} branding list
3255
+ ${ctx.cmd} branding extract https://company.com
3224
3256
 
3225
3257
  # Install/manage this skill
3226
- ${cmd2} skill install
3227
- ${cmd2} skill show
3228
- \`\`\`
3258
+ ${ctx.cmd} skill install
3259
+ ${ctx.cmd} skill show
3260
+ \`\`\``
3261
+ };
3229
3262
 
3230
- ## Examples
3263
+ // src/commands/skill/sections/examples.ts
3264
+ var examples = {
3265
+ title: "Examples",
3266
+ render: (ctx) => `## Examples
3231
3267
 
3232
3268
  ### Present a Codebase Feature
3233
3269
 
3234
3270
  \`\`\`bash
3235
3271
  # Read the relevant files and create presentation
3236
- cat src/lib/auth.ts src/lib/session.ts | ${cmd2} create "Authentication System" \\
3272
+ cat src/lib/auth.ts src/lib/session.ts | ${ctx.cmd} create "Authentication System" \\
3237
3273
  --slides 8 --tone educational --audience "New developers" \\
3238
3274
  --goal train
3239
3275
  \`\`\`
@@ -3241,7 +3277,7 @@ cat src/lib/auth.ts src/lib/session.ts | ${cmd2} create "Authentication System"
3241
3277
  ### Technical Documentation with Diagrams
3242
3278
 
3243
3279
  \`\`\`bash
3244
- ${cmd2} create "API Reference" \\
3280
+ ${ctx.cmd} create "API Reference" \\
3245
3281
  --file ./docs/api.md \\
3246
3282
  --file ./diagrams/architecture.png \\
3247
3283
  --mode best --amount detailed \\
@@ -3251,14 +3287,14 @@ ${cmd2} create "API Reference" \\
3251
3287
  ### Quick Project Overview
3252
3288
 
3253
3289
  \`\`\`bash
3254
- cat README.md package.json | ${cmd2} create "Project Introduction" \\
3290
+ cat README.md package.json | ${ctx.cmd} create "Project Introduction" \\
3255
3291
  -m instant --slides 5
3256
3292
  \`\`\`
3257
3293
 
3258
3294
  ### Sales Deck from Existing Presentation
3259
3295
 
3260
3296
  \`\`\`bash
3261
- ${cmd2} create "Product Demo" \\
3297
+ ${ctx.cmd} create "Product Demo" \\
3262
3298
  --file ./existing-deck.pptx \\
3263
3299
  --goal persuade \\
3264
3300
  --audience "Enterprise buyers" \\
@@ -3268,14 +3304,18 @@ ${cmd2} create "Product Demo" \\
3268
3304
  ### Research Presentation
3269
3305
 
3270
3306
  \`\`\`bash
3271
- ${cmd2} create "Market Analysis" \\
3307
+ ${ctx.cmd} create "Market Analysis" \\
3272
3308
  --file ./research.pdf \\
3273
3309
  --sources https://report.com/industry.pdf \\
3274
3310
  --tone formal --audience "Executive team" \\
3275
3311
  --goal report
3276
- \`\`\`
3312
+ \`\`\``
3313
+ };
3277
3314
 
3278
- ## Output
3315
+ // src/commands/skill/sections/output.ts
3316
+ var output = {
3317
+ title: "Output",
3318
+ render: (ctx) => `## Output
3279
3319
 
3280
3320
  Successful creation returns:
3281
3321
  \`\`\`
@@ -3285,44 +3325,168 @@ Successful creation returns:
3285
3325
  Slides: 8
3286
3326
  Generated in: 45s \xB7 12,500 tokens
3287
3327
 
3288
- Open: ${url}/en/view/presentations/auth-system-v1-abc123
3328
+ Open: ${ctx.url}/en/view/presentations/auth-system-v1-abc123
3289
3329
  \`\`\`
3290
3330
 
3291
3331
  For scripting, use JSON output:
3292
3332
  \`\`\`bash
3293
- URL=$(${cmd2} create "Demo" --context "..." -o json | jq -r '.viewUrl')
3294
- \`\`\`
3333
+ URL=$(${ctx.cmd} create "Demo" --context "..." -o json | jq -r '.viewUrl')
3334
+ \`\`\``
3335
+ };
3295
3336
 
3296
- ## Best Practices
3337
+ // src/commands/skill/sections/best-practices.ts
3338
+ var bestPractices = {
3339
+ title: "Best Practices",
3340
+ render: (_ctx) => `## Best Practices
3297
3341
 
3298
3342
  1. **Provide rich context** - More context = better slides. Include code, docs, data.
3299
3343
  2. **Use file uploads for binary content** - PDFs, images, PPTX files need \`--file\`.
3300
3344
  3. **Specify a goal** - Helps tailor the presentation structure and messaging.
3301
3345
  4. **Use appropriate mode** - \`instant\` for quick drafts, \`best\` for important presentations.
3302
3346
  5. **Specify audience** - Helps tailor complexity and terminology.
3303
- 6. **Combine sources** - Pipe multiple files for comprehensive presentations.
3347
+ 6. **Combine sources** - Pipe multiple files for comprehensive presentations.`
3348
+ };
3304
3349
 
3305
- ## Supported File Types
3350
+ // src/commands/skill/sections/file-types.ts
3351
+ var fileTypes = {
3352
+ title: "Supported File Types",
3353
+ render: (_ctx) => `## Supported File Types
3306
3354
 
3307
3355
  - **Documents**: PDF, DOCX, XLSX, PPTX
3308
3356
  - **Images**: JPEG, PNG, GIF, WebP
3309
- - **Text**: Markdown, TXT, CSV, JSON
3357
+ - **Text**: Markdown, TXT, CSV, JSON`
3358
+ };
3310
3359
 
3311
- ## Troubleshooting
3360
+ // src/commands/skill/sections/troubleshooting.ts
3361
+ var troubleshooting = {
3362
+ title: "Troubleshooting",
3363
+ render: (ctx) => `## Troubleshooting
3312
3364
 
3313
3365
  \`\`\`bash
3314
3366
  # Check if authenticated
3315
- ${cmd2} whoami
3367
+ ${ctx.cmd} whoami
3316
3368
 
3317
- # Verify API key
3318
- ${cmd2} config show
3369
+ # Re-authenticate if needed
3370
+ ${ctx.cmd} login
3319
3371
 
3320
3372
  # Debug mode
3321
- ${cmd2} create "Test" --context "test" --debug
3322
- \`\`\`
3323
- `;
3373
+ ${ctx.cmd} create "Test" --context "test" --debug
3374
+ \`\`\``
3375
+ };
3376
+
3377
+ // src/commands/skill/sections/video-creation.ts
3378
+ var videoCreation = {
3379
+ title: "Video Creation",
3380
+ render: (ctx) => `## Video Creation
3381
+
3382
+ ### How to Create a Perfect Video
3383
+
3384
+ Videos must feel like premium tech launches (Stripe, Apple, Linear) - **Kinetic Composition**, not slideshows.
3385
+
3386
+ **Core Philosophy:** "Nothing sits still. Everything is physics-based. Every pixel breathes."
3387
+
3388
+ ### Required Reading (The Motion Bible)
3389
+
3390
+ | Rule | Why This Matters |
3391
+ |------|------------------|
3392
+ | [video-creation-guide.md](rules/video/video-creation-guide.md) | **The Motion Bible.** Camera rigs, 2.5D entrances, cursor physics, typography, backgrounds |
3393
+ | [failures.md](rules/video/failures.md) | **Avoid rejection.** 7 common mistakes that make videos look like slideshows |
3394
+ | [project-based.md](rules/video/project-based.md) | **Copy real components.** Eject actual UI code, strip logic, animate pixel-perfect |
3395
+ | [parameterization.md](rules/video/parameterization.md) | **Saves debugging.** Never hardcode frame numbers |
3396
+ | [layers.md](rules/video/layers.md) | **Prevents z-index bugs.** Background orbs \u2192 Vignette \u2192 CameraRig \u2192 Content |
3397
+ | [social-media.md](rules/video/social-media.md) | **Platform specs.** Resolution, aspect ratio, duration per platform |
3398
+
3399
+ **Required:** \`npx skills add https://github.com/remotion-dev/skills --skill remotion-best-practices\`
3400
+
3401
+ ### Workflow
3402
+
3403
+ #### Phase 1: Discovery
3404
+
3405
+ Explore the project thoroughly - assets, components, branding, what makes this product/topic unique.
3406
+
3407
+ #### Phase 2: Video Brief
3408
+
3409
+ Present a brief outline (scenes, duration, assets found) and get user approval before production.
3410
+
3411
+ #### Phase 3: Production
3412
+
3413
+ 1. **Read video-creation-guide.md + project-based.md + remotion-best-practices**
3414
+ 2. **Generate audio** - \`${ctx.cmd} video create\` with scenes JSON
3415
+ 3. **Scaffold OUTSIDE project** - \`cd .. && ${ctx.cmd} video init my-video\`
3416
+ 4. **Copy assets** - Logo, fonts, colors from project into video project
3417
+ 5. **Implement kinetically** - Camera rig, 2.5D entrances, staggered lists, transitions
3418
+ 6. **Verify checklist** - Is it kinetic or static?
3419
+
3420
+ #### Phase 4: Render
3421
+
3422
+ Auto-render when done: \`pnpm exec remotion render FullVideo\`
3423
+
3424
+ Only open Studio if user asks.
3425
+
3426
+ ### Kinetic Checklist
3427
+
3428
+ - [ ] CameraRig wraps entire scene with drift/zoom
3429
+ - [ ] Every UI element uses 2.5D rotation entrance (\`perspective + rotateX\`)
3430
+ - [ ] Cursor moves in Bezier curves with overshoot
3431
+ - [ ] Lists/grids stagger (never appear all at once)
3432
+ - [ ] Text uses masked reveal or keyword animation
3433
+ - [ ] Background has moving orbs + vignette + noise
3434
+ - [ ] Something is moving on EVERY frame
3435
+ - [ ] No static resting states longer than 30 frames
3436
+
3437
+ ### Asset Generation
3438
+
3439
+ \`\`\`bash
3440
+ cat <<EOF | ${ctx.cmd} video create --output ./public
3441
+ {
3442
+ "scenes": [
3443
+ { "name": "Hook", "script": "..." },
3444
+ { "name": "Demo", "script": "..." },
3445
+ { "name": "CTA", "script": "..." }
3446
+ ],
3447
+ "voice": "Kore",
3448
+ "musicPrompt": "upbeat corporate"
3449
+ }
3450
+ EOF
3451
+ \`\`\``
3452
+ };
3453
+
3454
+ // src/commands/skill/generate-content.ts
3455
+ var DEFAULT_SECTIONS = [
3456
+ frontmatter,
3457
+ header3,
3458
+ prerequisites,
3459
+ workflow,
3460
+ createCommand2,
3461
+ createOptions,
3462
+ otherCommands,
3463
+ examples,
3464
+ output,
3465
+ videoCreation,
3466
+ bestPractices,
3467
+ fileTypes,
3468
+ troubleshooting
3469
+ ];
3470
+ function createSkillContext(brand2) {
3471
+ return {
3472
+ cmd: brand2.name,
3473
+ pkg: brand2.packageName,
3474
+ url: brand2.apiUrl,
3475
+ name: brand2.displayName
3476
+ };
3477
+ }
3478
+ function generateSkillContent(brand2, sections = DEFAULT_SECTIONS) {
3479
+ const ctx = createSkillContext(brand2);
3480
+ return sections.map((section) => section.render(ctx)).join("\n\n");
3324
3481
  }
3325
- var EDITORS = [
3482
+
3483
+ // src/commands/skill/installer.ts
3484
+ import { mkdirSync, writeFileSync, existsSync as existsSync2, rmSync } from "fs";
3485
+ import { join, resolve as resolve4, relative } from "path";
3486
+ import { homedir } from "os";
3487
+
3488
+ // src/commands/skill/editors.ts
3489
+ var SUPPORTED_EDITORS = [
3326
3490
  { name: "Claude Code", dir: ".claude" },
3327
3491
  { name: "Cursor", dir: ".cursor" },
3328
3492
  { name: "Codex", dir: ".codex" },
@@ -3330,201 +3494,1346 @@ var EDITORS = [
3330
3494
  { name: "Windsurf", dir: ".windsurf" },
3331
3495
  { name: "Agent", dir: ".agent" }
3332
3496
  ];
3333
- var skillCommand = new Command14("skill").description(`Manage ${brand.displayName} skill for AI coding assistants`).addHelpText(
3334
- "after",
3335
- `
3336
- ${chalk12.bold("Examples:")}
3337
- ${chalk12.gray("# Install skill for all detected editors")}
3338
- $ ${brand.name} skill install
3339
3497
 
3340
- ${chalk12.gray("# Install to specific directory")}
3341
- $ ${brand.name} skill install --dir ~/.claude
3498
+ // src/commands/skill/rules/video/content.ts
3499
+ var VIDEO_RULE_CONTENTS = [
3500
+ {
3501
+ filename: "video-creation-guide.md",
3502
+ content: `# Video Creation Guide
3342
3503
 
3343
- ${chalk12.gray("# Show skill content")}
3344
- $ ${brand.name} skill show
3345
- `
3346
- );
3347
- skillCommand.command("install").description(`Install the ${brand.displayName} skill for AI coding assistants`).option("-d, --dir <path>", "Install to specific directory").option("-g, --global", "Install globally (to home directory)", true).option("-l, --local", "Install locally (to current directory)").option("-f, --force", "Overwrite existing skill files").action(async (options) => {
3348
- const installed = [];
3349
- const skipped = [];
3350
- const errors = [];
3351
- const baseDir = options.local ? process.cwd() : homedir();
3352
- const skillContent = generateSkillContent(brand);
3353
- if (options.dir) {
3354
- const skillPath = join(options.dir, "skills", brand.name);
3355
- try {
3356
- installSkill(skillPath, skillContent, options.force);
3357
- installed.push(options.dir);
3358
- } catch (err) {
3359
- errors.push(`${options.dir}: ${err instanceof Error ? err.message : String(err)}`);
3360
- }
3361
- } else {
3362
- for (const editor of EDITORS) {
3363
- const editorDir = join(baseDir, editor.dir);
3364
- const skillPath = join(editorDir, "skills", brand.name);
3365
- const skillFile = join(skillPath, "SKILL.md");
3366
- if (!existsSync2(editorDir)) {
3367
- continue;
3368
- }
3369
- if (existsSync2(skillFile) && !options.force) {
3370
- skipped.push(editor.name);
3371
- continue;
3372
- }
3373
- try {
3374
- installSkill(skillPath, skillContent, options.force);
3375
- installed.push(editor.name);
3376
- } catch (err) {
3377
- errors.push(`${editor.name}: ${err instanceof Error ? err.message : String(err)}`);
3378
- }
3379
- }
3380
- }
3381
- console.log();
3382
- if (installed.length > 0) {
3383
- success("Skill installed successfully");
3384
- console.log();
3385
- keyValue("Installed to", installed.join(", "));
3386
- }
3387
- if (skipped.length > 0) {
3388
- console.log();
3389
- info(`Skipped (already exists): ${skipped.join(", ")}`);
3390
- console.log(chalk12.gray(" Use --force to overwrite"));
3391
- }
3392
- if (errors.length > 0) {
3393
- console.log();
3394
- for (const err of errors) {
3395
- error(err);
3396
- }
3397
- }
3398
- if (installed.length === 0 && skipped.length === 0 && errors.length === 0) {
3399
- info("No supported AI coding assistants detected.");
3400
- console.log();
3401
- console.log(chalk12.gray("Supported editors: " + EDITORS.map((e) => e.name).join(", ")));
3402
- console.log(chalk12.gray("Use --dir <path> to install to a specific directory"));
3403
- }
3404
- console.log();
3405
- });
3406
- skillCommand.command("show").description("Display the skill content").action(() => {
3407
- console.log(generateSkillContent(brand));
3408
- });
3409
- skillCommand.command("uninstall").description(`Remove the ${brand.displayName} skill from AI coding assistants`).option("-g, --global", "Uninstall globally (from home directory)", true).option("-l, --local", "Uninstall locally (from current directory)").action(async (options) => {
3410
- const { rmSync } = await import("fs");
3411
- const removed = [];
3412
- const baseDir = options.local ? process.cwd() : homedir();
3413
- for (const editor of EDITORS) {
3414
- const skillPath = join(baseDir, editor.dir, "skills", brand.name);
3415
- if (existsSync2(skillPath)) {
3416
- try {
3417
- rmSync(skillPath, { recursive: true });
3418
- removed.push(editor.name);
3419
- } catch {
3420
- }
3421
- }
3422
- }
3423
- console.log();
3424
- if (removed.length > 0) {
3425
- success("Skill uninstalled");
3426
- keyValue("Removed from", removed.join(", "));
3427
- } else {
3428
- info("No installed skills found");
3429
- }
3430
- console.log();
3431
- });
3432
- function installSkill(skillPath, content, force) {
3433
- const skillFile = join(skillPath, "SKILL.md");
3434
- mkdirSync(skillPath, { recursive: true });
3435
- writeFileSync(skillFile, content, "utf-8");
3436
- }
3504
+ > **ALSO READ:**
3505
+ > - [project-based.md](project-based.md) - How to extract and animate real project components pixel-perfect
3506
+ > - If installed, check \`remotion-best-practices\` skill for Remotion-specific patterns
3437
3507
 
3438
- // src/commands/tts.ts
3439
- init_api();
3440
- init_output();
3441
- init_types();
3442
- import { Command as Command15 } from "commander";
3443
- import ora8 from "ora";
3444
- import { writeFile as writeFile2 } from "fs/promises";
3445
- var generateCommand = new Command15("generate").description("Generate speech from text").requiredOption("-t, --text <text>", "Text to convert to speech").requiredOption("-o, --output <path>", "Output file path").option("-v, --voice <voice>", "Voice name or ID (e.g., Kore, Rachel, alloy)").option("-p, --provider <provider>", "Provider: gemini, elevenlabs, openai").option("-m, --model <model>", "Model (provider-specific)").option("-s, --speed <speed>", "Speech speed 0.25-4.0 (default: 1.0)").option("-f, --format <format>", "Output format: human, json, quiet", "human").action(async (options) => {
3446
- const format = options.format;
3447
- const spinner = format === "human" ? ora8("Generating speech...").start() : null;
3448
- let speed;
3449
- if (options.speed) {
3450
- speed = parseFloat(options.speed);
3451
- if (isNaN(speed) || speed < 0.25 || speed > 4) {
3452
- spinner?.stop();
3453
- error("Speed must be between 0.25 and 4.0");
3454
- process.exit(EXIT_CODES.INVALID_INPUT);
3455
- }
3456
- }
3457
- try {
3458
- const result = await generateSpeech({
3459
- text: options.text,
3460
- options: {
3461
- provider: options.provider,
3462
- voice: options.voice,
3463
- model: options.model,
3464
- speed
3465
- }
3466
- });
3467
- spinner?.stop();
3468
- const outputPath = options.output.endsWith(`.${result.format}`) ? options.output : `${options.output}.${result.format}`;
3469
- await writeFile2(outputPath, result.audioData);
3470
- if (format === "json") {
3471
- printJson({
3472
- status: "completed",
3473
- output: outputPath,
3474
- duration: result.duration,
3475
- cost: result.cost,
3476
- provider: result.provider,
3477
- format: result.format
3478
- });
3479
- return;
3480
- }
3481
- if (format === "quiet") {
3482
- console.log(outputPath);
3483
- return;
3484
- }
3485
- success(`Saved to: ${outputPath}`);
3486
- info(`Duration: ${result.duration.toFixed(2)}s`);
3487
- info(`Provider: ${result.provider}`);
3488
- info(`Cost: $${result.cost.toFixed(6)}`);
3489
- } catch (err) {
3490
- spinner?.stop();
3491
- error(err instanceof Error ? err.message : "Unknown error");
3492
- process.exit(EXIT_CODES.GENERAL_ERROR);
3493
- }
3494
- });
3495
- var voicesCommand = new Command15("voices").description("List available voices").option("-p, --provider <provider>", "Filter by provider: gemini, elevenlabs, openai").option("-f, --format <format>", "Output format: human, json", "human").action(async (options) => {
3496
- const spinner = options.format === "human" ? ora8("Fetching voices...").start() : null;
3497
- try {
3498
- const result = await getVoices();
3499
- spinner?.stop();
3500
- if (options.format === "json") {
3501
- if (options.provider) {
3502
- const providerVoices = result.voices[options.provider];
3503
- printJson(providerVoices || []);
3504
- } else {
3505
- printJson(result.voices);
3506
- }
3507
- return;
3508
- }
3509
- const providers = options.provider ? [options.provider] : ["gemini", "elevenlabs", "openai"];
3510
- for (const provider of providers) {
3511
- const voices = result.voices[provider];
3512
- if (!voices || voices.length === 0) continue;
3513
- console.log();
3514
- console.log(`${provider.toUpperCase()} Voices:`);
3515
- console.log("-".repeat(50));
3516
- for (const voice of voices) {
3517
- console.log(` ${voice.name} (${voice.id})`);
3518
- console.log(` ${voice.description}`);
3519
- }
3520
- }
3521
- } catch (err) {
3522
- spinner?.stop();
3523
- error(err instanceof Error ? err.message : "Unknown error");
3524
- process.exit(EXIT_CODES.GENERAL_ERROR);
3525
- }
3508
+ ---
3509
+
3510
+ # The "Kinetic SaaS" Motion Design System
3511
+
3512
+ **Objective:** Replicate the high-energy, fluid feel of premium tech product videos (Stripe, Apple, Linear, Affable.ai).
3513
+
3514
+ **Core Philosophy:** "Nothing sits still. Everything is physics-based. Every pixel breathes."
3515
+
3516
+ This is the difference between **Static Composition** (slideshows) and **Kinetic Composition** (motion graphics). You must rebuild UI as individual animated layers and film with a virtual camera.
3517
+
3518
+ ---
3519
+
3520
+ ## 1. The Global Camera Rig (The "Anti-Static" Layer)
3521
+
3522
+ Even when reading text, the screen is slowly zooming or panning.
3523
+
3524
+ ### Virtual Camera
3525
+
3526
+ Every scene must be wrapped in a \`CameraRig\` component:
3527
+
3528
+ \`\`\`tsx
3529
+ const CameraRig: React.FC<{ children: React.ReactNode }> = ({ children }) => {
3530
+ const frame = useCurrentFrame();
3531
+ const { durationInFrames } = useVideoConfig();
3532
+
3533
+ // The "Drift" - constant subtle movement
3534
+ const scale = interpolate(frame, [0, durationInFrames], [1.0, 1.05]);
3535
+ const rotation = interpolate(frame, [0, durationInFrames], [0, 0.5]);
3536
+
3537
+ return (
3538
+ <AbsoluteFill style={{
3539
+ transform: \`scale(\${scale}) rotate(\${rotation}deg)\`,
3540
+ }}>
3541
+ {children}
3542
+ </AbsoluteFill>
3543
+ );
3544
+ };
3545
+ \`\`\`
3546
+
3547
+ ### Zoom-Through Transitions
3548
+
3549
+ Transitions are NOT just fades - they are camera movements:
3550
+
3551
+ \`\`\`tsx
3552
+ // Camera scales rapidly into an element (logo, dot) until it fills screen
3553
+ const zoomThrough = interpolate(frame, [TRANSITION_START, TRANSITION_END], [1, 50]);
3554
+ // The element becomes the background for the next scene
3555
+ \`\`\`
3556
+
3557
+ ---
3558
+
3559
+ ## 2. UI & Mockup Animation Rules (Rebuilding Reality)
3560
+
3561
+ UI doesn't just fade in. It pops up with weight.
3562
+
3563
+ ### Priority: Use Real Project Components
3564
+
3565
+ **If you're in a project folder, ALWAYS check for actual components first:**
3566
+
3567
+ \`\`\`bash
3568
+ # Before building ANY UI, explore the project
3569
+ ls src/components/
3570
+ ls src/app/
3571
+ \`\`\`
3572
+
3573
+ **When project components exist:**
3574
+ 1. **Copy the actual component** into your Remotion project
3575
+ 2. **Strip logic** (remove useState, API calls, event handlers)
3576
+ 3. **Keep visuals identical** (same styles, colors, spacing)
3577
+ 4. **Add animation props** (progress, isHovered, etc.)
3578
+ 5. **Apply kinetic animation** using the rules below
3579
+
3580
+ See [project-based.md](project-based.md) for the full component extraction process.
3581
+
3582
+ **This is the priority order:**
3583
+ 1. \u2705 Pixel-perfect copy of real project components (animated)
3584
+ 2. \u26A0\uFE0F Recreate UI from project's design system (colors, fonts, spacing)
3585
+ 3. \u274C Generic mockups or stock images (ONLY for non-UI scenes like hooks)
3586
+
3587
+ ### The "Pop-Up" Entrance (2.5D Rotation)
3588
+
3589
+ \`\`\`tsx
3590
+ // Heavy spring for weighty feel
3591
+ const progress = spring({
3592
+ frame,
3593
+ fps,
3594
+ config: { mass: 2, damping: 20, stiffness: 100 },
3526
3595
  });
3527
- var ttsCommand = new Command15("tts").description("Text-to-speech commands").addCommand(generateCommand).addCommand(voicesCommand);
3596
+
3597
+ // Start tilted back, spring to flat
3598
+ const rotateX = interpolate(progress, [0, 1], [20, 0]);
3599
+ const y = interpolate(progress, [0, 1], [100, 0]);
3600
+ const scale = interpolate(progress, [0, 1], [0.8, 1.0]);
3601
+
3602
+ <div style={{
3603
+ transform: \`perspective(1000px) rotateX(\${rotateX}deg) translateY(\${y}px) scale(\${scale})\`,
3604
+ }}>
3605
+ {uiComponent}
3606
+ </div>
3607
+ \`\`\`
3608
+
3609
+ ### Cursor Simulation
3610
+
3611
+ **Movement:** Never linear. Cursors move in **Bezier Curves** with an arc.
3612
+
3613
+ \`\`\`tsx
3614
+ // 1. Spring progress from start to end
3615
+ const progress = spring({
3616
+ frame: frame - startFrame,
3617
+ fps,
3618
+ config: { damping: 20, stiffness: 80 }, // Slower, deliberate
3619
+ });
3620
+
3621
+ // 2. Linear interpolation for X and Y
3622
+ const linearX = interpolate(progress, [0, 1], [start.x, end.x]);
3623
+ const linearY = interpolate(progress, [0, 1], [start.y, end.y]);
3624
+
3625
+ // 3. THE ARC: Parabola that peaks mid-travel (this is the secret sauce)
3626
+ const arcHeight = 100; // How much the cursor "loops"
3627
+ const arcOffset = Math.sin(progress * Math.PI) * arcHeight;
3628
+
3629
+ // 4. Apply arc to Y position
3630
+ const cursorY = linearY - arcOffset;
3631
+ \`\`\`
3632
+
3633
+ **Click Interaction:**
3634
+ \`\`\`tsx
3635
+ // On click:
3636
+ // 1. Cursor scales down
3637
+ const cursorScale = isClicking ? 0.8 : 1;
3638
+
3639
+ // 2. Button squishes
3640
+ const buttonScaleX = isClicking ? 1.05 : 1;
3641
+ const buttonScaleY = isClicking ? 0.95 : 1;
3642
+
3643
+ // 3. Release both with spring
3644
+ \`\`\`
3645
+
3646
+ **Standard Cursor SVG:**
3647
+ \`\`\`tsx
3648
+ // Mac-style cursor (use as reference, customize as needed)
3649
+ <svg width="32" height="32" viewBox="0 0 32 32" fill="none">
3650
+ <path
3651
+ d="M4 4L11.5 26L16 17.5L25 25L27 23L18.5 15L28 11.5L4 4Z"
3652
+ fill="black"
3653
+ stroke="white"
3654
+ strokeWidth="2"
3655
+ />
3656
+ </svg>
3657
+ \`\`\`
3658
+
3659
+ ### Staggered Lists & Grids
3660
+
3661
+ **Rule:** NEVER show a list or grid all at once.
3662
+
3663
+ \`\`\`tsx
3664
+ const STAGGER_FRAMES = 4; // ~0.05s at 60fps
3665
+
3666
+ {items.map((item, i) => {
3667
+ const delay = i * STAGGER_FRAMES;
3668
+ const progress = spring({
3669
+ frame: frame - delay,
3670
+ fps,
3671
+ config: { damping: 15, stiffness: 120 },
3672
+ });
3673
+
3674
+ return (
3675
+ <div style={{
3676
+ opacity: progress,
3677
+ transform: \`translateY(\${interpolate(progress, [0, 1], [20, 0])}px)\`,
3678
+ }}>
3679
+ {item}
3680
+ </div>
3681
+ );
3682
+ })}
3683
+ \`\`\`
3684
+
3685
+ ---
3686
+
3687
+ ## 3. Kinetic Typography (Text that Hits)
3688
+
3689
+ Text doesn't fade. It slams in, changes fill, or slides up.
3690
+
3691
+ ### Pattern A: The "Masked Reveal"
3692
+
3693
+ Text rises from a floor:
3694
+
3695
+ \`\`\`tsx
3696
+ <div style={{ overflow: 'hidden', height: 80 }}>
3697
+ <h1 style={{
3698
+ transform: \`translateY(\${interpolate(progress, [0, 1], [100, 0])}%)\`,
3699
+ }}>
3700
+ INTRODUCING
3701
+ </h1>
3702
+ </div>
3703
+ \`\`\`
3704
+
3705
+ ### Pattern B: Variable Weight (Animate Keywords)
3706
+
3707
+ Don't animate the whole sentence. Animate **keywords**:
3708
+
3709
+ \`\`\`tsx
3710
+ // "Teams waste HOURS" - only "HOURS" animates
3711
+ <p>
3712
+ Teams waste{' '}
3713
+ <span style={{
3714
+ transform: \`scale(\${interpolate(keywordProgress, [0, 1], [1, 1.2])})\`,
3715
+ color: interpolateColors(keywordProgress, [0, 1], ['#F0F0F0', '#FF6B6B']),
3716
+ textShadow: \`0 0 \${glowProgress * 20}px rgba(255,107,107,0.5)\`,
3717
+ }}>
3718
+ HOURS
3719
+ </span>
3720
+ </p>
3721
+ \`\`\`
3722
+
3723
+ ### Pattern C: The "Glitch/Tech" Accent
3724
+
3725
+ Chromatic aberration on impact:
3726
+
3727
+ \`\`\`tsx
3728
+ // Duplicate text, offset by 2px with color channels, flash for 2 frames
3729
+ {isImpactFrame && (
3730
+ <>
3731
+ <span style={{ position: 'absolute', left: -2, color: '#FF0000', opacity: 0.5 }}>TEXT</span>
3732
+ <span style={{ position: 'absolute', left: 2, color: '#0000FF', opacity: 0.5 }}>TEXT</span>
3733
+ </>
3734
+ )}
3735
+ \`\`\`
3736
+
3737
+ ### Text Colors
3738
+
3739
+ Never pure white (\`#FFF\`). Use \`#F0F0F0\` with subtle gradient or shadow for depth.
3740
+
3741
+ ---
3742
+
3743
+ ## 4. Atmosphere & Backgrounds (The "Deep Space")
3744
+
3745
+ Background is never a solid color. It's deep black with floating colored orbs.
3746
+
3747
+ ### The "Orb" System
3748
+
3749
+ \`\`\`tsx
3750
+ const MovingBackground: React.FC = () => {
3751
+ const frame = useCurrentFrame();
3752
+
3753
+ // Orbs move in figure-8 or circular patterns
3754
+ const orb1X = Math.sin(frame / 60) * 200;
3755
+ const orb1Y = Math.cos(frame / 80) * 100;
3756
+ const orb2X = Math.sin(frame / 70 + Math.PI) * 150;
3757
+ const orb2Y = Math.cos(frame / 90 + Math.PI) * 120;
3758
+
3759
+ return (
3760
+ <AbsoluteFill style={{ backgroundColor: '#0a0a0a' }}>
3761
+ {/* Orb 1 - Teal */}
3762
+ <div style={{
3763
+ position: 'absolute',
3764
+ width: 600,
3765
+ height: 600,
3766
+ borderRadius: '50%',
3767
+ background: 'radial-gradient(circle, rgba(20,184,166,0.3) 0%, transparent 70%)',
3768
+ filter: 'blur(100px)',
3769
+ transform: \`translate(\${orb1X}px, \${orb1Y}px)\`,
3770
+ left: '20%',
3771
+ top: '30%',
3772
+ }} />
3773
+
3774
+ {/* Orb 2 - Purple */}
3775
+ <div style={{
3776
+ position: 'absolute',
3777
+ width: 500,
3778
+ height: 500,
3779
+ borderRadius: '50%',
3780
+ background: 'radial-gradient(circle, rgba(168,85,247,0.3) 0%, transparent 70%)',
3781
+ filter: 'blur(100px)',
3782
+ transform: \`translate(\${orb2X}px, \${orb2Y}px)\`,
3783
+ right: '20%',
3784
+ bottom: '20%',
3785
+ }} />
3786
+ </AbsoluteFill>
3787
+ );
3788
+ };
3789
+ \`\`\`
3790
+
3791
+ ### Vignette & Noise
3792
+
3793
+ \`\`\`tsx
3794
+ // Noise texture overlay (5% opacity)
3795
+ <AbsoluteFill style={{
3796
+ backgroundImage: 'url(/noise.png)',
3797
+ opacity: 0.05,
3798
+ mixBlendMode: 'overlay',
3799
+ }} />
3800
+
3801
+ // Vignette (dark corners)
3802
+ <AbsoluteFill style={{
3803
+ background: 'radial-gradient(ellipse at center, transparent 40%, rgba(0,0,0,0.8) 100%)',
3804
+ }} />
3805
+ \`\`\`
3806
+
3807
+ ---
3808
+
3809
+ ## 5. Physics & Timing Reference
3810
+
3811
+ ### Spring Configs
3812
+
3813
+ | Use Case | Config | Feel |
3814
+ |----------|--------|------|
3815
+ | Standard (snappy) | \`{ mass: 1, damping: 15, stiffness: 120 }\` | Smooth, professional |
3816
+ | Heavy (UI mockups) | \`{ mass: 2, damping: 20, stiffness: 100 }\` | Weighty, premium |
3817
+ | Bouncy (attention) | \`{ mass: 1, damping: 10, stiffness: 150 }\` | Playful overshoot |
3818
+
3819
+ ### Timing Values
3820
+
3821
+ | Action | Frames | Notes |
3822
+ |--------|--------|-------|
3823
+ | Element entrance | 15-20 | Spring to rest |
3824
+ | Stagger gap | 3-5 | Between list items |
3825
+ | Hold on key info | 45-60 | Minimum read time |
3826
+ | Scene transition | 20-30 | Zoom-through |
3827
+
3828
+ ### The Golden Rule
3829
+
3830
+ Use \`interpolate(frame)\` to ensure \`scale\` or \`rotation\` is changing on **EVERY frame**. No static resting states.
3831
+
3832
+ ---
3833
+
3834
+ ## 6. Micro-Tricks from Premium Videos
3835
+
3836
+ ### The "Match Cut"
3837
+
3838
+ Small circle zooms to fill screen, becomes next scene's background:
3839
+
3840
+ \`\`\`tsx
3841
+ // Circle element scales 50x to wipe to next scene
3842
+ const circleScale = interpolate(frame, [MATCH_START, MATCH_END], [1, 50]);
3843
+ \`\`\`
3844
+
3845
+ ### Search Bar Typing
3846
+
3847
+ Include blinking cursor:
3848
+
3849
+ \`\`\`tsx
3850
+ const Typewriter: React.FC<{ text: string }> = ({ text }) => {
3851
+ const frame = useCurrentFrame();
3852
+ const charIndex = Math.floor(frame / 3); // 3 frames per char
3853
+ const showCursor = Math.floor(frame / 15) % 2 === 0; // Blink every 15 frames
3854
+
3855
+ return (
3856
+ <span>
3857
+ {text.slice(0, charIndex)}
3858
+ {showCursor && <span style={{ opacity: 0.8 }}>|</span>}
3859
+ </span>
3860
+ );
3861
+ };
3862
+ \`\`\`
3863
+
3864
+ ### The "Card Fan"
3865
+
3866
+ Stack cards, then fan out:
3867
+
3868
+ \`\`\`tsx
3869
+ const cards = [
3870
+ { rotation: -5, x: -20 },
3871
+ { rotation: 0, x: 0 },
3872
+ { rotation: 5, x: 20 },
3873
+ ];
3874
+
3875
+ {cards.map((card, i) => {
3876
+ const fanProgress = spring({ frame: frame - 30, fps, config: { damping: 15, stiffness: 100 } });
3877
+
3878
+ return (
3879
+ <div style={{
3880
+ position: 'absolute',
3881
+ transform: \`rotate(\${fanProgress * card.rotation}deg) translateX(\${fanProgress * card.x}px)\`,
3882
+ zIndex: i,
3883
+ }}>
3884
+ <ProfileCard />
3885
+ </div>
3886
+ );
3887
+ })}
3888
+ \`\`\`
3889
+
3890
+ ---
3891
+
3892
+ ## 7. Strict Animation Guidelines (DO NOT DEVIATE)
3893
+
3894
+ **ROLE:** Senior Motion Graphics Engineer for Remotion.
3895
+ **REFERENCE STYLE:** "High-End SaaS Product Launch" (e.g., Affable.ai, Linear.app).
3896
+
3897
+ ### Physics & Timing
3898
+
3899
+ - **No Linear Motion:** All spatial movement (X, Y, Scale) must use \`spring\`.
3900
+ - *Standard Spring:* \`{ mass: 1, damping: 15, stiffness: 120 }\` (Snappy but smooth)
3901
+ - *Heavy Spring (Mockups):* \`{ mass: 2, damping: 20, stiffness: 100 }\` (Feels weighty)
3902
+ - **Continuous Flow:** Use \`interpolate(frame)\` to ensure \`scale\` or \`rotation\` is changing slightly on EVERY frame. No static resting states.
3903
+
3904
+ ### UI Component Behavior
3905
+
3906
+ - **Entrance:** When a UI card enters, it must use **2.5D Rotation**.
3907
+ - *Code:* \`transform: perspective(1000px) rotateX(\${rotateX}deg) translateY(\${y}px)\`
3908
+ - *Values:* Start at \`rotateX(20deg)\` and \`y(100px)\`. Spring to \`0\`.
3909
+ - **Internal Staggering:** If the UI card contains a list or grid, use \`<Sequence>\` or \`delay\` to reveal items one by one (3 frame gap).
3910
+ - **Cursor Interaction:** If a cursor clicks a button:
3911
+ 1. Cursor scales \`1 -> 0.8\`
3912
+ 2. Button scales \`1 -> 0.95\`
3913
+ 3. Release both to \`1\` with a spring
3914
+
3915
+ ### Typography Patterns
3916
+
3917
+ - **Pattern A (Headline):** "Masked Slide Up". Text translates Y from \`100%\` inside a clipped container.
3918
+ - **Pattern B (Keywords):** "Scale & Glow". Keywords scale to \`1.1\` and emit a \`text-shadow\`.
3919
+ - **Colors:** Text is never pure white (\`#FFF\`). Use \`#F0F0F0\` with a subtle gradient or shadow to add depth.
3920
+
3921
+ ### Background "Aliveness"
3922
+
3923
+ - Create a \`<MovingBackground>\` component.
3924
+ - It must feature 2-3 \`AbsoluteFill\` gradient orbs that move in a continuous loop using \`Math.sin(frame / slowFactor)\`.
3925
+ - This ensures the video has "depth" behind the text.
3926
+
3927
+ ---
3928
+
3929
+ ## 8. Seamless Scene Transitions (No Hard Cuts)
3930
+
3931
+ In high-end motion design, transitions are rarely separate "effects" (like a cross-dissolve). The *outgoing* scene physically pushes, slides, or zooms into the *incoming* scene.
3932
+
3933
+ ### The "Wipe" Rule (No Hard Cuts)
3934
+
3935
+ - **Forbidden:** Never let Scene A end at frame X and Scene B start at frame X+1 with a simple cut.
3936
+ - **Required:** Scene B must exist *above* Scene A (z-index) for at least 15-20 frames before the cut happens.
3937
+ - **Mechanism:** Scene B enters using a "Wipe" motion (sliding in from the bottom/right) or a "Scale In" (expanding from a dot).
3938
+
3939
+ ### The "Zoom-Through" Technique (Best for SaaS)
3940
+
3941
+ As Scene A ends, the camera must **accelerate** its zoom:
3942
+
3943
+ \`\`\`tsx
3944
+ // Scene A Action: At duration - 20 frames, zoom way in
3945
+ const scaleA = interpolate(frame, [duration - 20, duration], [1.0, 5.0]);
3946
+
3947
+ // Scene B Action: Starts at scale 0.5 and springs to 1.0
3948
+ const scaleB = spring({ frame, fps, config: { damping: 14, stiffness: 120 } });
3949
+ const scaleBValue = interpolate(scaleB, [0, 1], [0.5, 1]);
3950
+ \`\`\`
3951
+
3952
+ Result: It looks like the camera flew *through* Scene A to find Scene B behind it.
3953
+
3954
+ ### The "Color Swipe" (Safety Net)
3955
+
3956
+ If a complex match-cut is too hard, use a "Curtain":
3957
+
3958
+ \`\`\`tsx
3959
+ // A solid colored AbsoluteFill slides in from left, covers screen for 2 frames,
3960
+ // then slides out to the right, revealing Scene B
3961
+ const curtainX = interpolate(frame, [0, 10, 12, 22], [-100, 0, 0, 100]);
3962
+ \`\`\`
3963
+
3964
+ ### TransitionWrapper Component
3965
+
3966
+ Use the \`TransitionWrapper\` from shared components:
3967
+
3968
+ \`\`\`tsx
3969
+ import { TransitionWrapper } from './shared';
3970
+
3971
+ // Types: 'slide' | 'zoom' | 'fade' | 'none'
3972
+ // enterFrom: 'left' | 'right' | 'bottom'
3973
+
3974
+ <TransitionWrapper type="slide" enterFrom="bottom">
3975
+ <YourScene />
3976
+ </TransitionWrapper>
3977
+ \`\`\`
3978
+
3979
+ ### Critical: Overlap Your Sequences
3980
+
3981
+ **This is the most important part.** To make transitions work, you must overlap sequences slightly. If Scene 1 is 90 frames, start Scene 2 at frame 75 (15-frame overlap).
3982
+
3983
+ \`\`\`tsx
3984
+ export const MyVideo = () => {
3985
+ return (
3986
+ <AbsoluteFill>
3987
+ {/* SCENE 1: Ends at frame 100 */}
3988
+ <Sequence from={0} durationInFrames={100}>
3989
+ <SceneOne />
3990
+ </Sequence>
3991
+
3992
+ {/* SCENE 2: Starts at frame 85 (15 frames early!) */}
3993
+ {/* TransitionWrapper slides it ON TOP of Scene 1 */}
3994
+ <Sequence from={85} durationInFrames={150}>
3995
+ <TransitionWrapper type="slide" enterFrom="bottom">
3996
+ <SceneTwo />
3997
+ </TransitionWrapper>
3998
+ </Sequence>
3999
+
4000
+ {/* SCENE 3: Starts early again */}
4001
+ <Sequence from={220} durationInFrames={150}>
4002
+ <TransitionWrapper type="zoom">
4003
+ <SceneThree />
4004
+ </TransitionWrapper>
4005
+ </Sequence>
4006
+ </AbsoluteFill>
4007
+ );
4008
+ };
4009
+ \`\`\`
4010
+
4011
+ **Why this fixes the "Light Switch" effect:** Because Scene 2 physically slides *over* Scene 1 while Scene 1 is still visible underneath, your brain registers it as continuous movement rather than a "cut."
4012
+
4013
+ ---
4014
+
4015
+ ## Quick Tips from Motion Devs
4016
+
4017
+ - **Don't use white backgrounds.** The 2.5D lighting and shadows pop much better on dark grey or black backgrounds.
4018
+ - **Overlap delays.** If Title starts at frame 10 and Card starts at frame 30, the title hasn't finished moving when the card appears. This overlap is crucial for fluidity.
4019
+ - **High FPS.** Ensure your \`remotion.config.ts\` is set to 60fps for springs to look smooth.
4020
+
4021
+ ---
4022
+
4023
+ ## Self-Check: Is It Kinetic?
4024
+
4025
+ Before rendering, verify:
4026
+
4027
+ - [ ] Camera rig wraps entire scene with drift/zoom
4028
+ - [ ] Every UI element uses 2.5D rotation entrance
4029
+ - [ ] Cursor moves in curves with overshoot
4030
+ - [ ] Lists/grids stagger (never appear all at once)
4031
+ - [ ] Text uses masked reveal or keyword animation
4032
+ - [ ] Background has moving orbs + vignette + noise
4033
+ - [ ] Something is moving on EVERY frame
4034
+ - [ ] No static resting states longer than 30 frames
4035
+ - [ ] Scene transitions overlap (no hard cuts between scenes)
4036
+ - [ ] TransitionWrapper used for slide/zoom entrances
4037
+
4038
+ If your video looks like PowerPoint slides with voiceover, **START OVER**.
4039
+ `
4040
+ },
4041
+ {
4042
+ filename: "failures.md",
4043
+ content: `# Common Failures - READ THIS FIRST
4044
+
4045
+ If your video looks like any of these, START OVER.
4046
+
4047
+ ## FAILURE 1: Slideshow
4048
+
4049
+ \`\`\`
4050
+ What you made:
4051
+ - Background image
4052
+ - Text appears on top
4053
+ - Text disappears
4054
+ - New background image
4055
+ - More text
4056
+
4057
+ This is PowerPoint, not a video. REJECTED.
4058
+ \`\`\`
4059
+
4060
+ ## FAILURE 2: Lorem Ipsum / Placeholder Content
4061
+
4062
+ \`\`\`
4063
+ What you made:
4064
+ - "Lorem ipsum dolor sit amet..."
4065
+ - "Sample text here"
4066
+ - Generic placeholder content
4067
+
4068
+ Use REAL content from the project. REJECTED.
4069
+ \`\`\`
4070
+
4071
+ ## FAILURE 3: Static UI Screenshot
4072
+
4073
+ \`\`\`
4074
+ What you made:
4075
+ - Screenshot of the app
4076
+ - Text overlay saying "Our Dashboard"
4077
+ - No motion, no interaction
4078
+
4079
+ Recreate the UI in code and ANIMATE it. REJECTED.
4080
+ \`\`\`
4081
+
4082
+ ## FAILURE 4: Elements Just Appearing
4083
+
4084
+ \`\`\`
4085
+ What you made:
4086
+ - Frame 0: nothing
4087
+ - Frame 1: element is fully visible
4088
+ - No transition, no animation
4089
+
4090
+ Every element must animate in with spring/easing. REJECTED.
4091
+ \`\`\`
4092
+
4093
+ ## FAILURE 5: Hard Cuts Between Scenes (The "Light Switch" Effect)
4094
+
4095
+ \`\`\`
4096
+ What you made:
4097
+ - Scene 1 ends at frame 100
4098
+ - Scene 2 starts at frame 101
4099
+ - No overlap, no slide, no zoom-through
4100
+ - Feels like a light switching on/off
4101
+
4102
+ FIX: Overlap sequences by 15-20 frames. Scene 2 must slide/zoom
4103
+ ON TOP of Scene 1 while Scene 1 is still visible. Use TransitionWrapper.
4104
+ REJECTED.
4105
+ \`\`\`
4106
+
4107
+ ## FAILURE 6: Static Camera
4108
+
4109
+ \`\`\`
4110
+ What you made:
4111
+ - Camera never moves
4112
+ - No drift, no zoom, no rotation
4113
+ - Feels like watching a screenshot
4114
+
4115
+ Camera must ALWAYS be subtly moving. REJECTED.
4116
+ \`\`\`
4117
+
4118
+ ## FAILURE 7: Linear Cursor Movement
4119
+
4120
+ \`\`\`
4121
+ What you made:
4122
+ - Cursor moves in straight lines
4123
+ - No overshoot on stops
4124
+ - Click has no feedback
4125
+
4126
+ Cursor must move in Bezier curves with overshoot. REJECTED.
4127
+ \`\`\`
4128
+ `
4129
+ },
4130
+ {
4131
+ filename: "parameterization.md",
4132
+ content: `# Parameterization (Critical)
4133
+
4134
+ Never hardcode frame numbers. Use variables for all keyframes.
4135
+
4136
+ ## Why
4137
+
4138
+ When you change timing early in video, everything else breaks if hardcoded.
4139
+
4140
+ ## Pattern
4141
+
4142
+ \`\`\`ts
4143
+ // Define keyframes as variables
4144
+ const SCENE_START = 0;
4145
+ const TITLE_IN = SCENE_START + 15;
4146
+ const TITLE_HOLD = TITLE_IN + 60;
4147
+ const TITLE_OUT = TITLE_HOLD + 15;
4148
+ const SUBTITLE_IN = TITLE_IN + 20; // Relative to title
4149
+ const SCENE_END = TITLE_OUT + 30;
4150
+
4151
+ // Use in animations
4152
+ opacity: interpolate(frame, [TITLE_IN, TITLE_IN + 15], [0, 1])
4153
+ \`\`\`
4154
+
4155
+ ## Scene Duration
4156
+
4157
+ \`\`\`ts
4158
+ // Calculate from keyframes, don't hardcode
4159
+ const sceneDuration = SCENE_END - SCENE_START;
4160
+ \`\`\`
4161
+
4162
+ ## Audio Sync
4163
+
4164
+ \`\`\`ts
4165
+ // If audio timestamp changes, only update one variable
4166
+ const VOICEOVER_START = 45;
4167
+ const VISUAL_CUE = VOICEOVER_START + 10; // Auto-adjusts
4168
+ \`\`\`
4169
+
4170
+ ## Multi-Scene
4171
+
4172
+ \`\`\`ts
4173
+ const SCENE_1_START = 0;
4174
+ const SCENE_1_END = SCENE_1_START + 90;
4175
+ const SCENE_2_START = SCENE_1_END - 15; // 15 frame overlap for transition
4176
+ const SCENE_2_END = SCENE_2_START + 120;
4177
+ \`\`\`
4178
+ `
4179
+ },
4180
+ {
4181
+ filename: "layers.md",
4182
+ content: `# Layers & Composition
4183
+
4184
+ Explicit z-index prevents visual bugs.
4185
+
4186
+ ## Z-Index Scale
4187
+
4188
+ | Layer | Z-Index | Examples |
4189
+ |-------|---------|----------|
4190
+ | Background orbs | 0 | Moving gradients, noise |
4191
+ | Vignette | 1 | Dark corners overlay |
4192
+ | UI Base | 10 | Main UI container |
4193
+ | UI Elements | 20 | Buttons, cards, inputs |
4194
+ | Overlays | 30 | Modals, tooltips, highlights |
4195
+ | Text/Captions | 40 | Titles, labels |
4196
+ | Cursor | 50 | Mouse pointer |
4197
+ | Debug | 100 | Frame counter (remove in final) |
4198
+
4199
+ ## Composition Structure
4200
+
4201
+ \`\`\`tsx
4202
+ <AbsoluteFill>
4203
+ {/* Background layer */}
4204
+ <MovingBackground />
4205
+
4206
+ {/* Vignette */}
4207
+ <Vignette />
4208
+
4209
+ {/* Camera rig wraps all content */}
4210
+ <CameraRig>
4211
+ <Sequence from={0}>
4212
+ <Scene1 />
4213
+ </Sequence>
4214
+ <Sequence from={SCENE_2_START}>
4215
+ <Scene2 />
4216
+ </Sequence>
4217
+ </CameraRig>
4218
+
4219
+ {/* Audio */}
4220
+ <Audio src={music} volume={0.3} />
4221
+ <Audio src={voiceover} />
4222
+ </AbsoluteFill>
4223
+ \`\`\`
4224
+
4225
+ ## Overlapping Sequences
4226
+
4227
+ For zoom-through transitions, scenes overlap:
4228
+
4229
+ \`\`\`tsx
4230
+ <Sequence from={0} durationInFrames={100}>
4231
+ <Scene1 />
4232
+ </Sequence>
4233
+ <Sequence from={80} durationInFrames={100}> {/* 20 frame overlap */}
4234
+ <Scene2 />
4235
+ </Sequence>
4236
+ \`\`\`
4237
+ `
4238
+ },
4239
+ {
4240
+ filename: "project-based.md",
4241
+ content: `# Component Extraction (Critical for Product Videos)
4242
+
4243
+ When creating a video for a project, **copy the actual components** - don't rebuild from scratch.
4244
+
4245
+ ## The Goal
4246
+
4247
+ Your video should show the REAL product UI, pixel-perfect. Viewers should not be able to tell the difference between a screenshot and your video (except yours is animated with kinetic motion).
4248
+
4249
+ ## Process: Eject \u2192 Simplify \u2192 Animate
4250
+
4251
+ ### Step 1: Find Components to Feature
4252
+
4253
+ \`\`\`bash
4254
+ # Explore the project structure
4255
+ ls src/components/
4256
+ ls src/app/
4257
+ # Find the key UI: dashboards, forms, cards, modals, etc.
4258
+ \`\`\`
4259
+
4260
+ ### Step 2: Copy Component Code
4261
+
4262
+ Copy the actual component file into your Remotion project.
4263
+
4264
+ ### Step 3: Eject - Strip Logic, Keep Visuals
4265
+
4266
+ \`\`\`tsx
4267
+ // BEFORE: Original component from project
4268
+ export function PricingCard({ plan, onSelect }) {
4269
+ const [loading, setLoading] = useState(false);
4270
+ const handleClick = async () => { /* API calls */ };
4271
+ return <div>...</div>;
4272
+ }
4273
+
4274
+ // AFTER: Ejected for Remotion
4275
+ export function PricingCard({ plan, progress, isHovered }) {
4276
+ // No useState, no API calls, no handlers
4277
+ // Just visual + animation props from Remotion
4278
+
4279
+ const entranceProgress = spring({ frame: progress, fps, config: { mass: 2, damping: 20, stiffness: 100 } });
4280
+ const rotateX = interpolate(entranceProgress, [0, 1], [20, 0]);
4281
+
4282
+ return (
4283
+ <div style={{
4284
+ transform: \`perspective(1000px) rotateX(\${rotateX}deg)\`,
4285
+ // ... exact same visual styles as original
4286
+ }}>
4287
+ ...
4288
+ </div>
4289
+ );
4290
+ }
4291
+ \`\`\`
4292
+
4293
+ ### Step 4: Add Kinetic Animation
4294
+
4295
+ Apply the rules from kinetic-saas.md:
4296
+ - 2.5D rotation entrance
4297
+ - Spring physics
4298
+ - Staggered children
4299
+ - Cursor interaction states
4300
+
4301
+ ### Step 5: Fill With Real Demo Data
4302
+
4303
+ \`\`\`tsx
4304
+ // Use realistic data that matches the product
4305
+ const DEMO_PLANS = [
4306
+ { name: 'Starter', price: 9, features: ['5 projects', '10GB storage'] },
4307
+ { name: 'Pro', price: 29, features: ['Unlimited projects', '100GB storage', 'Priority support'] },
4308
+ ];
4309
+ // NOT: "Plan A", "$XX/mo", "Feature 1"
4310
+ \`\`\`
4311
+
4312
+ ## What to Copy
4313
+
4314
+ | Find in Project | Copy to Video |
4315
+ |-----------------|---------------|
4316
+ | \`tailwind.config.js\` | Colors, fonts, spacing |
4317
+ | \`globals.css\` | CSS variables, font imports |
4318
+ | \`/public/logo.svg\` | Actual logo file |
4319
+ | \`/src/components/*.tsx\` | Component structure & styles |
4320
+
4321
+ ## Common Mistakes
4322
+
4323
+ \u274C Using placeholder colors like \`#333\`
4324
+ \u2705 Using exact project colors from tailwind config
4325
+
4326
+ \u274C Generic content: "Feature 1", "Lorem ipsum"
4327
+ \u2705 Real content: "Unlimited projects", "Priority support"
4328
+
4329
+ \u274C Building from scratch based on screenshots
4330
+ \u2705 Copying actual component code and ejecting it
4331
+
4332
+ \u274C Static fade-in animations
4333
+ \u2705 2.5D rotation with spring physics (kinetic style)
4334
+ `
4335
+ },
4336
+ {
4337
+ filename: "social-media.md",
4338
+ content: `# Social Media Video Guide (TikTok/Reels/Shorts)
4339
+
4340
+ **Platforms:** TikTok, Instagram Reels, YouTube Shorts (all 9:16 vertical)
4341
+
4342
+ **Philosophy:** "Don't make ads. Make TikToks." Your video must look native to the platform - lo-fi beats polished.
4343
+
4344
+ ---
4345
+
4346
+ ## 1. The "3-Second War"
4347
+
4348
+ You have 3 seconds before the thumb scrolls. Win or lose everything.
4349
+
4350
+ ### Hook Requirements (0-3s)
4351
+
4352
+ - **Text on screen in FRAME 1** - no waiting for audio
4353
+ - **Movement** - never start static
4354
+ - **Pattern interrupt** - something unexpected
4355
+
4356
+ ### Hook Types
4357
+
4358
+ | Type | Description | Use For |
4359
+ |------|-------------|---------|
4360
+ | Face Close-Up | Tight on face, zoom out | Personal/story |
4361
+ | Green Screen | Speaker + background tweet/article | Commentary |
4362
+ | Text Slam | Bold text hits screen with sound | Listicles, tips |
4363
+ | Movement | Object thrown, quick zoom | Product demos |
4364
+
4365
+ ### Audio Hooks
4366
+
4367
+ Bad: "Today I want to talk about..."
4368
+ Good: "Stop scrolling if you hate [X]." / "This feels illegal to know."
4369
+
4370
+ ---
4371
+
4372
+ ## 2. Pacing Rules (No Dead Air)
4373
+
4374
+ | Rule | Implementation |
4375
+ |------|----------------|
4376
+ | Visual change every 2-3s | Cut, zoom, or new element every 60-90 frames |
4377
+ | No "Millennial Pause" | Never start with 1-2s of stillness |
4378
+ | Remove all breaths | Edit out pauses between sentences |
4379
+ | Jump cuts are native | Don't smooth-cut; jump cuts feel authentic |
4380
+
4381
+ ---
4382
+
4383
+ ## 3. Native Lo-Fi Aesthetic
4384
+
4385
+ | Looks Like TV Ad (Bad) | Looks Native (Good) |
4386
+ |------------------------|---------------------|
4387
+ | Perfect lighting | Natural/ring light |
4388
+ | Broadcast fonts | Platform-native fonts |
4389
+ | Smooth transitions | Jump cuts |
4390
+ | Color graded | Raw, slightly oversaturated |
4391
+
4392
+ ### Native Font Stack
4393
+
4394
+ \`\`\`tsx
4395
+ const NATIVE_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif";
4396
+ \`\`\`
4397
+
4398
+ ---
4399
+
4400
+ ## 4. Safe Zone Rules (CRITICAL)
4401
+
4402
+ Platform UI overlays your content. Plan for it.
4403
+
4404
+ \`\`\`
4405
+ 1080x1920 Canvas
4406
+ +------------------------------------------+
4407
+ | TOP SAFE ZONE (150px) | <- Notch, status bar
4408
+ | ----------------------------------------|
4409
+ | |
4410
+ | CONTENT SAFE ZONE |
4411
+ | (60px left, 120px right margins) |
4412
+ | |
4413
+ | ----------------------------------------|
4414
+ | BOTTOM SAFE ZONE (384px / 20%) | <- Captions, username
4415
+ | [Icon strip on right: 120px] | <- Like, Comment, Share
4416
+ +------------------------------------------+
4417
+ \`\`\`
4418
+
4419
+ ### Safe Zone Values
4420
+
4421
+ \`\`\`tsx
4422
+ const SAFE_ZONE = {
4423
+ top: 150, // Notch/status bar
4424
+ bottom: 384, // 20% of 1920 - captions/username
4425
+ left: 60, // Edge safety
4426
+ right: 120, // Like/Comment/Share icons
4427
+ };
4428
+
4429
+ // Captions must sit ABOVE bottom safe zone
4430
+ const CAPTION_BOTTOM = 350; // px from bottom
4431
+ \`\`\`
4432
+
4433
+ ### SafeZoneGuide Component
4434
+
4435
+ \`\`\`tsx
4436
+ const SafeZoneGuide: React.FC = () => (
4437
+ <AbsoluteFill style={{ pointerEvents: "none" }}>
4438
+ {/* Top danger zone */}
4439
+ <div style={{
4440
+ position: "absolute", top: 0, left: 0, right: 0, height: 150,
4441
+ backgroundColor: "rgba(255,0,0,0.2)", borderBottom: "2px dashed red",
4442
+ }} />
4443
+ {/* Bottom danger zone */}
4444
+ <div style={{
4445
+ position: "absolute", bottom: 0, left: 0, right: 0, height: 384,
4446
+ backgroundColor: "rgba(255,0,0,0.2)", borderTop: "2px dashed red",
4447
+ }} />
4448
+ {/* Right icon strip */}
4449
+ <div style={{
4450
+ position: "absolute", top: 150, right: 0, bottom: 384, width: 120,
4451
+ backgroundColor: "rgba(255,165,0,0.2)", borderLeft: "2px dashed orange",
4452
+ }} />
4453
+ </AbsoluteFill>
4454
+ );
4455
+ \`\`\`
4456
+
4457
+ ---
4458
+
4459
+ ## 5. Content Formats
4460
+
4461
+ ### Green Screen (Speaker + Background)
4462
+
4463
+ \`\`\`tsx
4464
+ <AbsoluteFill>
4465
+ <Img src={backgroundImage} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
4466
+ <div style={{
4467
+ position: "absolute", bottom: 400, left: 40,
4468
+ width: 300, height: 400, borderRadius: 20, overflow: "hidden",
4469
+ }}>
4470
+ <Video src={speakerVideo} />
4471
+ </div>
4472
+ </AbsoluteFill>
4473
+ \`\`\`
4474
+
4475
+ ### Listicle (Rapid-Fire)
4476
+
4477
+ \`\`\`tsx
4478
+ const FRAMES_PER_ITEM = 60; // 2 seconds per item
4479
+ const currentIndex = Math.floor(frame / FRAMES_PER_ITEM);
4480
+ // Slam-in with spring animation for each item
4481
+ \`\`\`
4482
+
4483
+ ### Visual ASMR (No Talking)
4484
+
4485
+ - No voiceover, music only
4486
+ - Slow, deliberate cursor movements
4487
+ - Satisfying click feedback
4488
+ - Ken Burns zoom on details
4489
+
4490
+ ### POV Skit
4491
+
4492
+ \`\`\`tsx
4493
+ <div style={{ position: "absolute", top: 160, left: 40, right: 120, fontSize: 42 }}>
4494
+ POV: {setup}
4495
+ </div>
4496
+ \`\`\`
4497
+
4498
+ ---
4499
+
4500
+ ## 6. TikTok Captions
4501
+
4502
+ Use \`@remotion/captions\` with \`createTikTokStyleCaptions\`:
4503
+
4504
+ \`\`\`tsx
4505
+ import { createTikTokStyleCaptions } from "@remotion/captions";
4506
+
4507
+ const { pages } = createTikTokStyleCaptions({
4508
+ captions: transcriptCaptions,
4509
+ combineTokensWithinMilliseconds: 1200, // Higher = more words per page
4510
+ });
4511
+ \`\`\`
4512
+
4513
+ ### Word-by-Word Highlighting
4514
+
4515
+ \`\`\`tsx
4516
+ {page.tokens.map((token) => {
4517
+ const isActive = currentTimeMs >= token.fromMs && currentTimeMs < token.toMs;
4518
+ return (
4519
+ <span style={{
4520
+ color: isActive ? "#FFD700" : "#fff",
4521
+ fontSize: 64,
4522
+ fontWeight: 900,
4523
+ textTransform: "uppercase",
4524
+ }}>
4525
+ {token.text}
4526
+ </span>
4527
+ );
4528
+ })}
4529
+ \`\`\`
4530
+
4531
+ ---
4532
+
4533
+ ## 7. Script Structure (60 Seconds)
4534
+
4535
+ | Timestamp | Section | Purpose |
4536
+ |-----------|---------|---------|
4537
+ | 0:00-0:03 | **HOOK** | Pattern interrupt, bold claim |
4538
+ | 0:03-0:10 | **PROBLEM** | Agitate the pain point |
4539
+ | 0:10-0:40 | **SOLUTION** | Rapid value delivery |
4540
+ | 0:40-0:55 | **PROOF** | Show, don't tell |
4541
+ | 0:55-0:60 | **CTA** | Soft ask (Follow/Link in bio) |
4542
+
4543
+ ### CTA Best Practices
4544
+
4545
+ | Do | Don't |
4546
+ |----|-------|
4547
+ | "Follow for more tips" | "SMASH that like button" |
4548
+ | "Link in bio" | "Click the link below" |
4549
+ | "Save this for later" | "Subscribe to my channel" |
4550
+
4551
+ ---
4552
+
4553
+ ## 8. Quick Reference
4554
+
4555
+ ### Platform Specs
4556
+
4557
+ | Platform | Ratio | Resolution | Duration | FPS |
4558
+ |----------|-------|------------|----------|-----|
4559
+ | TikTok | 9:16 | 1080x1920 | 15-60s | 30 |
4560
+ | Reels | 9:16 | 1080x1920 | 15-90s | 30 |
4561
+ | Shorts | 9:16 | 1080x1920 | 15-60s | 30 |
4562
+ | YouTube | 16:9 | 1920x1080 | any | 30/60 |
4563
+
4564
+ ### Timing (30fps)
4565
+
4566
+ | Action | Frames | Seconds |
4567
+ |--------|--------|---------|
4568
+ | Hook impact | 0-3 | 0-0.1s |
4569
+ | Text hold min | 45 | 1.5s |
4570
+ | Scene change max | 90 | 3s |
4571
+ | Ideal scene | 60 | 2s |
4572
+
4573
+ ### Self-Check
4574
+
4575
+ - [ ] Text in FRAME 1 (no millennial pause)
4576
+ - [ ] Visual change every 2-3 seconds
4577
+ - [ ] All content within safe zones
4578
+ - [ ] Captions at bottom: 350px+
4579
+ - [ ] No content in bottom 20% or right 120px
4580
+ - [ ] Lo-fi aesthetic (not TV ad)
4581
+ - [ ] CTA is soft, not desperate
4582
+ - [ ] Duration 15-60 seconds
4583
+ `
4584
+ }
4585
+ ];
4586
+
4587
+ // src/commands/skill/installer.ts
4588
+ function validatePath(basePath, targetPath) {
4589
+ const resolvedBase = resolve4(basePath);
4590
+ const resolvedTarget = resolve4(basePath, targetPath);
4591
+ const relativePath = relative(resolvedBase, resolvedTarget);
4592
+ if (relativePath.startsWith("..") || resolve4(resolvedTarget) !== resolvedTarget.replace(/\.\./g, "")) {
4593
+ throw new Error(`Invalid path: "${targetPath}" would escape base directory`);
4594
+ }
4595
+ return resolvedTarget;
4596
+ }
4597
+ function getRuleFiles() {
4598
+ const rules = [];
4599
+ for (const rule of VIDEO_RULE_CONTENTS) {
4600
+ rules.push({
4601
+ path: `rules/video/${rule.filename}`,
4602
+ content: rule.content
4603
+ });
4604
+ }
4605
+ return rules;
4606
+ }
4607
+ function installSkillToPath(skillPath, content) {
4608
+ const skillFile = join(skillPath, "SKILL.md");
4609
+ mkdirSync(skillPath, { recursive: true });
4610
+ writeFileSync(skillFile, content, "utf-8");
4611
+ const rules = getRuleFiles();
4612
+ for (const rule of rules) {
4613
+ const rulePath = join(skillPath, rule.path);
4614
+ const ruleDir = join(skillPath, rule.path.split("/").slice(0, -1).join("/"));
4615
+ mkdirSync(ruleDir, { recursive: true });
4616
+ writeFileSync(rulePath, rule.content, "utf-8");
4617
+ }
4618
+ }
4619
+ function installSkill(skillName, content, options = {}) {
4620
+ const result = {
4621
+ installed: [],
4622
+ skipped: [],
4623
+ errors: []
4624
+ };
4625
+ const baseDir = options.local ? process.cwd() : homedir();
4626
+ if (options.dir) {
4627
+ try {
4628
+ const resolvedDir = resolve4(options.dir);
4629
+ const skillPath = validatePath(resolvedDir, join("skills", skillName));
4630
+ installSkillToPath(skillPath, content);
4631
+ result.installed.push(options.dir);
4632
+ } catch (err) {
4633
+ result.errors.push(`${options.dir}: ${err instanceof Error ? err.message : String(err)}`);
4634
+ }
4635
+ } else {
4636
+ for (const editor of SUPPORTED_EDITORS) {
4637
+ const editorDir = join(baseDir, editor.dir);
4638
+ const skillPath = join(editorDir, "skills", skillName);
4639
+ const skillFile = join(skillPath, "SKILL.md");
4640
+ if (!existsSync2(editorDir)) {
4641
+ continue;
4642
+ }
4643
+ if (existsSync2(skillFile) && !options.force) {
4644
+ result.skipped.push(editor.name);
4645
+ continue;
4646
+ }
4647
+ try {
4648
+ installSkillToPath(skillPath, content);
4649
+ result.installed.push(editor.name);
4650
+ } catch (err) {
4651
+ result.errors.push(`${editor.name}: ${err instanceof Error ? err.message : String(err)}`);
4652
+ }
4653
+ }
4654
+ }
4655
+ return result;
4656
+ }
4657
+ function uninstallSkill(skillName, options = {}) {
4658
+ const result = {
4659
+ removed: [],
4660
+ errors: []
4661
+ };
4662
+ const baseDir = options.local ? process.cwd() : homedir();
4663
+ for (const editor of SUPPORTED_EDITORS) {
4664
+ const skillPath = join(baseDir, editor.dir, "skills", skillName);
4665
+ if (existsSync2(skillPath)) {
4666
+ try {
4667
+ rmSync(skillPath, { recursive: true });
4668
+ result.removed.push(editor.name);
4669
+ } catch (err) {
4670
+ result.errors.push(`${editor.name}: ${err instanceof Error ? err.message : String(err)}`);
4671
+ }
4672
+ }
4673
+ }
4674
+ return result;
4675
+ }
4676
+ function getSupportedEditorNames() {
4677
+ return SUPPORTED_EDITORS.map((e) => e.name);
4678
+ }
4679
+
4680
+ // src/commands/skill/index.ts
4681
+ var skillCommand = new Command14("skill").description(`Manage ${brand.displayName} skill for AI coding assistants`).addHelpText(
4682
+ "after",
4683
+ `
4684
+ ${chalk12.bold("Examples:")}
4685
+ ${chalk12.gray("# Install skill for all detected editors")}
4686
+ $ ${brand.name} skill install
4687
+
4688
+ ${chalk12.gray("# Install to specific directory")}
4689
+ $ ${brand.name} skill install --dir ~/.claude
4690
+
4691
+ ${chalk12.gray("# Show skill content")}
4692
+ $ ${brand.name} skill show
4693
+ `
4694
+ );
4695
+ skillCommand.command("install").description(`Install the ${brand.displayName} skill for AI coding assistants`).option("-d, --dir <path>", "Install to specific directory").option("-g, --global", "Install globally (to home directory)", true).option("-l, --local", "Install locally (to current directory)").option("-f, --force", "Overwrite existing skill files").action(async (options) => {
4696
+ const skillContent = generateSkillContent(brand);
4697
+ const result = installSkill(brand.name, skillContent, {
4698
+ dir: options.dir,
4699
+ local: options.local,
4700
+ force: options.force
4701
+ });
4702
+ console.log();
4703
+ if (result.installed.length > 0) {
4704
+ success("Skill installed successfully");
4705
+ console.log();
4706
+ keyValue("Installed to", result.installed.join(", "));
4707
+ }
4708
+ if (result.skipped.length > 0) {
4709
+ console.log();
4710
+ info(`Skipped (already exists): ${result.skipped.join(", ")}`);
4711
+ console.log(chalk12.gray(" Use --force to overwrite"));
4712
+ }
4713
+ if (result.errors.length > 0) {
4714
+ console.log();
4715
+ for (const err of result.errors) {
4716
+ error(err);
4717
+ }
4718
+ }
4719
+ if (result.installed.length === 0 && result.skipped.length === 0 && result.errors.length === 0) {
4720
+ info("No supported AI coding assistants detected.");
4721
+ console.log();
4722
+ console.log(chalk12.gray("Supported editors: " + getSupportedEditorNames().join(", ")));
4723
+ console.log(chalk12.gray("Use --dir <path> to install to a specific directory"));
4724
+ }
4725
+ console.log();
4726
+ });
4727
+ skillCommand.command("show").description("Display the skill content").action(() => {
4728
+ console.log(generateSkillContent(brand));
4729
+ });
4730
+ skillCommand.command("uninstall").description(`Remove the ${brand.displayName} skill from AI coding assistants`).option("-g, --global", "Uninstall globally (from home directory)", true).option("-l, --local", "Uninstall locally (from current directory)").action(async (options) => {
4731
+ const result = uninstallSkill(brand.name, { local: options.local });
4732
+ console.log();
4733
+ if (result.removed.length > 0) {
4734
+ success("Skill uninstalled");
4735
+ keyValue("Removed from", result.removed.join(", "));
4736
+ } else {
4737
+ info("No installed skills found");
4738
+ }
4739
+ if (result.errors.length > 0) {
4740
+ for (const err of result.errors) {
4741
+ warn(`Failed to remove: ${err}`);
4742
+ }
4743
+ }
4744
+ console.log();
4745
+ });
4746
+
4747
+ // src/commands/tts.ts
4748
+ init_api();
4749
+ init_output();
4750
+ init_types();
4751
+ import { Command as Command15 } from "commander";
4752
+ import ora8 from "ora";
4753
+ import { writeFile as writeFile2 } from "fs/promises";
4754
+ var generateCommand = new Command15("generate").description("Generate speech from text").requiredOption("-t, --text <text>", "Text to convert to speech").requiredOption("-o, --output <path>", "Output file path").option("-v, --voice <voice>", "Voice name or ID (e.g., Kore, Rachel, alloy)").option("-p, --provider <provider>", "Provider: gemini, elevenlabs, openai").option("-m, --model <model>", "Model (provider-specific)").option("-s, --speed <speed>", "Speech speed 0.25-4.0 (default: 1.0)").option("-f, --format <format>", "Output format: human, json, quiet", "human").action(async (options) => {
4755
+ const format = options.format;
4756
+ const spinner = format === "human" ? ora8("Generating speech...").start() : null;
4757
+ let speed;
4758
+ if (options.speed) {
4759
+ speed = parseFloat(options.speed);
4760
+ if (isNaN(speed) || speed < 0.25 || speed > 4) {
4761
+ spinner?.stop();
4762
+ error("Speed must be between 0.25 and 4.0");
4763
+ process.exit(EXIT_CODES.INVALID_INPUT);
4764
+ }
4765
+ }
4766
+ try {
4767
+ const result = await generateSpeech({
4768
+ text: options.text,
4769
+ options: {
4770
+ provider: options.provider,
4771
+ voice: options.voice,
4772
+ model: options.model,
4773
+ speed
4774
+ }
4775
+ });
4776
+ spinner?.stop();
4777
+ const outputPath = options.output.endsWith(`.${result.format}`) ? options.output : `${options.output}.${result.format}`;
4778
+ await writeFile2(outputPath, result.audioData);
4779
+ if (format === "json") {
4780
+ printJson({
4781
+ status: "completed",
4782
+ output: outputPath,
4783
+ duration: result.duration,
4784
+ cost: result.cost,
4785
+ provider: result.provider,
4786
+ format: result.format
4787
+ });
4788
+ return;
4789
+ }
4790
+ if (format === "quiet") {
4791
+ console.log(outputPath);
4792
+ return;
4793
+ }
4794
+ success(`Saved to: ${outputPath}`);
4795
+ info(`Duration: ${result.duration.toFixed(2)}s`);
4796
+ info(`Provider: ${result.provider}`);
4797
+ info(`Cost: $${result.cost.toFixed(6)}`);
4798
+ } catch (err) {
4799
+ spinner?.stop();
4800
+ error(err instanceof Error ? err.message : "Unknown error");
4801
+ process.exit(EXIT_CODES.GENERAL_ERROR);
4802
+ }
4803
+ });
4804
+ var voicesCommand = new Command15("voices").description("List available voices").option("-p, --provider <provider>", "Filter by provider: gemini, elevenlabs, openai").option("-f, --format <format>", "Output format: human, json", "human").action(async (options) => {
4805
+ const spinner = options.format === "human" ? ora8("Fetching voices...").start() : null;
4806
+ try {
4807
+ const result = await getVoices();
4808
+ spinner?.stop();
4809
+ if (options.format === "json") {
4810
+ if (options.provider) {
4811
+ const providerVoices = result.voices[options.provider];
4812
+ printJson(providerVoices || []);
4813
+ } else {
4814
+ printJson(result.voices);
4815
+ }
4816
+ return;
4817
+ }
4818
+ const providers = options.provider ? [options.provider] : ["gemini", "elevenlabs", "openai"];
4819
+ for (const provider of providers) {
4820
+ const voices = result.voices[provider];
4821
+ if (!voices || voices.length === 0) continue;
4822
+ console.log();
4823
+ console.log(`${provider.toUpperCase()} Voices:`);
4824
+ console.log("-".repeat(50));
4825
+ for (const voice of voices) {
4826
+ console.log(` ${voice.name} (${voice.id})`);
4827
+ console.log(` ${voice.description}`);
4828
+ }
4829
+ }
4830
+ } catch (err) {
4831
+ spinner?.stop();
4832
+ error(err instanceof Error ? err.message : "Unknown error");
4833
+ process.exit(EXIT_CODES.GENERAL_ERROR);
4834
+ }
4835
+ });
4836
+ var ttsCommand = new Command15("tts").description("Text-to-speech commands").addCommand(generateCommand).addCommand(voicesCommand);
3528
4837
 
3529
4838
  // src/commands/music.ts
3530
4839
  init_api();
@@ -3860,8 +5169,118 @@ init_output();
3860
5169
  init_types();
3861
5170
  import { Command as Command19 } from "commander";
3862
5171
  import ora12 from "ora";
3863
- import { mkdir, writeFile as writeFile5, readFile as readFile2 } from "fs/promises";
3864
- import { join as join2 } from "path";
5172
+ import { mkdir, writeFile as writeFile5, readFile as readFile2, access, rm } from "fs/promises";
5173
+ import { join as join2, resolve as resolve5 } from "path";
5174
+ import { execSync, spawn } from "child_process";
5175
+ var DEFAULT_TEMPLATE = "inizio-inc/remotion-composition";
5176
+ var DEFAULT_FPS = 30;
5177
+ function parseScriptIntoSections(script) {
5178
+ if (script.includes("---") || script.includes("[Section")) {
5179
+ const parts = script.split(/---|\[Section \d+\]/i).filter((s) => s.trim());
5180
+ if (parts.length > 1) {
5181
+ return parts.map((p) => p.trim());
5182
+ }
5183
+ }
5184
+ const sentences = script.split(/(?<=[.!?])\s+/).map((s) => s.trim()).filter((s) => s.length > 0);
5185
+ const sections = [];
5186
+ let pendingShort = "";
5187
+ for (const sentence of sentences) {
5188
+ const wordCount = sentence.split(/\s+/).length;
5189
+ if (pendingShort) {
5190
+ sections.push(`${pendingShort} ${sentence}`);
5191
+ pendingShort = "";
5192
+ } else if (wordCount < 5 && sections.length < sentences.length - 1) {
5193
+ pendingShort = sentence;
5194
+ } else {
5195
+ sections.push(sentence);
5196
+ }
5197
+ }
5198
+ if (pendingShort) {
5199
+ if (sections.length > 0) {
5200
+ sections[sections.length - 1] += ` ${pendingShort}`;
5201
+ } else {
5202
+ sections.push(pendingShort);
5203
+ }
5204
+ }
5205
+ return sections;
5206
+ }
5207
+ function calculateSectionTiming(sections, totalDuration, fps = DEFAULT_FPS, timestamps) {
5208
+ if (timestamps && timestamps.characters.length > 0) {
5209
+ return calculateSectionTimingFromTimestamps(sections, timestamps, fps);
5210
+ }
5211
+ const totalWords = sections.reduce((sum, s) => sum + s.split(/\s+/).length, 0);
5212
+ let currentTime = 0;
5213
+ return sections.map((text, index) => {
5214
+ const wordCount = text.split(/\s+/).length;
5215
+ const proportion = wordCount / totalWords;
5216
+ const durationInSeconds = totalDuration * proportion;
5217
+ const durationInFrames = Math.round(durationInSeconds * fps);
5218
+ const section = {
5219
+ id: index + 1,
5220
+ text,
5221
+ wordCount,
5222
+ startTime: currentTime,
5223
+ endTime: currentTime + durationInSeconds,
5224
+ durationInSeconds,
5225
+ durationInFrames
5226
+ };
5227
+ currentTime += durationInSeconds;
5228
+ return section;
5229
+ });
5230
+ }
5231
+ function calculateSectionTimingFromTimestamps(sections, timestamps, fps) {
5232
+ const { characters, characterStartTimesSeconds, characterEndTimesSeconds } = timestamps;
5233
+ const fullText = characters.join("");
5234
+ const results = [];
5235
+ let charIndex = 0;
5236
+ for (let i = 0; i < sections.length; i++) {
5237
+ const sectionText = sections[i];
5238
+ const sectionLength = sectionText.length;
5239
+ while (charIndex < characters.length && characters[charIndex].match(/^\s*$/)) {
5240
+ charIndex++;
5241
+ }
5242
+ const startCharIndex = charIndex;
5243
+ const startTime = characterStartTimesSeconds[startCharIndex] || 0;
5244
+ charIndex += sectionLength;
5245
+ let endCharIndex = charIndex - 1;
5246
+ while (endCharIndex > startCharIndex && characters[endCharIndex]?.match(/^\s*$/)) {
5247
+ endCharIndex--;
5248
+ }
5249
+ const endTime = characterEndTimesSeconds[Math.min(endCharIndex, characterEndTimesSeconds.length - 1)] || startTime + 1;
5250
+ const durationInSeconds = endTime - startTime;
5251
+ const durationInFrames = Math.round(durationInSeconds * fps);
5252
+ results.push({
5253
+ id: i + 1,
5254
+ text: sectionText,
5255
+ wordCount: sectionText.split(/\s+/).length,
5256
+ startTime,
5257
+ endTime,
5258
+ durationInSeconds,
5259
+ durationInFrames
5260
+ });
5261
+ }
5262
+ return results;
5263
+ }
5264
+ async function readStdin2() {
5265
+ if (process.stdin.isTTY) {
5266
+ return null;
5267
+ }
5268
+ return new Promise((resolve6) => {
5269
+ let data = "";
5270
+ process.stdin.setEncoding("utf-8");
5271
+ process.stdin.on("data", (chunk) => {
5272
+ data += chunk;
5273
+ });
5274
+ process.stdin.on("end", () => resolve6(data.trim() || null));
5275
+ process.stdin.on("error", () => resolve6(null));
5276
+ setTimeout(() => {
5277
+ if (!data) resolve6(null);
5278
+ }, 100);
5279
+ });
5280
+ }
5281
+ function toFilename(name) {
5282
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
5283
+ }
3865
5284
  async function downloadFile3(url, outputPath) {
3866
5285
  if (url.startsWith("data:")) {
3867
5286
  const matches = url.match(/^data:[^;]+;base64,(.+)$/);
@@ -3891,61 +5310,199 @@ function getExtension(url) {
3891
5310
  }
3892
5311
  return "jpg";
3893
5312
  }
3894
- var createCommand2 = new Command19("create").description("Create video assets (voiceover, music, images)").option("-s, --script <text>", "Narration script text").option("--script-file <path>", "Path to script file").option("-t, --topic <text>", "Topic for image search (inferred from script if not provided)").option("-d, --duration <seconds>", "Target duration (auto-calculated from script if not set)").option("-v, --voice <name>", "TTS voice (Kore, Puck, Rachel, alloy)", "Kore").option("-m, --music-prompt <text>", "Music description (auto-generated if not provided)").option("-n, --num-images <number>", "Number of images to search/download", "5").option("-o, --output <dir>", "Output directory", "./public").option("-f, --format <format>", "Output format: human, json, quiet", "human").action(async (options) => {
5313
+ var createCommand3 = new Command19("create").description("Create video assets (voiceover per scene, music, images)").option("-s, --script <text>", "Narration script (legacy single-script mode)").option("--script-file <path>", "Path to script file (legacy) or scenes JSON").option("-t, --topic <text>", "Topic for image search").option("-v, --voice <name>", "TTS voice (Kore, Puck, Rachel, alloy)", "Kore").option("-m, --music-prompt <text>", "Music description").option("-n, --num-images <number>", "Number of images to search/download", "5").option("-o, --output <dir>", "Output directory", "./public").option("-f, --format <format>", "Output format: human, json, quiet", "human").action(async (options) => {
3895
5314
  const format = options.format;
3896
5315
  const spinner = format === "human" ? ora12("Initializing...").start() : null;
3897
5316
  try {
3898
- let script = options.script;
3899
- if (options.scriptFile) {
5317
+ const stdinData = await readStdin2();
5318
+ let scenesInput = null;
5319
+ if (stdinData) {
3900
5320
  try {
3901
- script = await readFile2(options.scriptFile, "utf-8");
3902
- } catch (err) {
3903
- spinner?.stop();
3904
- error(`Failed to read script file: ${err instanceof Error ? err.message : "Unknown error"}`);
3905
- process.exit(EXIT_CODES.INVALID_INPUT);
5321
+ const parsed = JSON.parse(stdinData);
5322
+ if (parsed.scenes && Array.isArray(parsed.scenes)) {
5323
+ scenesInput = parsed;
5324
+ }
5325
+ } catch {
3906
5326
  }
3907
5327
  }
3908
- if (!script || script.trim().length === 0) {
3909
- spinner?.stop();
3910
- error("Either --script or --script-file is required");
3911
- process.exit(EXIT_CODES.INVALID_INPUT);
3912
- }
3913
- script = script.trim();
3914
- const topic = options.topic || script.split(".")[0].slice(0, 50);
3915
- const numImages = parseInt(options.numImages, 10);
3916
- if (isNaN(numImages) || numImages < 1 || numImages > 20) {
3917
- spinner?.stop();
3918
- error("Number of images must be between 1 and 20");
3919
- process.exit(EXIT_CODES.INVALID_INPUT);
5328
+ if (!scenesInput && options.scriptFile) {
5329
+ try {
5330
+ const fileContent = await readFile2(options.scriptFile, "utf-8");
5331
+ const parsed = JSON.parse(fileContent);
5332
+ if (parsed.scenes && Array.isArray(parsed.scenes)) {
5333
+ scenesInput = parsed;
5334
+ }
5335
+ } catch {
5336
+ }
3920
5337
  }
5338
+ const voice = scenesInput?.voice || options.voice;
5339
+ const musicPrompt = scenesInput?.musicPrompt || options.musicPrompt || "uplifting background music, positive energy";
3921
5340
  const audioDir = join2(options.output, "audio");
3922
5341
  const imagesDir = join2(options.output, "images");
5342
+ const videosDir = join2(options.output, "videos");
3923
5343
  if (spinner) spinner.text = "Creating directories...";
3924
5344
  await mkdir(audioDir, { recursive: true });
3925
5345
  await mkdir(imagesDir, { recursive: true });
5346
+ await mkdir(videosDir, { recursive: true });
3926
5347
  let totalCost = 0;
3927
- if (spinner) spinner.text = "Generating voiceover...";
3928
- const ttsResult = await generateSpeech({
3929
- text: script,
3930
- options: { voice: options.voice }
3931
- });
3932
- const voiceoverPath = join2(audioDir, `voiceover.${ttsResult.format}`);
3933
- await writeFile5(voiceoverPath, ttsResult.audioData);
3934
- totalCost += ttsResult.cost;
3935
- const voiceoverInfo = {
3936
- path: `audio/voiceover.${ttsResult.format}`,
3937
- duration: ttsResult.duration,
3938
- voice: options.voice,
3939
- provider: ttsResult.provider,
3940
- cost: ttsResult.cost
3941
- };
3942
- if (format === "human") {
3943
- spinner?.stop();
3944
- success(`Voiceover: ${voiceoverPath} (${ttsResult.duration.toFixed(1)}s)`);
3945
- spinner?.start();
5348
+ let scenes = [];
5349
+ let totalDuration = 0;
5350
+ const allImages = [];
5351
+ const allVideos = [];
5352
+ if (scenesInput && scenesInput.scenes.length > 0) {
5353
+ if (format === "human") {
5354
+ spinner?.stop();
5355
+ info(`Processing ${scenesInput.scenes.length} scenes...`);
5356
+ spinner?.start();
5357
+ }
5358
+ let currentTime = 0;
5359
+ for (let i = 0; i < scenesInput.scenes.length; i++) {
5360
+ const scene = scenesInput.scenes[i];
5361
+ const filename = toFilename(scene.name);
5362
+ if (spinner) spinner.text = `[${scene.name}] Generating speech...`;
5363
+ const ttsResult = await generateSpeech({
5364
+ text: scene.script,
5365
+ options: { voice }
5366
+ });
5367
+ const audioPath = join2(audioDir, `${filename}.${ttsResult.format}`);
5368
+ await writeFile5(audioPath, ttsResult.audioData);
5369
+ totalCost += ttsResult.cost;
5370
+ const durationInSeconds = ttsResult.duration;
5371
+ const durationInFrames = Math.round(durationInSeconds * DEFAULT_FPS);
5372
+ const sceneData = {
5373
+ id: i + 1,
5374
+ name: scene.name,
5375
+ text: scene.script,
5376
+ wordCount: scene.script.split(/\s+/).length,
5377
+ startTime: currentTime,
5378
+ endTime: currentTime + durationInSeconds,
5379
+ durationInSeconds,
5380
+ durationInFrames,
5381
+ audioPath: `audio/${filename}.${ttsResult.format}`
5382
+ };
5383
+ if (scene.imageQuery) {
5384
+ if (spinner) spinner.text = `[${scene.name}] Searching image...`;
5385
+ try {
5386
+ const imageResults = await searchImages({
5387
+ query: scene.imageQuery,
5388
+ options: { maxResults: 1, size: "large", safeSearch: true }
5389
+ });
5390
+ const imgs = imageResults.data.results.flatMap((r) => r.results);
5391
+ totalCost += imageResults.data.totalCost;
5392
+ if (imgs.length > 0) {
5393
+ const img = imgs[0];
5394
+ const ext = getExtension(img.url);
5395
+ const imgFilename = `${filename}.${ext}`;
5396
+ const imgPath = join2(imagesDir, imgFilename);
5397
+ await downloadFile3(img.url, imgPath);
5398
+ sceneData.imagePath = `images/${imgFilename}`;
5399
+ allImages.push({
5400
+ path: `images/${imgFilename}`,
5401
+ url: img.url,
5402
+ width: img.width,
5403
+ height: img.height,
5404
+ query: scene.imageQuery
5405
+ });
5406
+ }
5407
+ } catch (err) {
5408
+ if (format === "human") {
5409
+ spinner?.stop();
5410
+ warn(`[${scene.name}] Image search failed: ${err instanceof Error ? err.message : "Unknown"}`);
5411
+ spinner?.start();
5412
+ }
5413
+ }
5414
+ }
5415
+ if (scene.videoQuery) {
5416
+ if (spinner) spinner.text = `[${scene.name}] Searching video...`;
5417
+ try {
5418
+ const videoResults = await searchVideos({
5419
+ query: scene.videoQuery,
5420
+ options: { maxResults: 1 }
5421
+ });
5422
+ const vids = videoResults.data.results.flatMap((r) => r.results);
5423
+ totalCost += videoResults.data.totalCost;
5424
+ if (vids.length > 0) {
5425
+ const vid = vids[0];
5426
+ const vidUrl = vid.previewUrl || vid.downloadUrl;
5427
+ if (vidUrl) {
5428
+ const vidFilename = `${filename}.mp4`;
5429
+ const vidPath = join2(videosDir, vidFilename);
5430
+ await downloadFile3(vidUrl, vidPath);
5431
+ sceneData.videoPath = `videos/${vidFilename}`;
5432
+ allVideos.push({
5433
+ path: `videos/${vidFilename}`,
5434
+ url: vidUrl,
5435
+ width: vid.width,
5436
+ height: vid.height,
5437
+ duration: vid.duration,
5438
+ query: scene.videoQuery
5439
+ });
5440
+ }
5441
+ }
5442
+ } catch (err) {
5443
+ if (format === "human") {
5444
+ spinner?.stop();
5445
+ warn(`[${scene.name}] Video search failed: ${err instanceof Error ? err.message : "Unknown"}`);
5446
+ spinner?.start();
5447
+ }
5448
+ }
5449
+ }
5450
+ scenes.push(sceneData);
5451
+ currentTime += durationInSeconds;
5452
+ totalDuration += durationInSeconds;
5453
+ if (format === "human") {
5454
+ spinner?.stop();
5455
+ const assets = [
5456
+ `audio: ${durationInSeconds.toFixed(1)}s`,
5457
+ sceneData.imagePath ? "image" : null,
5458
+ sceneData.videoPath ? "video" : null
5459
+ ].filter(Boolean).join(", ");
5460
+ success(` ${scene.name}: ${assets}`);
5461
+ spinner?.start();
5462
+ }
5463
+ }
5464
+ } else {
5465
+ let script = options.script;
5466
+ if (options.scriptFile) {
5467
+ try {
5468
+ script = await readFile2(options.scriptFile, "utf-8");
5469
+ } catch (err) {
5470
+ spinner?.stop();
5471
+ error(`Failed to read script file: ${err instanceof Error ? err.message : "Unknown error"}`);
5472
+ process.exit(EXIT_CODES.INVALID_INPUT);
5473
+ }
5474
+ }
5475
+ if (!script || script.trim().length === 0) {
5476
+ spinner?.stop();
5477
+ error("Provide scenes via stdin JSON, --script-file with scenes JSON, or --script for legacy mode");
5478
+ process.exit(EXIT_CODES.INVALID_INPUT);
5479
+ }
5480
+ script = script.trim();
5481
+ const _topic = options.topic || script.split(".")[0].slice(0, 50);
5482
+ void _topic;
5483
+ if (spinner) spinner.text = "Generating voiceover...";
5484
+ const ttsResult = await generateSpeech({
5485
+ text: script,
5486
+ options: { voice }
5487
+ });
5488
+ const voiceoverPath = join2(audioDir, `voiceover.${ttsResult.format}`);
5489
+ await writeFile5(voiceoverPath, ttsResult.audioData);
5490
+ totalCost += ttsResult.cost;
5491
+ totalDuration = ttsResult.duration;
5492
+ const sectionTexts = parseScriptIntoSections(script);
5493
+ const sectionsWithTiming = calculateSectionTiming(sectionTexts, ttsResult.duration, DEFAULT_FPS, ttsResult.timestamps);
5494
+ scenes = sectionsWithTiming.map((s, i) => ({
5495
+ ...s,
5496
+ name: `Section${i + 1}`,
5497
+ audioPath: `audio/voiceover.${ttsResult.format}`
5498
+ }));
5499
+ if (format === "human") {
5500
+ spinner?.stop();
5501
+ success(`Voiceover: ${voiceoverPath} (${ttsResult.duration.toFixed(1)}s)`);
5502
+ spinner?.start();
5503
+ }
3946
5504
  }
3947
- const musicDuration = Math.min(30, Math.ceil(ttsResult.duration) + 5);
3948
- const musicPrompt = options.musicPrompt || "uplifting background music, positive energy";
5505
+ const musicDuration = Math.min(30, Math.ceil(totalDuration) + 5);
3949
5506
  if (spinner) spinner.text = "Generating music...";
3950
5507
  let musicResult = await generateMusic({
3951
5508
  prompt: musicPrompt,
@@ -3980,58 +5537,15 @@ var createCommand2 = new Command19("create").description("Create video assets (v
3980
5537
  success(`Music: ${musicPath} (${musicInfo.duration}s)`);
3981
5538
  spinner?.start();
3982
5539
  }
3983
- if (spinner) spinner.text = "Searching for images...";
3984
- const imageResults = await searchImages({
3985
- query: topic,
3986
- options: {
3987
- maxResults: numImages,
3988
- size: "large",
3989
- safeSearch: true
3990
- }
3991
- });
3992
- const allImages = imageResults.data.results.flatMap(
3993
- (providerResult) => providerResult.results.map((img) => ({
3994
- ...img,
3995
- provider: providerResult.providerName
3996
- }))
3997
- );
3998
- totalCost += imageResults.data.totalCost;
3999
- const downloadedImages = [];
4000
- for (let i = 0; i < Math.min(allImages.length, numImages); i++) {
4001
- const img = allImages[i];
4002
- const ext = getExtension(img.url);
4003
- const filename = `scene-${i + 1}.${ext}`;
4004
- const imagePath = join2(imagesDir, filename);
4005
- if (spinner) spinner.text = `Downloading image ${i + 1}/${Math.min(allImages.length, numImages)}...`;
4006
- try {
4007
- await downloadFile3(img.url, imagePath);
4008
- downloadedImages.push({
4009
- path: `images/${filename}`,
4010
- url: img.url,
4011
- width: img.width,
4012
- height: img.height,
4013
- query: topic
4014
- });
4015
- } catch (err) {
4016
- if (format === "human") {
4017
- spinner?.stop();
4018
- warn(`Failed to download image ${i + 1}: ${err instanceof Error ? err.message : "Unknown error"}`);
4019
- spinner?.start();
4020
- }
4021
- }
4022
- }
4023
- if (format === "human") {
4024
- spinner?.stop();
4025
- success(`Images: Downloaded ${downloadedImages.length} images to ${imagesDir}`);
4026
- spinner?.start();
4027
- }
4028
5540
  if (spinner) spinner.text = "Writing manifest...";
5541
+ const totalDurationInFrames = Math.round(totalDuration * DEFAULT_FPS);
4029
5542
  const manifest = {
4030
- topic,
4031
- script,
4032
- voiceover: voiceoverInfo,
4033
5543
  music: musicInfo,
4034
- images: downloadedImages,
5544
+ images: allImages,
5545
+ videos: allVideos,
5546
+ scenes,
5547
+ totalDurationInFrames,
5548
+ fps: DEFAULT_FPS,
4035
5549
  totalCost,
4036
5550
  createdAt: (/* @__PURE__ */ new Date()).toISOString()
4037
5551
  };
@@ -4049,18 +5563,19 @@ var createCommand2 = new Command19("create").description("Create video assets (v
4049
5563
  console.log();
4050
5564
  success("Video assets created successfully!");
4051
5565
  console.log();
4052
- info(`Topic: ${topic}`);
4053
- info(`Voiceover: ${voiceoverInfo.path} (${voiceoverInfo.duration.toFixed(1)}s, ${voiceoverInfo.voice})`);
5566
+ info(`Scenes: ${scenes.length} (${totalDurationInFrames} frames at ${DEFAULT_FPS}fps)`);
5567
+ for (const scene of scenes) {
5568
+ const assets = [
5569
+ scene.audioPath ? "audio" : null,
5570
+ scene.imagePath ? "image" : null,
5571
+ scene.videoPath ? "video" : null
5572
+ ].filter(Boolean).join(", ");
5573
+ info(` - ${scene.name}: ${scene.durationInSeconds.toFixed(1)}s [${assets}]`);
5574
+ }
4054
5575
  info(`Music: ${musicInfo.path} (${musicInfo.duration}s)`);
4055
- info(`Images: ${downloadedImages.length} downloaded`);
4056
5576
  info(`Manifest: ${manifestPath}`);
4057
5577
  console.log();
4058
5578
  info(`Total cost: $${totalCost.toFixed(4)}`);
4059
- console.log();
4060
- info("Next steps:");
4061
- info(" 1. Create Remotion project (see remotion-best-practices skill)");
4062
- info(" 2. Use manifest data to configure video composition");
4063
- info(" 3. Run: npm start (preview) / npm run render (build)");
4064
5579
  } catch (err) {
4065
5580
  spinner?.stop();
4066
5581
  error(err instanceof Error ? err.message : "Unknown error");
@@ -4111,10 +5626,135 @@ var searchCommand2 = new Command19("search").description("Search for stock video
4111
5626
  process.exit(EXIT_CODES.GENERAL_ERROR);
4112
5627
  }
4113
5628
  });
4114
- var videoCommand = new Command19("video").description("Video asset generation commands").addCommand(createCommand2).addCommand(searchCommand2);
5629
+ var initCommand = new Command19("init").description("Create a new Remotion video project from template").argument("<name>", "Project directory name").option("-t, --template <repo>", "GitHub repo (user/repo)", DEFAULT_TEMPLATE).option("--type <type>", "Video type: landscape (16:9) or tiktok (9:16)", "landscape").option("--no-install", "Skip pnpm install").option("-f, --format <format>", "Output format: human, json, quiet", "human").action(async (name, options) => {
5630
+ const format = options.format;
5631
+ const spinner = format === "human" ? ora12("Initializing video project...").start() : null;
5632
+ try {
5633
+ const targetDir = resolve5(process.cwd(), name);
5634
+ try {
5635
+ await access(targetDir);
5636
+ spinner?.stop();
5637
+ error(`Directory "${name}" already exists`);
5638
+ process.exit(EXIT_CODES.INVALID_INPUT);
5639
+ } catch {
5640
+ }
5641
+ const templatePattern = /^[a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+(\/[a-zA-Z0-9_.-]+)*(#[a-zA-Z0-9_.-]+)?$/;
5642
+ if (!templatePattern.test(options.template)) {
5643
+ spinner?.stop();
5644
+ error(`Invalid template format: "${options.template}". Expected format: owner/repo or owner/repo#branch`);
5645
+ process.exit(EXIT_CODES.INVALID_INPUT);
5646
+ }
5647
+ if (spinner) spinner.text = `Downloading template from ${options.template}...`;
5648
+ try {
5649
+ execSync(`npx --yes degit ${options.template} "${targetDir}"`, {
5650
+ stdio: "pipe"
5651
+ });
5652
+ } catch {
5653
+ if (spinner) spinner.text = "Cloning template...";
5654
+ const repoMatch = options.template.match(/^([a-zA-Z0-9_-]+\/[a-zA-Z0-9_.-]+)/);
5655
+ const repo = repoMatch ? repoMatch[1] : options.template;
5656
+ execSync(`git clone --depth 1 https://github.com/${repo}.git "${targetDir}"`, {
5657
+ stdio: "pipe"
5658
+ });
5659
+ await rm(join2(targetDir, ".git"), { recursive: true, force: true });
5660
+ }
5661
+ if (format === "human") {
5662
+ spinner?.stop();
5663
+ success(`Template downloaded to ${name}/`);
5664
+ spinner?.start();
5665
+ }
5666
+ if (options.install) {
5667
+ if (spinner) spinner.text = "Installing dependencies...";
5668
+ await new Promise((resolvePromise, reject) => {
5669
+ const child = spawn("pnpm", ["install"], {
5670
+ cwd: targetDir,
5671
+ stdio: "pipe",
5672
+ shell: true
5673
+ });
5674
+ child.on("close", (code) => {
5675
+ if (code === 0) {
5676
+ resolvePromise();
5677
+ } else {
5678
+ reject(new Error(`pnpm install failed with code ${code}`));
5679
+ }
5680
+ });
5681
+ child.on("error", reject);
5682
+ });
5683
+ if (format === "human") {
5684
+ spinner?.stop();
5685
+ success("Dependencies installed");
5686
+ spinner?.start();
5687
+ }
5688
+ }
5689
+ if (options.type === "tiktok") {
5690
+ if (spinner) spinner.text = "Configuring for TikTok (9:16)...";
5691
+ const constantsPath = join2(targetDir, "types/constants.ts");
5692
+ try {
5693
+ let constantsContent = await readFile2(constantsPath, "utf-8");
5694
+ constantsContent = constantsContent.replace(
5695
+ /export const VIDEO_WIDTH = 1920;/,
5696
+ "export const VIDEO_WIDTH = 1080; // TikTok 9:16"
5697
+ ).replace(
5698
+ /export const VIDEO_HEIGHT = 1080;/,
5699
+ "export const VIDEO_HEIGHT = 1920; // TikTok 9:16"
5700
+ ).replace(
5701
+ /export const VIDEO_FPS = 60;/,
5702
+ "export const VIDEO_FPS = 30; // TikTok standard"
5703
+ );
5704
+ await writeFile5(constantsPath, constantsContent, "utf-8");
5705
+ if (format === "human") {
5706
+ spinner?.stop();
5707
+ success("Configured for TikTok (1080x1920 @ 30fps)");
5708
+ spinner?.start();
5709
+ }
5710
+ } catch {
5711
+ }
5712
+ }
5713
+ spinner?.stop();
5714
+ if (format === "json") {
5715
+ printJson({
5716
+ name,
5717
+ path: targetDir,
5718
+ template: options.template,
5719
+ type: options.type,
5720
+ installed: options.install
5721
+ });
5722
+ return;
5723
+ }
5724
+ if (format === "quiet") {
5725
+ console.log(targetDir);
5726
+ return;
5727
+ }
5728
+ console.log();
5729
+ success(`Video project "${name}" created successfully!`);
5730
+ if (options.type === "tiktok") {
5731
+ info("Format: TikTok/Reels/Shorts (1080x1920 @ 30fps)");
5732
+ } else {
5733
+ info("Format: Landscape (1920x1080 @ 60fps)");
5734
+ }
5735
+ console.log();
5736
+ info("Next steps:");
5737
+ info(` cd ${name}`);
5738
+ if (!options.install) {
5739
+ info(" pnpm install");
5740
+ }
5741
+ info(" pnpm dev # Preview in Remotion Studio");
5742
+ info(" cc video create ... # Generate assets to public/");
5743
+ if (options.type === "tiktok") {
5744
+ info(" pnpm exec remotion render TikTokVideo # Render TikTok video");
5745
+ } else {
5746
+ info(" pnpm exec remotion render FullVideo # Render final video");
5747
+ }
5748
+ } catch (err) {
5749
+ spinner?.stop();
5750
+ error(err instanceof Error ? err.message : "Unknown error");
5751
+ process.exit(EXIT_CODES.GENERAL_ERROR);
5752
+ }
5753
+ });
5754
+ var videoCommand = new Command19("video").description("Video asset generation commands").addCommand(initCommand).addCommand(createCommand3).addCommand(searchCommand2);
4115
5755
 
4116
5756
  // src/index.ts
4117
- var VERSION = "0.1.5";
5757
+ var VERSION = "0.1.7";
4118
5758
  var program = new Command20();
4119
5759
  var cmdName = brand.commands[0];
4120
5760
  program.name(cmdName).description(brand.description).version(VERSION, "-v, --version", "Show version number").option("--debug", "Enable debug logging").option("--no-color", "Disable colored output").configureOutput({