diff --git a/.github/ISSUE_TEMPLATE/gaming-features.md b/.github/ISSUE_TEMPLATE/gaming-features.md new file mode 100644 index 0000000..4e867b5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/gaming-features.md @@ -0,0 +1,39 @@ +--- +name: Gaming Feature +about: Template for gaming framework integration features +title: '[GAMING] ' +labels: enhancement, gaming +assignees: '' +--- + +## Overview +Brief description of the feature + +## Motivation +Why this feature is needed for game-style video generation + +## Technical Approach +High-level implementation strategy + +## API Design +```json +// Example template usage +``` + +## Implementation Checklist +- [ ] Core implementation +- [ ] TypeScript types +- [ ] Tests +- [ ] Documentation +- [ ] Example template +- [ ] AI capabilities API update + +## Dependencies +List of issues that must be completed first + +## Acceptance Criteria +- [ ] Criterion 1 +- [ ] Criterion 2 + +## Related Issues +Links to related issues diff --git a/.github/issues/README.md b/.github/issues/README.md new file mode 100644 index 0000000..0eace1b --- /dev/null +++ b/.github/issues/README.md @@ -0,0 +1,186 @@ +# Gaming Framework Integration Issues + +This directory contains detailed GitHub issues for integrating gaming frameworks (Three.js, PixiJS, physics engines) into Rendervid to enable game-style video generation. + +## Overview + +**Epic Issue**: [GAMING-000](gaming-000-epic.md) - Complete overview and roadmap + +## Issues by Phase + +### Phase 1: Physics Foundation (Weeks 1-2) + +| Issue | Title | Description | Dependencies | +|-------|-------|-------------|--------------| +| [GAMING-001](gaming-001-physics-package.md) | Physics Package Foundation | Create `@rendervid/physics` with Rapier3D | None | +| [GAMING-002](gaming-002-physics-threejs-integration.md) | Physics Three.js Integration | Add physics to Three.js layer | GAMING-001 | +| [GAMING-003](gaming-003-collision-events.md) | Collision Events & Callbacks | Trigger actions on collisions | GAMING-002 | + +**Deliverables**: Physics simulation, collision detection, 3 examples + +### Phase 2: Visual Effects (Weeks 3-4) + +| Issue | Title | Description | Dependencies | +|-------|-------|-------------|--------------| +| [GAMING-004](gaming-004-gpu-particle-system.md) | GPU Particle System | 10k+ particles with emitters & forces | GAMING-002 | +| [GAMING-005](gaming-005-post-processing.md) | Post-Processing Effects | Bloom, DOF, motion blur, glitch | GAMING-002 | + +**Deliverables**: Particle effects, cinematic post-processing, 5 examples + +### Phase 3: Animation & Scripting (Weeks 5-6) + +| Issue | Title | Description | Dependencies | +|-------|-------|-------------|--------------| +| [GAMING-006](gaming-006-keyframe-animation.md) | Keyframe Animation System | Advanced animations with easing | GAMING-002 | +| [GAMING-007](gaming-007-scripting-system.md) | Custom Scripting with VM | Safe JavaScript execution | GAMING-002, GAMING-003 | +| [GAMING-010](gaming-010-behavior-presets.md) | Behavior Preset Library | 15+ reusable behaviors | GAMING-002, GAMING-008 | + +**Deliverables**: Keyframe system, scripting, behaviors, 5 examples + +### Phase 4: 2D Gaming (Weeks 7-8) + +| Issue | Title | Description | Dependencies | +|-------|-------|-------------|--------------| +| [GAMING-008](gaming-008-pixijs-layer.md) | PixiJS 2D Layer | Sprites, tilemaps, filters | None | +| [GAMING-009](gaming-009-matter-physics.md) | Matter.js 2D Physics | 2D physics for PixiJS | GAMING-008 | + +**Deliverables**: 2D layer, 2D physics, 5 examples + +### Phase 5: AI Integration (Week 9) + +| Issue | Title | Description | Dependencies | +|-------|-------|-------------|--------------| +| [GAMING-011](gaming-011-ai-capabilities.md) | AI Capabilities & MCP | Expose features to AI agents | All previous | + +**Deliverables**: Capabilities API, MCP tools, AI guide, 20+ total examples + +## Quick Reference + +### By Feature + +**Physics** +- [GAMING-001](gaming-001-physics-package.md) - Core physics engine +- [GAMING-002](gaming-002-physics-threejs-integration.md) - 3D physics +- [GAMING-009](gaming-009-matter-physics.md) - 2D physics +- [GAMING-003](gaming-003-collision-events.md) - Collision events + +**Visual Effects** +- [GAMING-004](gaming-004-gpu-particle-system.md) - Particle systems +- [GAMING-005](gaming-005-post-processing.md) - Post-processing + +**Animation & Control** +- [GAMING-006](gaming-006-keyframe-animation.md) - Keyframe animations +- [GAMING-007](gaming-007-scripting-system.md) - Custom scripting +- [GAMING-010](gaming-010-behavior-presets.md) - Behavior presets + +**Rendering** +- [GAMING-002](gaming-002-physics-threejs-integration.md) - Three.js (3D) +- [GAMING-008](gaming-008-pixijs-layer.md) - PixiJS (2D) + +**AI Integration** +- [GAMING-011](gaming-011-ai-capabilities.md) - AI capabilities + +### By Package + +| Package | Issues | +|---------|--------| +| `@rendervid/physics` | GAMING-001, GAMING-002, GAMING-009 | +| `@rendervid/particles` | GAMING-004 | +| `@rendervid/scripting` | GAMING-007 | +| `@rendervid/behaviors` | GAMING-010 | +| `@rendervid/renderer-browser` | GAMING-002, GAMING-004, GAMING-005, GAMING-006, GAMING-008 | +| `@rendervid/core` | All (type definitions) | +| `@rendervid/mcp-server` | GAMING-011 | + +## Implementation Order + +**Critical Path** (must be done in order): +1. GAMING-001 → GAMING-002 → GAMING-003 +2. GAMING-004 (parallel with 3) +3. GAMING-005 (parallel with 4) +4. GAMING-006 (parallel with 7) +5. GAMING-007 +6. GAMING-008 → GAMING-009 +7. GAMING-010 (requires 2 and 8) +8. GAMING-011 (requires all) + +**Can be parallelized**: +- GAMING-004 and GAMING-005 (both extend Three.js) +- GAMING-006 and GAMING-007 (different systems) +- GAMING-008 and GAMING-009 (2D is independent of 3D) + +## Estimated Effort + +| Issue | Complexity | Estimated Days | +|-------|------------|----------------| +| GAMING-001 | High | 5 | +| GAMING-002 | Medium | 4 | +| GAMING-003 | Medium | 3 | +| GAMING-004 | High | 6 | +| GAMING-005 | Medium | 4 | +| GAMING-006 | Medium | 5 | +| GAMING-007 | High | 6 | +| GAMING-008 | High | 7 | +| GAMING-009 | Medium | 4 | +| GAMING-010 | Medium | 5 | +| GAMING-011 | Low | 3 | +| **Total** | | **52 days** (~9 weeks with 1 developer) | + +## Success Metrics + +### Performance Targets +- 1000+ 3D physics bodies at 60fps +- 10,000+ 3D particles at 60fps +- 100+ 2D physics bodies at 60fps +- <5ms post-processing overhead + +### Quality Targets +- >90% test coverage +- Zero security vulnerabilities +- No memory leaks +- Deterministic rendering + +### User Experience +- 30+ example templates +- Complete documentation +- AI agents can generate videos +- <10 lines JSON for simple effects + +## Getting Started + +1. Read the [Epic Issue](gaming-000-epic.md) for full context +2. Start with [GAMING-001](gaming-001-physics-package.md) (physics foundation) +3. Follow the critical path order +4. Each issue includes: + - Detailed technical approach + - Type definitions + - Implementation checklist + - API design examples + - Testing requirements + - Documentation requirements + +## Questions? + +- Check the epic issue for architecture overview +- Each issue has detailed implementation notes +- Dependencies are clearly marked +- All issues include acceptance criteria + +## Contributing + +When implementing an issue: +1. Create a feature branch: `feature/gaming-XXX-description` +2. Follow the implementation checklist +3. Write tests (>90% coverage) +4. Update documentation +5. Create example templates +6. Update capabilities API (if applicable) +7. Submit PR referencing the issue + +## Notes + +- All issues are designed to be AI-friendly +- JSON-first API design +- Modular architecture (can adopt incrementally) +- Backward compatible (existing templates still work) +- Security-first (sandboxed scripting) diff --git a/.github/issues/gaming-000-epic.md b/.github/issues/gaming-000-epic.md new file mode 100644 index 0000000..b260bf1 --- /dev/null +++ b/.github/issues/gaming-000-epic.md @@ -0,0 +1,308 @@ +# [GAMING-000] Gaming Framework Integration - Epic + +## Overview +Transform Rendervid into a game engine-powered video generation platform, enabling AI agents to create game-style marketing videos with physics, particles, behaviors, and 2D/3D graphics. + +## Vision +Enable creation of dynamic, game-like videos that go beyond static animations: +- Products falling and bouncing with realistic physics +- Explosions, fire, and particle effects +- Cinematic post-processing (bloom, depth of field, motion blur) +- 2D platformer and arcade-style scenes +- AI-controlled behaviors and procedural generation +- Custom scripting for complex game logic + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Rendervid Core │ +│ (Template Engine) │ +└─────────────────────────────────────────────────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ +┌───────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐ +│ @rendervid/ │ │ @rendervid/ │ │ @rendervid/ │ +│ physics │ │ particles │ │ behaviors │ +│ │ │ │ │ │ +│ • Rapier3D │ │ • GPU 3D │ │ • Presets │ +│ • Matter2D │ │ • PixiJS 2D │ │ • Composition │ +└────────────────┘ └─────────────┘ └─────────────────┘ + │ │ │ + └───────────────────┼───────────────────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ +┌───────▼────────┐ ┌──────▼──────┐ ┌────────▼────────┐ +│ Three.js │ │ PixiJS │ │ Scripting │ +│ Layer │ │ Layer │ │ VM │ +│ │ │ │ │ │ +│ • 3D Graphics │ │ • 2D Games │ │ • Safe Exec │ +│ • Post-FX │ │ • Sprites │ │ • Custom Logic │ +│ • Animations │ │ • Tilemaps │ │ • AI Control │ +└────────────────┘ └─────────────┘ └─────────────────┘ + │ + ┌───────▼────────┐ + │ MCP Server │ + │ │ + │ • AI Tools │ + │ • Capabilities │ + │ • Examples │ + └────────────────┘ +``` + +## Implementation Phases + +### Phase 1: Foundation (Weeks 1-2) +**Goal**: Physics and core infrastructure + +- [#GAMING-001](gaming-001-physics-package.md) - Create @rendervid/physics package +- [#GAMING-002](gaming-002-physics-threejs-integration.md) - Integrate physics into Three.js layer +- [#GAMING-003](gaming-003-collision-events.md) - Physics collision events and callbacks + +**Deliverables**: +- Physics simulation working in 3D +- Collision detection and events +- 3+ example templates (falling boxes, bouncing balls, dominos) + +### Phase 2: Visual Effects (Weeks 3-4) +**Goal**: Particles and post-processing + +- [#GAMING-004](gaming-004-gpu-particle-system.md) - GPU particle system for Three.js +- [#GAMING-005](gaming-005-post-processing.md) - Post-processing effects for Three.js + +**Deliverables**: +- 10k+ particle support +- 10+ post-processing effects +- 5+ example templates (explosions, fire, cinematic effects) + +### Phase 3: Animation & Scripting (Weeks 5-6) +**Goal**: Advanced control and behaviors + +- [#GAMING-006](gaming-006-keyframe-animation.md) - Advanced keyframe animation system +- [#GAMING-007](gaming-007-scripting-system.md) - Custom scripting with safe VM +- [#GAMING-010](gaming-010-behavior-presets.md) - Behavior preset library + +**Deliverables**: +- Keyframe animations for all properties +- Safe JavaScript execution +- 15+ behavior presets +- 5+ example templates (complex choreography, AI behaviors) + +### Phase 4: 2D Gaming (Weeks 7-8) +**Goal**: 2D game-style videos + +- [#GAMING-008](gaming-008-pixijs-layer.md) - PixiJS 2D layer integration +- [#GAMING-009](gaming-009-matter-physics.md) - Matter.js 2D physics + +**Deliverables**: +- PixiJS layer working +- 2D physics simulation +- Sprite animations and tilemaps +- 5+ example templates (platformer, arcade, retro) + +### Phase 5: AI Integration (Week 9) +**Goal**: AI-friendly API and documentation + +- [#GAMING-011](gaming-011-ai-capabilities.md) - AI capabilities API and MCP integration + +**Deliverables**: +- Complete capabilities API +- MCP server tools +- AI guide documentation +- 20+ total example templates + +## Success Metrics + +### Technical +- [ ] 1000+ physics bodies at 60fps (3D) +- [ ] 10,000+ particles at 60fps (3D) +- [ ] 100+ physics bodies at 60fps (2D) +- [ ] All features work in browser and Node.js +- [ ] <5ms overhead for post-processing +- [ ] Deterministic rendering (same input = same output) + +### User Experience +- [ ] AI agents can generate gaming videos without errors +- [ ] 30+ example templates covering all features +- [ ] Complete documentation for all features +- [ ] <10 lines of JSON for simple gaming effects + +### Quality +- [ ] >90% test coverage for all packages +- [ ] Zero security vulnerabilities in scripting VM +- [ ] No memory leaks in long renders +- [ ] All examples render successfully + +## Example Use Cases + +### 1. Product Launch Video +```json +{ + "physics": { "enabled": true }, + "meshes": [ + { + "id": "product", + "geometry": { "type": "gltf", "url": "product.glb" }, + "position": [0, 10, 0], + "rigidBody": { "type": "dynamic" }, + "collisionEvents": { + "onCollisionStart": [ + { "type": "spawnParticles", "particleId": "impact", "count": 500 } + ] + } + } + ], + "postProcessing": { + "bloom": { "intensity": 2 }, + "depthOfField": { "focusDistance": 0.5 } + } +} +``` + +### 2. Explosion Effect +```json +{ + "particles": [{ + "id": "explosion", + "count": 10000, + "emitter": { + "type": "sphere", + "burst": [{ "frame": 60, "count": 5000 }] + }, + "forces": [ + { "type": "gravity", "strength": 9.81 }, + { "type": "turbulence", "strength": 2 } + ] + }], + "postProcessing": { + "bloom": { "intensity": 3 }, + "motionBlur": { "samples": 16 } + } +} +``` + +### 3. 2D Platformer Scene +```json +{ + "type": "pixi", + "props": { + "physics": { "enabled": true, "gravity": { "y": 1 } }, + "sprites": [{ + "id": "player", + "texture": "character.png", + "animation": { + "animations": { + "walk": { "frames": [0, 1, 2, 3], "speed": 10 } + } + }, + "behaviors": [ + { "type": "patrol", "params": { "waypoints": [[100, 300], [700, 300]] } } + ] + }], + "filters": [ + { "type": "pixelate", "size": 4 }, + { "type": "crt" } + ] + } +} +``` + +## Dependencies + +### External +- `@dimforge/rapier3d-compat` - 3D physics +- `matter-js` - 2D physics +- `pixi.js` - 2D rendering +- `@react-three/postprocessing` - Post-processing effects +- `vm2` or `isolated-vm` - Safe script execution + +### Internal +- `@rendervid/core` - Template engine +- `@rendervid/renderer-browser` - Browser rendering +- `@rendervid/renderer-node` - Node.js rendering +- `@rendervid/components` - React components + +## Risks & Mitigations + +### Performance +**Risk**: Physics/particles slow down rendering +**Mitigation**: +- Implement LOD systems +- Add performance budgets +- Provide optimization guidelines + +### Security +**Risk**: Custom scripts could be malicious +**Mitigation**: +- Isolated VM execution +- Timeout limits +- Memory limits +- Whitelist API surface + +### Complexity +**Risk**: Too many features, hard to learn +**Mitigation**: +- Behavior presets for common patterns +- Progressive disclosure in docs +- AI-friendly capabilities API +- Extensive examples + +### Browser Compatibility +**Risk**: WebGL/WASM not available everywhere +**Mitigation**: +- Feature detection +- Graceful degradation +- Clear browser requirements + +## Timeline + +**Total Duration**: 9 weeks + +- Week 1-2: Physics foundation +- Week 3-4: Visual effects +- Week 5-6: Animation & scripting +- Week 7-8: 2D gaming +- Week 9: AI integration & polish + +## Team Requirements + +- 1-2 developers with Three.js/WebGL experience +- 1 developer with physics engine experience +- 1 developer for testing & documentation +- Design input for example templates + +## Success Criteria + +- [ ] All 11 issues completed +- [ ] All tests passing +- [ ] All examples rendering +- [ ] Documentation complete +- [ ] AI agents can generate gaming videos +- [ ] Performance targets met +- [ ] Zero critical security issues + +## Related Issues + +- #GAMING-001 - Physics package +- #GAMING-002 - Physics Three.js integration +- #GAMING-003 - Collision events +- #GAMING-004 - GPU particles +- #GAMING-005 - Post-processing +- #GAMING-006 - Keyframe animations +- #GAMING-007 - Scripting system +- #GAMING-008 - PixiJS layer +- #GAMING-009 - Matter.js physics +- #GAMING-010 - Behavior presets +- #GAMING-011 - AI capabilities + +## Notes + +This is a major feature addition that will: +- 3x the capabilities of Rendervid +- Enable entirely new use cases +- Position Rendervid as a game engine for video +- Make it the most powerful AI video generation tool + +The modular architecture ensures features can be adopted incrementally without breaking existing functionality. diff --git a/.github/issues/gaming-001-physics-package.md b/.github/issues/gaming-001-physics-package.md new file mode 100644 index 0000000..c982440 --- /dev/null +++ b/.github/issues/gaming-001-physics-package.md @@ -0,0 +1,201 @@ +# [GAMING-001] Create @rendervid/physics Package Foundation + +## Overview +Create a new package `@rendervid/physics` that provides a unified interface for physics engines (3D and 2D) with Rapier3D as the initial implementation. + +## Motivation +Enable dynamic physics simulations in video templates for game-like effects: falling objects, collisions, explosions, ragdolls, and destruction. Physics is foundational for realistic game-style videos. + +## Technical Approach + +### Package Structure +``` +packages/physics/ +├── src/ +│ ├── index.ts +│ ├── types.ts +│ ├── engines/ +│ │ ├── rapier3d/ +│ │ │ ├── RapierPhysicsEngine.ts +│ │ │ ├── RigidBody.ts +│ │ │ ├── Collider.ts +│ │ │ └── World.ts +│ │ └── matter2d/ # Future: 2D physics +│ ├── utils/ +│ │ ├── conversion.ts # Three.js <-> Rapier conversions +│ │ └── debug.ts # Debug visualization +│ └── __tests__/ +├── package.json +└── tsconfig.json +``` + +### Core Interfaces + +```typescript +// types.ts +export interface PhysicsEngine { + init(): Promise; + step(deltaTime: number): void; + createRigidBody(config: RigidBodyConfig): RigidBody; + createCollider(config: ColliderConfig): Collider; + raycast(origin: Vector3, direction: Vector3, maxDistance: number): RaycastHit | null; + destroy(): void; +} + +export interface RigidBodyConfig { + type: 'dynamic' | 'static' | 'kinematic'; + position?: [number, number, number]; + rotation?: [number, number, number, number]; // quaternion + mass?: number; + linearVelocity?: [number, number, number]; + angularVelocity?: [number, number, number]; + linearDamping?: number; + angularDamping?: number; + gravityScale?: number; + canSleep?: boolean; + ccd?: boolean; // Continuous collision detection +} + +export interface ColliderConfig { + type: 'cuboid' | 'sphere' | 'capsule' | 'cylinder' | 'cone' | 'trimesh' | 'heightfield'; + // Type-specific properties + halfExtents?: [number, number, number]; // cuboid + radius?: number; // sphere, capsule, cylinder + halfHeight?: number; // capsule, cylinder + vertices?: number[]; // trimesh + indices?: number[]; // trimesh + // Physics properties + friction?: number; + restitution?: number; + density?: number; + isSensor?: boolean; + collisionGroups?: number; + solverGroups?: number; +} + +export interface RigidBody { + id: string; + setPosition(position: [number, number, number]): void; + setRotation(rotation: [number, number, number, number]): void; + setLinearVelocity(velocity: [number, number, number]): void; + setAngularVelocity(velocity: [number, number, number]): void; + applyImpulse(impulse: [number, number, number], point?: [number, number, number]): void; + applyForce(force: [number, number, number], point?: [number, number, number]): void; + applyTorque(torque: [number, number, number]): void; + getPosition(): [number, number, number]; + getRotation(): [number, number, number, number]; + getLinearVelocity(): [number, number, number]; + getAngularVelocity(): [number, number, number]; + setEnabled(enabled: boolean): void; + destroy(): void; +} + +export interface Collider { + id: string; + setFriction(friction: number): void; + setRestitution(restitution: number): void; + setEnabled(enabled: boolean): void; + destroy(): void; +} + +export interface RaycastHit { + point: [number, number, number]; + normal: [number, number, number]; + distance: number; + rigidBody: RigidBody; +} + +export interface PhysicsWorldConfig { + gravity: [number, number, number]; + timestep?: number; // Fixed timestep (default: 1/60) + maxSubsteps?: number; // Max physics steps per frame +} +``` + +## Implementation Checklist + +### Phase 1: Core Package Setup +- [ ] Create `packages/physics` directory +- [ ] Setup package.json with dependencies: + - `@dimforge/rapier3d-compat` (^0.11.0) + - `@rendervid/core` (workspace:*) +- [ ] Setup TypeScript configuration +- [ ] Create core type definitions + +### Phase 2: Rapier3D Integration +- [ ] Implement `RapierPhysicsEngine` class +- [ ] Implement `RapierRigidBody` wrapper +- [ ] Implement `RapierCollider` wrapper +- [ ] Add coordinate system conversion utilities (Three.js uses Y-up, Rapier uses Y-up but different handedness) +- [ ] Implement raycast functionality +- [ ] Add collision event system + +### Phase 3: Testing +- [ ] Unit tests for RigidBody creation and manipulation +- [ ] Unit tests for Collider creation +- [ ] Integration tests for physics simulation +- [ ] Test collision detection +- [ ] Test raycast functionality +- [ ] Performance benchmarks (1000+ bodies) + +### Phase 4: Documentation +- [ ] API documentation (JSDoc) +- [ ] Usage guide in `/docs/physics/` +- [ ] Physics concepts primer (rigid bodies, colliders, forces) +- [ ] Performance optimization guide + +## API Design + +```typescript +// Usage example +import { createPhysicsEngine } from '@rendervid/physics'; + +const physics = createPhysicsEngine('rapier3d', { + gravity: [0, -9.81, 0], + timestep: 1/60 +}); + +await physics.init(); + +// Create a dynamic box +const body = physics.createRigidBody({ + type: 'dynamic', + position: [0, 5, 0], + mass: 1 +}); + +const collider = physics.createCollider({ + type: 'cuboid', + halfExtents: [0.5, 0.5, 0.5], + friction: 0.5, + restitution: 0.8 +}); + +body.attachCollider(collider); + +// Simulate +physics.step(1/60); + +// Get updated position +const position = body.getPosition(); +``` + +## Dependencies +None (foundational package) + +## Acceptance Criteria +- [ ] Package builds without errors +- [ ] All tests pass with >90% coverage +- [ ] Can create and simulate 1000+ rigid bodies at 60fps +- [ ] Collision detection works accurately +- [ ] Memory usage is stable (no leaks) +- [ ] Documentation is complete and clear + +## Related Issues +- #GAMING-002 (depends on this) +- #GAMING-003 (depends on this) + +## Notes +- Use `@dimforge/rapier3d-compat` for better browser/Node.js compatibility +- Consider lazy-loading Rapier WASM module to reduce bundle size +- Plan for future 2D physics engine (Matter.js) with same interface diff --git a/.github/issues/gaming-002-physics-threejs-integration.md b/.github/issues/gaming-002-physics-threejs-integration.md new file mode 100644 index 0000000..cf64a73 --- /dev/null +++ b/.github/issues/gaming-002-physics-threejs-integration.md @@ -0,0 +1,273 @@ +# [GAMING-002] Integrate Physics into Three.js Layer + +## Overview +Extend the existing `ThreeLayer` to support physics simulation using the `@rendervid/physics` package. Add rigid body and collider configuration to mesh definitions. + +## Motivation +Enable physics-based animations in Three.js scenes: objects falling, bouncing, colliding, and reacting to forces. This transforms static 3D scenes into dynamic game-like environments. + +## Technical Approach + +### Type Extensions + +```typescript +// packages/core/src/types/three.ts + +export interface ThreeMeshConfig { + // ... existing properties + + /** Physics rigid body configuration */ + rigidBody?: { + type: 'dynamic' | 'static' | 'kinematic'; + mass?: number; + linearVelocity?: [number, number, number]; + angularVelocity?: [number, number, number]; + linearDamping?: number; + angularDamping?: number; + gravityScale?: number; + canSleep?: boolean; + ccd?: boolean; + }; + + /** Physics collider configuration */ + collider?: { + type: 'cuboid' | 'sphere' | 'capsule' | 'cylinder' | 'cone' | 'trimesh'; + // Auto-generated from geometry if not specified + halfExtents?: [number, number, number]; + radius?: number; + halfHeight?: number; + friction?: number; + restitution?: number; + density?: number; + isSensor?: boolean; + }; +} + +export interface ThreeLayerProps { + // ... existing properties + + /** Physics world configuration */ + physics?: { + enabled: boolean; + gravity?: [number, number, number]; + timestep?: number; + debug?: boolean; // Show collision shapes + }; +} +``` + +### Implementation in Renderer + +```typescript +// packages/renderer-browser/src/layers/three/PhysicsWorld.tsx + +import { useEffect, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import { createPhysicsEngine } from '@rendervid/physics'; +import type { ThreeLayerProps } from '@rendervid/core'; + +export function PhysicsWorld({ + config, + children +}: { + config: ThreeLayerProps['physics']; + children: React.ReactNode; +}) { + const physicsRef = useRef(null); + + useEffect(() => { + if (!config?.enabled) return; + + const physics = createPhysicsEngine('rapier3d', { + gravity: config.gravity || [0, -9.81, 0], + timestep: config.timestep || 1/60 + }); + + physics.init().then(() => { + physicsRef.current = physics; + }); + + return () => { + physics.destroy(); + }; + }, [config]); + + useFrame((state, delta) => { + if (physicsRef.current) { + physicsRef.current.step(delta); + } + }); + + return <>{children}; +} +``` + +```typescript +// packages/renderer-browser/src/layers/three/PhysicsMesh.tsx + +import { useEffect, useRef } from 'react'; +import { useFrame } from '@react-three/fiber'; +import type { Mesh } from 'three'; +import type { RigidBody } from '@rendervid/physics'; +import { usePhysics } from './PhysicsContext'; + +export function PhysicsMesh({ + mesh, + rigidBodyConfig, + colliderConfig, + children +}: PhysicsMeshProps) { + const meshRef = useRef(null); + const rigidBodyRef = useRef(null); + const physics = usePhysics(); + + useEffect(() => { + if (!physics || !meshRef.current) return; + + // Create rigid body + const body = physics.createRigidBody({ + type: rigidBodyConfig.type, + position: mesh.position.toArray(), + rotation: mesh.quaternion.toArray(), + ...rigidBodyConfig + }); + + // Create collider (auto-generate from geometry if not specified) + const collider = physics.createCollider({ + type: colliderConfig?.type || inferColliderType(mesh.geometry), + ...generateColliderParams(mesh.geometry, colliderConfig), + ...colliderConfig + }); + + body.attachCollider(collider); + rigidBodyRef.current = body; + + return () => { + body.destroy(); + }; + }, [physics, rigidBodyConfig, colliderConfig]); + + // Sync Three.js mesh with physics body + useFrame(() => { + if (rigidBodyRef.current && meshRef.current) { + const position = rigidBodyRef.current.getPosition(); + const rotation = rigidBodyRef.current.getRotation(); + + meshRef.current.position.set(...position); + meshRef.current.quaternion.set(...rotation); + } + }); + + return ( + + {children} + + ); +} +``` + +## Implementation Checklist + +### Phase 1: Type Definitions +- [ ] Add `rigidBody` and `collider` to `ThreeMeshConfig` +- [ ] Add `physics` to `ThreeLayerProps` +- [ ] Update schema validation in `@rendervid/core` + +### Phase 2: Physics Integration +- [ ] Create `PhysicsContext` for sharing physics engine +- [ ] Implement `PhysicsWorld` component +- [ ] Implement `PhysicsMesh` component +- [ ] Add collider auto-generation from geometry +- [ ] Implement position/rotation sync (physics → Three.js) +- [ ] Add debug visualization (wireframe colliders) + +### Phase 3: Advanced Features +- [ ] Collision event callbacks +- [ ] Apply forces/impulses via animations +- [ ] Joints and constraints support +- [ ] Compound colliders (multiple colliders per body) +- [ ] Collision filtering (groups/layers) + +### Phase 4: Testing +- [ ] Unit tests for type validation +- [ ] Integration tests with physics simulation +- [ ] Test all collider types +- [ ] Test collision detection +- [ ] Performance tests (100+ physics bodies) + +### Phase 5: Documentation & Examples +- [ ] Update Three.js layer documentation +- [ ] Create physics guide in `/docs/physics/` +- [ ] Example: Falling boxes +- [ ] Example: Bouncing balls +- [ ] Example: Domino effect +- [ ] Example: Product drop showcase + +## API Design + +```json +{ + "type": "three", + "props": { + "physics": { + "enabled": true, + "gravity": [0, -9.81, 0], + "debug": false + }, + "meshes": [ + { + "id": "dynamic-box", + "geometry": { "type": "box", "width": 1, "height": 1, "depth": 1 }, + "material": { "type": "standard", "color": "#ff0000" }, + "position": [0, 5, 0], + "rigidBody": { + "type": "dynamic", + "mass": 1, + "restitution": 0.8, + "friction": 0.5 + }, + "collider": { + "type": "cuboid" + } + }, + { + "id": "ground", + "geometry": { "type": "plane", "width": 20, "height": 20 }, + "material": { "type": "standard", "color": "#808080" }, + "position": [0, 0, 0], + "rotation": [-1.5708, 0, 0], + "rigidBody": { + "type": "static" + }, + "collider": { + "type": "cuboid" + } + } + ] + } +} +``` + +## Dependencies +- #GAMING-001 (must be completed first) + +## Acceptance Criteria +- [ ] Physics simulation works in browser renderer +- [ ] Physics simulation works in Node.js renderer +- [ ] All collider types supported +- [ ] Position/rotation sync is accurate +- [ ] No performance degradation with 100+ bodies +- [ ] Debug visualization works +- [ ] All tests pass +- [ ] Documentation is complete +- [ ] At least 3 example templates created + +## Related Issues +- #GAMING-001 (dependency) +- #GAMING-003 (collision events) +- #GAMING-007 (scripting integration) + +## Notes +- Consider using `useImperativeHandle` for external control of physics bodies +- Implement object pooling for better performance +- Add option to disable physics after N frames (for static final state) diff --git a/.github/issues/gaming-003-collision-events.md b/.github/issues/gaming-003-collision-events.md new file mode 100644 index 0000000..c68cdfc --- /dev/null +++ b/.github/issues/gaming-003-collision-events.md @@ -0,0 +1,309 @@ +# [GAMING-003] Physics Collision Events and Callbacks + +## Overview +Add collision event system to physics-enabled meshes, allowing templates to trigger actions (spawn particles, play sounds, change materials) when objects collide. + +## Motivation +Collision events are essential for game-like interactions: explosions on impact, sound effects, visual feedback, and triggering animations. This makes physics simulations feel responsive and alive. + +## Technical Approach + +### Type Extensions + +```typescript +// packages/core/src/types/three.ts + +export interface CollisionEvent { + type: 'collisionStart' | 'collisionEnd'; + otherMesh: string; // ID of the other mesh + impulse: number; // Collision impulse magnitude + point: [number, number, number]; // Contact point + normal: [number, number, number]; // Contact normal +} + +export interface ThreeMeshConfig { + // ... existing properties + + /** Collision event handlers */ + collisionEvents?: { + /** Triggered when collision starts */ + onCollisionStart?: CollisionAction[]; + /** Triggered when collision ends */ + onCollisionEnd?: CollisionAction[]; + /** Minimum impulse to trigger events (filters small collisions) */ + impulseThreshold?: number; + }; +} + +export type CollisionAction = + | { type: 'spawnParticles'; particleId: string; count?: number } + | { type: 'playSound'; soundId: string; volume?: number } + | { type: 'changeMaterial'; material: ThreeMaterialConfig } + | { type: 'applyForce'; force: [number, number, number] } + | { type: 'destroy'; delay?: number } + | { type: 'script'; code: string }; +``` + +### Implementation + +```typescript +// packages/physics/src/CollisionEventSystem.ts + +export class CollisionEventSystem { + private listeners = new Map(); + + addListener(bodyId: string, listener: CollisionListener) { + if (!this.listeners.has(bodyId)) { + this.listeners.set(bodyId, []); + } + this.listeners.get(bodyId)!.push(listener); + } + + removeListener(bodyId: string, listener: CollisionListener) { + const listeners = this.listeners.get(bodyId); + if (listeners) { + const index = listeners.indexOf(listener); + if (index > -1) listeners.splice(index, 1); + } + } + + handleCollision(event: CollisionEvent) { + const listeners = this.listeners.get(event.bodyId); + if (!listeners) return; + + for (const listener of listeners) { + if (event.impulse >= listener.impulseThreshold) { + listener.callback(event); + } + } + } +} + +interface CollisionListener { + impulseThreshold: number; + callback: (event: CollisionEvent) => void; +} +``` + +```typescript +// packages/renderer-browser/src/layers/three/CollisionHandler.tsx + +export function useCollisionEvents( + meshId: string, + rigidBody: RigidBody | null, + config: ThreeMeshConfig['collisionEvents'], + actions: CollisionActionHandlers +) { + const physics = usePhysics(); + + useEffect(() => { + if (!physics || !rigidBody || !config) return; + + const handleCollisionStart = (event: CollisionEvent) => { + config.onCollisionStart?.forEach(action => { + executeAction(action, event, actions); + }); + }; + + const handleCollisionEnd = (event: CollisionEvent) => { + config.onCollisionEnd?.forEach(action => { + executeAction(action, event, actions); + }); + }; + + physics.collisionEvents.addListener(rigidBody.id, { + impulseThreshold: config.impulseThreshold || 0, + callback: handleCollisionStart + }); + + return () => { + physics.collisionEvents.removeListener(rigidBody.id, handleCollisionStart); + }; + }, [physics, rigidBody, config]); +} + +function executeAction( + action: CollisionAction, + event: CollisionEvent, + handlers: CollisionActionHandlers +) { + switch (action.type) { + case 'spawnParticles': + handlers.spawnParticles(action.particleId, event.point, action.count); + break; + case 'playSound': + handlers.playSound(action.soundId, action.volume); + break; + case 'changeMaterial': + handlers.changeMaterial(event.bodyId, action.material); + break; + case 'applyForce': + handlers.applyForce(event.bodyId, action.force); + break; + case 'destroy': + handlers.destroy(event.bodyId, action.delay); + break; + case 'script': + handlers.executeScript(action.code, event); + break; + } +} +``` + +## Implementation Checklist + +### Phase 1: Core Event System +- [ ] Implement `CollisionEventSystem` in `@rendervid/physics` +- [ ] Add collision detection to Rapier integration +- [ ] Calculate impulse magnitude from collision data +- [ ] Add event filtering (impulse threshold) + +### Phase 2: Type Definitions +- [ ] Add `collisionEvents` to `ThreeMeshConfig` +- [ ] Define `CollisionAction` types +- [ ] Update schema validation + +### Phase 3: Action Handlers +- [ ] Implement `spawnParticles` action +- [ ] Implement `playSound` action (requires audio system) +- [ ] Implement `changeMaterial` action +- [ ] Implement `applyForce` action +- [ ] Implement `destroy` action +- [ ] Implement `script` action (requires scripting system) + +### Phase 4: React Integration +- [ ] Create `useCollisionEvents` hook +- [ ] Integrate with `PhysicsMesh` component +- [ ] Add collision visualization (debug mode) + +### Phase 5: Testing +- [ ] Unit tests for event system +- [ ] Integration tests for each action type +- [ ] Test impulse threshold filtering +- [ ] Test multiple simultaneous collisions +- [ ] Performance tests (many collision events) + +### Phase 6: Documentation & Examples +- [ ] Document collision event system +- [ ] Example: Ball bouncing with sound +- [ ] Example: Box breaking on impact +- [ ] Example: Particle explosion on collision +- [ ] Example: Chain reaction (dominos) + +## API Design + +```json +{ + "type": "three", + "props": { + "physics": { "enabled": true }, + "particles": [ + { + "id": "impact-particles", + "count": 1000, + "particle": { + "lifetime": 1, + "size": 0.1, + "color": "#ff6b6b" + } + } + ], + "meshes": [ + { + "id": "ball", + "geometry": { "type": "sphere", "radius": 0.5 }, + "position": [0, 5, 0], + "rigidBody": { + "type": "dynamic", + "mass": 1, + "restitution": 0.8 + }, + "collider": { "type": "sphere" }, + "collisionEvents": { + "impulseThreshold": 5, + "onCollisionStart": [ + { + "type": "spawnParticles", + "particleId": "impact-particles", + "count": 50 + }, + { + "type": "playSound", + "soundId": "bounce", + "volume": 0.8 + } + ] + } + }, + { + "id": "ground", + "geometry": { "type": "plane", "width": 20, "height": 20 }, + "position": [0, 0, 0], + "rotation": [-1.5708, 0, 0], + "rigidBody": { "type": "static" }, + "collider": { "type": "cuboid" } + } + ] + } +} +``` + +### Advanced Example: Destructible Object + +```json +{ + "meshes": [ + { + "id": "glass-box", + "geometry": { "type": "box" }, + "material": { "type": "physical", "transmission": 0.9 }, + "position": [0, 2, 0], + "rigidBody": { "type": "dynamic", "mass": 0.5 }, + "collider": { "type": "cuboid" }, + "collisionEvents": { + "impulseThreshold": 10, + "onCollisionStart": [ + { + "type": "spawnParticles", + "particleId": "glass-shards", + "count": 200 + }, + { + "type": "playSound", + "soundId": "glass-break" + }, + { + "type": "destroy", + "delay": 0 + } + ] + } + } + ] +} +``` + +## Dependencies +- #GAMING-002 (must be completed first) +- #GAMING-004 (for particle spawning) +- #GAMING-007 (for script actions) + +## Acceptance Criteria +- [ ] Collision events fire reliably +- [ ] Impulse threshold filtering works +- [ ] All action types implemented +- [ ] No performance impact with many collisions +- [ ] Events work in both browser and Node.js +- [ ] All tests pass +- [ ] Documentation complete +- [ ] At least 3 example templates + +## Related Issues +- #GAMING-002 (dependency) +- #GAMING-004 (particle system) +- #GAMING-007 (scripting) + +## Notes +- Consider debouncing rapid collision events +- Add option to limit events per second per object +- Collision events should be deterministic for reproducible renders diff --git a/.github/issues/gaming-004-gpu-particle-system.md b/.github/issues/gaming-004-gpu-particle-system.md new file mode 100644 index 0000000..22e8480 --- /dev/null +++ b/.github/issues/gaming-004-gpu-particle-system.md @@ -0,0 +1,451 @@ +# [GAMING-004] GPU Particle System for Three.js + +## Overview +Create a high-performance GPU-accelerated particle system for Three.js layer, supporting 10,000+ particles with emitters, forces, and collision. + +## Motivation +Particles are essential for game effects: explosions, fire, smoke, magic spells, trails, weather effects. GPU acceleration enables massive particle counts without performance issues. + +## Technical Approach + +### Architecture + +Use instanced rendering with custom shaders for maximum performance: +- Single draw call for all particles +- GPU-side position/velocity updates +- Texture atlases for variety +- Compute shaders for physics (WebGPU future) + +### Type Definitions + +```typescript +// packages/core/src/types/three.ts + +export interface ParticleSystemConfig { + id: string; + + /** Maximum particle count */ + count: number; + + /** Emitter configuration */ + emitter: ParticleEmitter; + + /** Particle properties */ + particle: ParticleProperties; + + /** Forces affecting particles */ + forces?: ParticleForce[]; + + /** Collision with scene geometry */ + collision?: { + enabled: boolean; + bounce?: number; // Restitution + friction?: number; + }; + + /** Rendering options */ + rendering?: { + blending?: 'normal' | 'additive' | 'multiply'; + depthWrite?: boolean; + texture?: string; // URL or data URI + textureAtlas?: { + columns: number; + rows: number; + randomFrame?: boolean; + }; + }; +} + +export type ParticleEmitter = + | PointEmitter + | SphereEmitter + | BoxEmitter + | ConeEmitter + | MeshEmitter; + +interface PointEmitter { + type: 'point'; + position: [number, number, number]; + rate?: number; // Particles per second + burst?: ParticleBurst[]; +} + +interface SphereEmitter { + type: 'sphere'; + position: [number, number, number]; + radius: number; + emitFromShell?: boolean; // Only from surface + rate?: number; + burst?: ParticleBurst[]; +} + +interface BoxEmitter { + type: 'box'; + position: [number, number, number]; + size: [number, number, number]; + rate?: number; + burst?: ParticleBurst[]; +} + +interface ConeEmitter { + type: 'cone'; + position: [number, number, number]; + direction: [number, number, number]; + angle: number; // Cone angle in radians + radius: number; + rate?: number; + burst?: ParticleBurst[]; +} + +interface MeshEmitter { + type: 'mesh'; + meshId: string; // Reference to existing mesh + emitFromVertices?: boolean; + emitFromFaces?: boolean; + rate?: number; + burst?: ParticleBurst[]; +} + +interface ParticleBurst { + frame: number; // When to burst + count: number; // How many particles +} + +export interface ParticleProperties { + /** Particle lifetime in seconds */ + lifetime: number | { min: number; max: number }; + + /** Initial velocity */ + velocity?: { + min: [number, number, number]; + max: [number, number, number]; + }; + + /** Size over lifetime */ + size: number | { start: number; end: number } | ParticleCurve; + + /** Color over lifetime */ + color: string | { start: string; end: string } | ParticleCurve; + + /** Opacity over lifetime */ + opacity?: number | { start: number; end: number } | ParticleCurve; + + /** Rotation speed (radians per second) */ + rotation?: number | { min: number; max: number }; + + /** Drag coefficient */ + drag?: number; +} + +interface ParticleCurve { + curve: 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'; + keyframes: Array<{ time: number; value: number | string }>; +} + +export type ParticleForce = + | { type: 'gravity'; strength: number } + | { type: 'wind'; direction: [number, number, number]; strength: number } + | { type: 'turbulence'; strength: number; frequency: number } + | { type: 'attraction'; position: [number, number, number]; strength: number; radius: number } + | { type: 'vortex'; position: [number, number, number]; axis: [number, number, number]; strength: number }; +``` + +### Shader Implementation + +```glsl +// Vertex Shader +attribute vec3 particlePosition; +attribute vec3 particleVelocity; +attribute float particleAge; +attribute float particleLifetime; +attribute float particleSize; +attribute vec4 particleColor; +attribute float particleRotation; + +uniform float time; +uniform mat4 modelViewMatrix; +uniform mat4 projectionMatrix; + +varying vec4 vColor; +varying vec2 vUv; +varying float vAge; + +void main() { + vAge = particleAge / particleLifetime; + vColor = particleColor; + vUv = uv; + + // Billboard rotation + vec3 pos = particlePosition; + vec4 mvPosition = modelViewMatrix * vec4(pos, 1.0); + + // Apply rotation + float c = cos(particleRotation); + float s = sin(particleRotation); + vec2 rotatedPosition = vec2( + position.x * c - position.y * s, + position.x * s + position.y * c + ); + + mvPosition.xy += rotatedPosition * particleSize; + + gl_Position = projectionMatrix * mvPosition; +} +``` + +```glsl +// Fragment Shader +uniform sampler2D particleTexture; +uniform bool useTexture; + +varying vec4 vColor; +varying vec2 vUv; +varying float vAge; + +void main() { + vec4 texColor = useTexture ? texture2D(particleTexture, vUv) : vec4(1.0); + + // Fade out at end of life + float alpha = vColor.a * texColor.a * (1.0 - vAge); + + gl_FragColor = vec4(vColor.rgb * texColor.rgb, alpha); + + if (gl_FragColor.a < 0.01) discard; +} +``` + +### CPU Update Loop + +```typescript +// packages/renderer-browser/src/layers/three/particles/ParticleSystem.ts + +export class ParticleSystem { + private particles: Particle[] = []; + private geometry: InstancedBufferGeometry; + private material: ShaderMaterial; + private mesh: InstancedMesh; + + constructor(config: ParticleSystemConfig) { + this.initGeometry(config.count); + this.initMaterial(config.rendering); + this.mesh = new InstancedMesh(this.geometry, this.material, config.count); + } + + update(deltaTime: number, frame: number) { + // Update existing particles + for (let i = 0; i < this.particles.length; i++) { + const particle = this.particles[i]; + + if (particle.age >= particle.lifetime) { + this.recycleParticle(i); + continue; + } + + // Apply forces + for (const force of this.config.forces || []) { + this.applyForce(particle, force, deltaTime); + } + + // Update position + particle.position.addScaledVector(particle.velocity, deltaTime); + particle.velocity.multiplyScalar(1 - particle.drag * deltaTime); + + // Update rotation + particle.rotation += particle.rotationSpeed * deltaTime; + + // Update age + particle.age += deltaTime; + + // Update attributes + this.updateParticleAttributes(i, particle); + } + + // Emit new particles + this.emit(deltaTime, frame); + + // Update GPU buffers + this.geometry.attributes.particlePosition.needsUpdate = true; + this.geometry.attributes.particleColor.needsUpdate = true; + // ... other attributes + } + + private applyForce(particle: Particle, force: ParticleForce, deltaTime: number) { + switch (force.type) { + case 'gravity': + particle.velocity.y -= force.strength * deltaTime; + break; + case 'wind': + particle.velocity.addScaledVector( + new Vector3(...force.direction).normalize(), + force.strength * deltaTime + ); + break; + case 'turbulence': + const noise = this.turbulenceNoise(particle.position, force.frequency); + particle.velocity.addScaledVector(noise, force.strength * deltaTime); + break; + // ... other forces + } + } + + private emit(deltaTime: number, frame: number) { + // Continuous emission + if (this.config.emitter.rate) { + const count = this.config.emitter.rate * deltaTime; + for (let i = 0; i < Math.floor(count); i++) { + this.spawnParticle(); + } + } + + // Burst emission + for (const burst of this.config.emitter.burst || []) { + if (frame === burst.frame) { + for (let i = 0; i < burst.count; i++) { + this.spawnParticle(); + } + } + } + } + + private spawnParticle() { + const particle = this.getRecycledParticle(); + + // Set initial position based on emitter type + particle.position.copy(this.getEmitterPosition()); + + // Set initial velocity + particle.velocity.copy(this.getInitialVelocity()); + + // Set properties + particle.age = 0; + particle.lifetime = this.getLifetime(); + particle.size = this.getSize(0); + particle.color.copy(this.getColor(0)); + particle.rotation = Math.random() * Math.PI * 2; + particle.rotationSpeed = this.getRotationSpeed(); + + this.particles.push(particle); + } +} +``` + +## Implementation Checklist + +### Phase 1: Core Particle System +- [ ] Create `ParticleSystem` class +- [ ] Implement instanced rendering +- [ ] Create particle shaders (vertex + fragment) +- [ ] Implement particle lifecycle (spawn, update, recycle) +- [ ] Add particle pooling for performance + +### Phase 2: Emitters +- [ ] Implement point emitter +- [ ] Implement sphere emitter +- [ ] Implement box emitter +- [ ] Implement cone emitter +- [ ] Implement mesh surface emitter +- [ ] Add burst emission +- [ ] Add continuous emission + +### Phase 3: Forces +- [ ] Implement gravity force +- [ ] Implement wind force +- [ ] Implement turbulence (Perlin noise) +- [ ] Implement attraction/repulsion +- [ ] Implement vortex force + +### Phase 4: Advanced Features +- [ ] Particle collision with scene geometry +- [ ] Texture atlas support +- [ ] Color gradients over lifetime +- [ ] Size curves over lifetime +- [ ] Additive/multiply blending modes +- [ ] Soft particles (depth fade) + +### Phase 5: React Integration +- [ ] Create `ParticleSystem` React component +- [ ] Integrate with `ThreeLayer` +- [ ] Add particle system to scene context +- [ ] Support dynamic spawning from collision events + +### Phase 6: Testing +- [ ] Unit tests for particle lifecycle +- [ ] Test all emitter types +- [ ] Test all force types +- [ ] Performance tests (10k, 50k, 100k particles) +- [ ] Memory leak tests + +### Phase 7: Documentation & Examples +- [ ] Particle system guide +- [ ] Example: Explosion effect +- [ ] Example: Fire and smoke +- [ ] Example: Magic spell trail +- [ ] Example: Confetti celebration +- [ ] Example: Rain/snow weather + +## API Design + +```json +{ + "type": "three", + "props": { + "particles": [ + { + "id": "explosion", + "count": 10000, + "emitter": { + "type": "sphere", + "position": [0, 2, 0], + "radius": 0.5, + "burst": [ + { "frame": 60, "count": 5000 } + ] + }, + "particle": { + "lifetime": { "min": 1, "max": 3 }, + "velocity": { + "min": [-10, 0, -10], + "max": [10, 20, 10] + }, + "size": { "start": 0.3, "end": 0.05 }, + "color": { "start": "#ff6b6b", "end": "#ffd43b" }, + "opacity": { "start": 1, "end": 0 }, + "drag": 0.5 + }, + "forces": [ + { "type": "gravity", "strength": 9.81 }, + { "type": "turbulence", "strength": 2, "frequency": 0.5 } + ], + "rendering": { + "blending": "additive", + "texture": "particle.png" + } + } + ] + } +} +``` + +## Dependencies +- #GAMING-002 (for Three.js integration) + +## Acceptance Criteria +- [ ] Can render 10,000+ particles at 60fps +- [ ] All emitter types work correctly +- [ ] All force types work correctly +- [ ] Particle lifecycle is deterministic +- [ ] No memory leaks +- [ ] Works in browser and Node.js +- [ ] All tests pass +- [ ] Documentation complete +- [ ] At least 5 example templates + +## Related Issues +- #GAMING-002 (Three.js integration) +- #GAMING-003 (collision events can spawn particles) + +## Notes +- Consider WebGPU compute shaders for future optimization +- Add LOD system (reduce particle count at distance) +- Support particle trails (ribbon particles) +- Consider soft particle depth fade for better blending diff --git a/.github/issues/gaming-005-post-processing.md b/.github/issues/gaming-005-post-processing.md new file mode 100644 index 0000000..0b656a9 --- /dev/null +++ b/.github/issues/gaming-005-post-processing.md @@ -0,0 +1,462 @@ +# [GAMING-005] Post-Processing Effects for Three.js + +## Overview +Integrate `@react-three/postprocessing` to add cinematic post-processing effects: bloom, depth of field, motion blur, glitch, chromatic aberration, and custom shader passes. + +## Motivation +Post-processing transforms basic 3D renders into cinematic, AAA-quality visuals. Essential for professional marketing videos and game-style aesthetics. + +## Technical Approach + +Use `@react-three/postprocessing` (based on pmndrs/postprocessing) which provides: +- Efficient multi-pass rendering +- 50+ built-in effects +- Custom shader support +- Minimal performance overhead + +### Type Definitions + +```typescript +// packages/core/src/types/three.ts + +export interface PostProcessingConfig { + /** Bloom effect (glow) */ + bloom?: { + intensity?: number; // 0-10, default: 1 + threshold?: number; // 0-1, default: 0.9 + radius?: number; // 0-1, default: 0.85 + luminanceSmoothing?: number; // 0-1, default: 0.025 + }; + + /** Depth of field (focus blur) */ + depthOfField?: { + focusDistance?: number; // 0-1, default: 0.5 + focalLength?: number; // 0-1, default: 0.02 + bokehScale?: number; // 0-10, default: 2 + height?: number; // Resolution, default: 480 + }; + + /** Motion blur */ + motionBlur?: { + intensity?: number; // 0-1, default: 1 + samples?: number; // 4-32, default: 8 + }; + + /** Chromatic aberration */ + chromaticAberration?: { + offset?: [number, number]; // [-0.1, 0.1], default: [0.001, 0.001] + radialModulation?: boolean; // default: false + modulationOffset?: number; // 0-1, default: 0 + }; + + /** Glitch effect */ + glitch?: { + delay?: [number, number]; // Min/max delay between glitches + duration?: [number, number]; // Min/max glitch duration + strength?: [number, number]; // Min/max glitch strength + mode?: 'sporadic' | 'constant'; + active?: boolean; + ratio?: number; // 0-1, default: 0.85 + }; + + /** Vignette (darkened edges) */ + vignette?: { + offset?: number; // 0-1, default: 0.5 + darkness?: number; // 0-1, default: 0.5 + eskil?: boolean; // Use Eskil's vignette, default: false + }; + + /** Color grading */ + colorGrading?: { + brightness?: number; // -1 to 1, default: 0 + contrast?: number; // -1 to 1, default: 0 + saturation?: number; // -1 to 1, default: 0 + hue?: number; // 0 to 360, default: 0 + temperature?: number; // -1 to 1, default: 0 (warm/cool) + }; + + /** Pixelation */ + pixelation?: { + granularity?: number; // 1-20, default: 5 + }; + + /** Scanline effect (CRT monitor) */ + scanline?: { + density?: number; // 0-2, default: 1.25 + opacity?: number; // 0-1, default: 1 + }; + + /** Noise/grain */ + noise?: { + opacity?: number; // 0-1, default: 0.5 + blendFunction?: 'normal' | 'add' | 'multiply' | 'screen'; + }; + + /** Screen space ambient occlusion */ + ssao?: { + samples?: number; // 1-32, default: 16 + radius?: number; // 0-1, default: 0.1 + intensity?: number; // 0-2, default: 1 + bias?: number; // 0-1, default: 0.025 + }; + + /** Screen space reflections */ + ssr?: { + intensity?: number; // 0-1, default: 1 + maxDistance?: number; // 0-10, default: 1 + thickness?: number; // 0-1, default: 0.1 + }; + + /** God rays (volumetric light) */ + godRays?: { + lightPosition?: [number, number, number]; + density?: number; // 0-1, default: 0.96 + decay?: number; // 0-1, default: 0.9 + weight?: number; // 0-1, default: 0.4 + exposure?: number; // 0-1, default: 0.6 + samples?: number; // 15-100, default: 60 + }; + + /** Custom shader pass */ + customShader?: { + fragmentShader: string; + uniforms?: Record; + }; +} + +export interface ThreeLayerProps { + // ... existing properties + + /** Post-processing effects */ + postProcessing?: PostProcessingConfig; +} +``` + +### Implementation + +```typescript +// packages/renderer-browser/src/layers/three/PostProcessing.tsx + +import { EffectComposer } from '@react-three/postprocessing'; +import { + Bloom, + DepthOfField, + ChromaticAberration, + Glitch, + Vignette, + Noise, + SSAO, + GodRays, + // ... other effects +} from '@react-three/postprocessing'; +import { BlendFunction, GlitchMode } from 'postprocessing'; + +export function PostProcessing({ config }: { config: PostProcessingConfig }) { + if (!config || Object.keys(config).length === 0) { + return null; + } + + return ( + + {config.bloom && ( + + )} + + {config.depthOfField && ( + + )} + + {config.chromaticAberration && ( + + )} + + {config.glitch && ( + + )} + + {config.vignette && ( + + )} + + {config.noise && ( + + )} + + {config.ssao && ( + + )} + + {config.godRays && config.godRays.lightPosition && ( + + )} + + {config.customShader && ( + + )} + + ); +} +``` + +### Color Grading Implementation + +```typescript +// Custom color grading effect +import { Effect } from 'postprocessing'; + +const colorGradingShader = ` +uniform float brightness; +uniform float contrast; +uniform float saturation; +uniform float hue; +uniform float temperature; + +vec3 adjustBrightness(vec3 color, float value) { + return color + value; +} + +vec3 adjustContrast(vec3 color, float value) { + return (color - 0.5) * (1.0 + value) + 0.5; +} + +vec3 adjustSaturation(vec3 color, float value) { + float gray = dot(color, vec3(0.299, 0.587, 0.114)); + return mix(vec3(gray), color, 1.0 + value); +} + +vec3 adjustHue(vec3 color, float value) { + // RGB to HSV and back with hue shift + // ... implementation +} + +vec3 adjustTemperature(vec3 color, float value) { + // Warm: increase red/yellow, Cool: increase blue + if (value > 0.0) { + color.r += value * 0.3; + color.g += value * 0.1; + } else { + color.b += abs(value) * 0.3; + } + return color; +} + +void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { + vec3 color = inputColor.rgb; + + color = adjustBrightness(color, brightness); + color = adjustContrast(color, contrast); + color = adjustSaturation(color, saturation); + color = adjustHue(color, hue); + color = adjustTemperature(color, temperature); + + outputColor = vec4(color, inputColor.a); +} +`; + +export class ColorGradingEffect extends Effect { + constructor(config: ColorGradingConfig) { + super('ColorGradingEffect', colorGradingShader, { + uniforms: new Map([ + ['brightness', new Uniform(config.brightness ?? 0)], + ['contrast', new Uniform(config.contrast ?? 0)], + ['saturation', new Uniform(config.saturation ?? 0)], + ['hue', new Uniform(config.hue ?? 0)], + ['temperature', new Uniform(config.temperature ?? 0)], + ]) + }); + } +} +``` + +## Implementation Checklist + +### Phase 1: Package Setup +- [ ] Add `@react-three/postprocessing` dependency +- [ ] Add `postprocessing` peer dependency +- [ ] Update TypeScript types + +### Phase 2: Core Effects +- [ ] Implement Bloom +- [ ] Implement Depth of Field +- [ ] Implement Motion Blur +- [ ] Implement Chromatic Aberration +- [ ] Implement Glitch +- [ ] Implement Vignette +- [ ] Implement Noise + +### Phase 3: Advanced Effects +- [ ] Implement SSAO +- [ ] Implement SSR +- [ ] Implement God Rays +- [ ] Implement Color Grading (custom) +- [ ] Implement Pixelation +- [ ] Implement Scanline + +### Phase 4: Custom Shaders +- [ ] Support custom fragment shaders +- [ ] Support uniform passing +- [ ] Support texture inputs +- [ ] Add shader validation + +### Phase 5: Integration +- [ ] Integrate with `ThreeLayer` +- [ ] Add to scene rendering pipeline +- [ ] Support effect ordering +- [ ] Add performance monitoring + +### Phase 6: Testing +- [ ] Unit tests for each effect +- [ ] Visual regression tests +- [ ] Performance tests +- [ ] Test effect combinations +- [ ] Test in browser and Node.js + +### Phase 7: Documentation & Examples +- [ ] Post-processing guide +- [ ] Effect reference documentation +- [ ] Example: Cinematic bloom +- [ ] Example: Glitch effect +- [ ] Example: Depth of field product shot +- [ ] Example: God rays dramatic lighting +- [ ] Example: Custom shader effect + +## API Design + +```json +{ + "type": "three", + "props": { + "meshes": [ + { + "id": "product", + "geometry": { "type": "sphere", "radius": 1 }, + "material": { "type": "physical", "metalness": 1, "roughness": 0.2 } + } + ], + "postProcessing": { + "bloom": { + "intensity": 2, + "threshold": 0.8, + "radius": 0.9 + }, + "depthOfField": { + "focusDistance": 0.5, + "focalLength": 0.02, + "bokehScale": 3 + }, + "chromaticAberration": { + "offset": [0.002, 0.002] + }, + "vignette": { + "offset": 0.3, + "darkness": 0.7 + }, + "colorGrading": { + "brightness": 0.1, + "contrast": 0.2, + "saturation": 0.3, + "temperature": 0.1 + } + } + } +} +``` + +### Glitch Effect Example + +```json +{ + "postProcessing": { + "glitch": { + "delay": [0.5, 1.5], + "duration": [0.1, 0.3], + "strength": [0.5, 1.0], + "mode": "sporadic", + "active": true + }, + "chromaticAberration": { + "offset": [0.005, 0.005] + } + } +} +``` + +### Custom Shader Example + +```json +{ + "postProcessing": { + "customShader": { + "fragmentShader": "void mainImage(const in vec4 inputColor, const in vec2 uv, out vec4 outputColor) { vec3 color = inputColor.rgb; color = vec3(1.0) - color; outputColor = vec4(color, inputColor.a); }", + "uniforms": { + "intensity": { "value": 1.0 } + } + } + } +} +``` + +## Dependencies +- #GAMING-002 (Three.js integration) + +## Acceptance Criteria +- [ ] All listed effects work correctly +- [ ] Effects can be combined +- [ ] Custom shaders supported +- [ ] No significant performance impact (<5ms per frame) +- [ ] Works in browser and Node.js +- [ ] All tests pass +- [ ] Documentation complete +- [ ] At least 5 example templates + +## Related Issues +- #GAMING-002 (Three.js integration) + +## Notes +- Consider effect presets (cinematic, retro, cyberpunk, etc.) +- Add effect intensity animation over time +- Support selective effects (per-object masking) +- Consider LUT (Look-Up Table) support for color grading diff --git a/.github/issues/gaming-006-keyframe-animation.md b/.github/issues/gaming-006-keyframe-animation.md new file mode 100644 index 0000000..f23b1c9 --- /dev/null +++ b/.github/issues/gaming-006-keyframe-animation.md @@ -0,0 +1,491 @@ +# [GAMING-006] Advanced Animation System with Keyframes + +## Overview +Create a comprehensive keyframe animation system that goes beyond simple auto-rotation, supporting position, rotation, scale, material properties, and custom properties with bezier curves and easing functions. + +## Motivation +Game-style videos need complex, choreographed animations. Current auto-rotation is too limited. Need timeline-based keyframe system for cinematic camera movements, object animations, and property changes. + +## Technical Approach + +Extend existing animation system with keyframe support, inspired by animation tools like After Effects and Blender. + +### Type Definitions + +```typescript +// packages/core/src/types/three.ts + +export interface KeyframeAnimation { + /** Property path to animate (e.g., "position.y", "material.color", "rotation.x") */ + property: string; + + /** Keyframes defining the animation */ + keyframes: Keyframe[]; + + /** Loop behavior */ + loop?: 'none' | 'repeat' | 'pingpong'; + + /** Number of loops (0 = infinite) */ + loopCount?: number; +} + +export interface Keyframe { + /** Frame number */ + frame: number; + + /** Value at this keyframe */ + value: number | string | [number, number, number] | [number, number, number, number]; + + /** Easing function */ + easing?: EasingFunction; + + /** Bezier curve control points (for custom easing) */ + bezier?: { + cp1: [number, number]; + cp2: [number, number]; + }; +} + +export type EasingFunction = + | 'linear' + | 'easeIn' | 'easeOut' | 'easeInOut' + | 'easeInQuad' | 'easeOutQuad' | 'easeInOutQuad' + | 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic' + | 'easeInQuart' | 'easeOutQuart' | 'easeInOutQuart' + | 'easeInQuint' | 'easeOutQuint' | 'easeInOutQuint' + | 'easeInSine' | 'easeOutSine' | 'easeInOutSine' + | 'easeInExpo' | 'easeOutExpo' | 'easeInOutExpo' + | 'easeInCirc' | 'easeOutCirc' | 'easeInOutCirc' + | 'easeInBack' | 'easeOutBack' | 'easeInOutBack' + | 'easeInElastic' | 'easeOutElastic' | 'easeInOutElastic' + | 'easeInBounce' | 'easeOutBounce' | 'easeInOutBounce'; + +export interface ThreeMeshConfig { + // ... existing properties + + /** Keyframe animations */ + animations?: KeyframeAnimation[]; + + /** Remove autoRotate (replaced by keyframe animations) */ + // autoRotate?: [number, number, number]; // DEPRECATED +} + +export interface ThreeCameraConfig { + // ... existing properties + + /** Camera animations */ + animations?: KeyframeAnimation[]; +} + +export interface ThreeLightConfig { + // ... existing properties + + /** Light animations */ + animations?: KeyframeAnimation[]; +} +``` + +### Animation Engine + +```typescript +// packages/renderer-browser/src/layers/three/animation/AnimationEngine.ts + +export class AnimationEngine { + private animations = new Map(); + + addAnimation(targetId: string, animation: KeyframeAnimation) { + const track = new AnimationTrack(animation); + this.animations.set(`${targetId}.${animation.property}`, track); + } + + update(frame: number, targets: Map) { + for (const [key, track] of this.animations) { + const [targetId, property] = key.split('.'); + const target = targets.get(targetId); + + if (target) { + const value = track.evaluate(frame); + this.setProperty(target, property, value); + } + } + } + + private setProperty(target: any, path: string, value: any) { + const parts = path.split('.'); + let obj = target; + + for (let i = 0; i < parts.length - 1; i++) { + obj = obj[parts[i]]; + } + + const finalProp = parts[parts.length - 1]; + + if (Array.isArray(value)) { + // Vector or quaternion + if (obj[finalProp].set) { + obj[finalProp].set(...value); + } else if (obj[finalProp].fromArray) { + obj[finalProp].fromArray(value); + } + } else if (typeof value === 'string' && finalProp === 'color') { + // Color + obj[finalProp].set(value); + } else { + // Scalar + obj[finalProp] = value; + } + } +} + +class AnimationTrack { + private keyframes: Keyframe[]; + private loop: 'none' | 'repeat' | 'pingpong'; + private loopCount: number; + + constructor(animation: KeyframeAnimation) { + this.keyframes = animation.keyframes.sort((a, b) => a.frame - b.frame); + this.loop = animation.loop || 'none'; + this.loopCount = animation.loopCount || 0; + } + + evaluate(frame: number): any { + // Handle looping + const duration = this.keyframes[this.keyframes.length - 1].frame; + let localFrame = frame; + + if (this.loop === 'repeat') { + localFrame = frame % duration; + } else if (this.loop === 'pingpong') { + const cycle = Math.floor(frame / duration); + localFrame = cycle % 2 === 0 + ? frame % duration + : duration - (frame % duration); + } + + // Find surrounding keyframes + let k1: Keyframe | null = null; + let k2: Keyframe | null = null; + + for (let i = 0; i < this.keyframes.length - 1; i++) { + if (localFrame >= this.keyframes[i].frame && localFrame <= this.keyframes[i + 1].frame) { + k1 = this.keyframes[i]; + k2 = this.keyframes[i + 1]; + break; + } + } + + // Before first keyframe + if (!k1) return this.keyframes[0].value; + + // After last keyframe + if (!k2) return this.keyframes[this.keyframes.length - 1].value; + + // Interpolate + const t = (localFrame - k1.frame) / (k2.frame - k1.frame); + const easedT = this.applyEasing(t, k1.easing || 'linear', k1.bezier); + + return this.interpolate(k1.value, k2.value, easedT); + } + + private applyEasing(t: number, easing: EasingFunction, bezier?: any): number { + if (bezier) { + return this.cubicBezier(t, bezier.cp1, bezier.cp2); + } + + switch (easing) { + case 'linear': return t; + case 'easeInQuad': return t * t; + case 'easeOutQuad': return t * (2 - t); + case 'easeInOutQuad': return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + case 'easeInCubic': return t * t * t; + case 'easeOutCubic': return (--t) * t * t + 1; + case 'easeInOutCubic': return t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1; + case 'easeInElastic': return this.elasticIn(t); + case 'easeOutElastic': return this.elasticOut(t); + case 'easeInBounce': return 1 - this.bounceOut(1 - t); + case 'easeOutBounce': return this.bounceOut(t); + // ... more easing functions + default: return t; + } + } + + private cubicBezier(t: number, cp1: [number, number], cp2: [number, number]): number { + // Cubic bezier curve implementation + const cx = 3 * cp1[0]; + const bx = 3 * (cp2[0] - cp1[0]) - cx; + const ax = 1 - cx - bx; + + const cy = 3 * cp1[1]; + const by = 3 * (cp2[1] - cp1[1]) - cy; + const ay = 1 - cy - by; + + return ((ax * t + bx) * t + cx) * t; + } + + private interpolate(v1: any, v2: any, t: number): any { + if (typeof v1 === 'number' && typeof v2 === 'number') { + return v1 + (v2 - v1) * t; + } + + if (Array.isArray(v1) && Array.isArray(v2)) { + return v1.map((val, i) => val + (v2[i] - val) * t); + } + + if (typeof v1 === 'string' && typeof v2 === 'string') { + // Color interpolation + return this.interpolateColor(v1, v2, t); + } + + return v2; // Discrete values + } + + private interpolateColor(c1: string, c2: string, t: number): string { + const color1 = new Color(c1); + const color2 = new Color(c2); + return '#' + color1.lerp(color2, t).getHexString(); + } + + private elasticOut(t: number): number { + const p = 0.3; + return Math.pow(2, -10 * t) * Math.sin((t - p / 4) * (2 * Math.PI) / p) + 1; + } + + private bounceOut(t: number): number { + if (t < 1 / 2.75) { + return 7.5625 * t * t; + } else if (t < 2 / 2.75) { + return 7.5625 * (t -= 1.5 / 2.75) * t + 0.75; + } else if (t < 2.5 / 2.75) { + return 7.5625 * (t -= 2.25 / 2.75) * t + 0.9375; + } else { + return 7.5625 * (t -= 2.625 / 2.75) * t + 0.984375; + } + } +} +``` + +### React Integration + +```typescript +// packages/renderer-browser/src/layers/three/AnimatedMesh.tsx + +export function AnimatedMesh({ + mesh, + animations, + frame +}: AnimatedMeshProps) { + const meshRef = useRef(null); + const animationEngine = useRef(new AnimationEngine()); + + useEffect(() => { + if (!animations) return; + + animations.forEach(anim => { + animationEngine.current.addAnimation(mesh.id, anim); + }); + }, [animations]); + + useFrame(() => { + if (meshRef.current) { + const targets = new Map([[mesh.id, meshRef.current]]); + animationEngine.current.update(frame, targets); + } + }); + + return {/* ... */}; +} +``` + +## Implementation Checklist + +### Phase 1: Core Animation Engine +- [ ] Create `AnimationEngine` class +- [ ] Implement `AnimationTrack` class +- [ ] Implement keyframe evaluation +- [ ] Implement interpolation (number, vector, color) +- [ ] Add loop support (repeat, pingpong) + +### Phase 2: Easing Functions +- [ ] Implement all standard easing functions (30+) +- [ ] Implement cubic bezier curves +- [ ] Add easing visualization tool (for docs) + +### Phase 3: Property Animation +- [ ] Support position animation +- [ ] Support rotation animation (euler and quaternion) +- [ ] Support scale animation +- [ ] Support material property animation +- [ ] Support camera animation +- [ ] Support light animation + +### Phase 4: React Integration +- [ ] Create `AnimatedMesh` component +- [ ] Create `AnimatedCamera` component +- [ ] Create `AnimatedLight` component +- [ ] Integrate with existing `ThreeLayer` + +### Phase 5: Advanced Features +- [ ] Animation blending (crossfade between animations) +- [ ] Animation events (callbacks at specific frames) +- [ ] Path animation (follow spline curve) +- [ ] Look-at constraints +- [ ] Parent-child animation inheritance + +### Phase 6: Testing +- [ ] Unit tests for interpolation +- [ ] Unit tests for easing functions +- [ ] Integration tests for all property types +- [ ] Test loop modes +- [ ] Performance tests + +### Phase 7: Documentation & Examples +- [ ] Animation system guide +- [ ] Easing function reference +- [ ] Example: Camera flythrough +- [ ] Example: Product rotation showcase +- [ ] Example: Complex multi-object choreography +- [ ] Example: Material color animation +- [ ] Example: Light intensity pulse + +## API Design + +### Basic Position Animation + +```json +{ + "meshes": [{ + "id": "box", + "geometry": { "type": "box" }, + "animations": [ + { + "property": "position.y", + "keyframes": [ + { "frame": 0, "value": 0, "easing": "easeOutBounce" }, + { "frame": 60, "value": 5, "easing": "easeInQuad" }, + { "frame": 120, "value": 0, "easing": "easeOutBounce" } + ], + "loop": "repeat" + } + ] + }] +} +``` + +### Complex Multi-Property Animation + +```json +{ + "meshes": [{ + "id": "product", + "geometry": { "type": "sphere" }, + "material": { "type": "standard", "color": "#ff0000" }, + "animations": [ + { + "property": "position", + "keyframes": [ + { "frame": 0, "value": [0, 0, 0] }, + { "frame": 60, "value": [2, 3, 1], "easing": "easeInOutCubic" }, + { "frame": 120, "value": [0, 0, 0], "easing": "easeInOutCubic" } + ] + }, + { + "property": "rotation.y", + "keyframes": [ + { "frame": 0, "value": 0 }, + { "frame": 120, "value": 6.28318, "easing": "linear" } + ] + }, + { + "property": "scale", + "keyframes": [ + { "frame": 0, "value": [1, 1, 1] }, + { "frame": 30, "value": [1.2, 1.2, 1.2], "easing": "easeOutElastic" }, + { "frame": 60, "value": [1, 1, 1], "easing": "easeInOutQuad" } + ] + }, + { + "property": "material.color", + "keyframes": [ + { "frame": 0, "value": "#ff0000" }, + { "frame": 60, "value": "#00ff00" }, + { "frame": 120, "value": "#0000ff" } + ] + } + ] + }] +} +``` + +### Camera Animation + +```json +{ + "camera": { + "type": "perspective", + "fov": 75, + "position": [0, 0, 5], + "animations": [ + { + "property": "position", + "keyframes": [ + { "frame": 0, "value": [5, 2, 5] }, + { "frame": 90, "value": [-5, 2, 5], "easing": "easeInOutCubic" }, + { "frame": 180, "value": [0, 5, 0], "easing": "easeInOutCubic" } + ] + }, + { + "property": "fov", + "keyframes": [ + { "frame": 0, "value": 75 }, + { "frame": 90, "value": 50, "easing": "easeInOutQuad" } + ] + } + ] + } +} +``` + +### Custom Bezier Curve + +```json +{ + "animations": [{ + "property": "position.y", + "keyframes": [ + { + "frame": 0, + "value": 0, + "bezier": { + "cp1": [0.42, 0], + "cp2": [0.58, 1] + } + }, + { "frame": 60, "value": 5 } + ] + }] +} +``` + +## Dependencies +- #GAMING-002 (Three.js integration) + +## Acceptance Criteria +- [ ] All property types can be animated +- [ ] All easing functions work correctly +- [ ] Bezier curves work correctly +- [ ] Loop modes work correctly +- [ ] Animations are deterministic +- [ ] No performance degradation +- [ ] All tests pass +- [ ] Documentation complete +- [ ] At least 5 example templates + +## Related Issues +- #GAMING-002 (Three.js integration) +- #GAMING-007 (scripting can control animations) + +## Notes +- Consider animation timeline editor UI (future) +- Support importing animations from Blender/Maya (future) +- Add animation presets library +- Consider GLTF animation import diff --git a/.github/issues/gaming-007-scripting-system.md b/.github/issues/gaming-007-scripting-system.md new file mode 100644 index 0000000..e3247c0 --- /dev/null +++ b/.github/issues/gaming-007-scripting-system.md @@ -0,0 +1,523 @@ +# [GAMING-007] Custom Scripting System with Safe VM Execution + +## Overview +Create a safe scripting system that allows custom JavaScript code in templates for game logic, behaviors, and dynamic interactions. Execute scripts in isolated VM with timeout and memory limits. + +## Motivation +Enable complex game-like behaviors that can't be expressed in JSON: AI pathfinding, procedural generation, state machines, conditional logic, and frame-by-frame control. Essential for true game scene videos. + +## Technical Approach + +Use isolated VM execution with whitelisted API surface. Scripts run in sandboxed environment with no access to file system, network, or dangerous APIs. + +### Type Definitions + +```typescript +// packages/core/src/types/three.ts + +export interface ScriptConfig { + /** Script execution hooks */ + hooks?: { + /** Called once when scene initializes */ + onInit?: string; + + /** Called every frame */ + onFrame?: string; + + /** Called when scene is destroyed */ + onDestroy?: string; + }; + + /** Reusable behavior functions */ + behaviors?: Record; + + /** Global variables accessible to all scripts */ + globals?: Record; +} + +export interface BehaviorScript { + /** JavaScript function code */ + code: string; + + /** Event triggers */ + triggers?: Array<'collision' | 'frame' | 'custom'>; + + /** Parameters for the behavior */ + params?: Record; +} + +export interface ThreeMeshConfig { + // ... existing properties + + /** Per-mesh scripts */ + scripts?: { + /** Called when mesh is created */ + onInit?: string; + + /** Called every frame */ + onUpdate?: string; + + /** Called on collision */ + onCollision?: string; + + /** Called when mesh is destroyed */ + onDestroy?: string; + + /** Behavior references */ + behaviors?: string[]; // IDs from ScriptConfig.behaviors + }; +} + +export interface ThreeLayerProps { + // ... existing properties + + /** Scene-level scripts */ + scripts?: ScriptConfig; +} +``` + +### Script API Surface + +```typescript +// packages/scripting/src/ScriptAPI.ts + +export interface ScriptAPI { + // Scene access + scene: { + /** Get mesh by ID */ + getById(id: string): MeshProxy | null; + + /** Get all meshes */ + getAll(): MeshProxy[]; + + /** Add new mesh */ + add(config: ThreeMeshConfig): MeshProxy; + + /** Remove mesh */ + remove(id: string): void; + + /** Get camera */ + getCamera(): CameraProxy; + + /** Get lights */ + getLights(): LightProxy[]; + }; + + // Physics access + physics: { + /** Apply force to rigid body */ + applyForce(meshId: string, force: [number, number, number], point?: [number, number, number]): void; + + /** Apply impulse */ + applyImpulse(meshId: string, impulse: [number, number, number], point?: [number, number, number]): void; + + /** Set velocity */ + setVelocity(meshId: string, velocity: [number, number, number]): void; + + /** Raycast */ + raycast(origin: [number, number, number], direction: [number, number, number], maxDistance: number): RaycastHit | null; + + /** Get velocity */ + getVelocity(meshId: string): [number, number, number]; + }; + + // Particles + particles: { + /** Emit particles */ + emit(particleSystemId: string, position: [number, number, number], count: number): void; + + /** Burst particles */ + burst(particleSystemId: string, position: [number, number, number], count: number): void; + }; + + // Math utilities + math: { + /** Vector3 operations */ + vec3: { + add(a: [number, number, number], b: [number, number, number]): [number, number, number]; + subtract(a: [number, number, number], b: [number, number, number]): [number, number, number]; + multiply(v: [number, number, number], scalar: number): [number, number, number]; + dot(a: [number, number, number], b: [number, number, number]): number; + cross(a: [number, number, number], b: [number, number, number]): [number, number, number]; + normalize(v: [number, number, number]): [number, number, number]; + length(v: [number, number, number]): number; + distance(a: [number, number, number], b: [number, number, number]): number; + }; + + /** Random utilities */ + random: { + float(min: number, max: number): number; + int(min: number, max: number): number; + choice(array: T[]): T; + vector3(min: [number, number, number], max: [number, number, number]): [number, number, number]; + }; + + /** Noise functions */ + noise: { + perlin(x: number, y: number, z: number): number; + simplex(x: number, y: number, z: number): number; + }; + + /** Interpolation */ + lerp(a: number, b: number, t: number): number; + clamp(value: number, min: number, max: number): number; + map(value: number, inMin: number, inMax: number, outMin: number, outMax: number): number; + }; + + // Time + time: { + /** Current frame */ + frame: number; + + /** Delta time in seconds */ + deltaTime: number; + + /** Total elapsed time */ + elapsed: number; + + /** Frames per second */ + fps: number; + }; + + // State management + state: { + /** Get global state */ + get(key: string): any; + + /** Set global state */ + set(key: string, value: any): void; + + /** Check if key exists */ + has(key: string): boolean; + }; + + // Logging (for debugging) + console: { + log(...args: any[]): void; + warn(...args: any[]): void; + error(...args: any[]): void; + }; +} + +// Proxy objects (limited access to underlying Three.js objects) +export interface MeshProxy { + id: string; + position: [number, number, number]; + rotation: [number, number, number]; + scale: [number, number, number]; + visible: boolean; + + setPosition(position: [number, number, number]): void; + setRotation(rotation: [number, number, number]): void; + setScale(scale: [number, number, number]): void; + setVisible(visible: boolean): void; + setMaterial(material: Partial): void; + destroy(): void; +} +``` + +### VM Implementation + +```typescript +// packages/scripting/src/ScriptRunner.ts + +import { VM } from 'vm2'; + +export class ScriptRunner { + private vm: VM; + private api: ScriptAPI; + private timeout: number; + + constructor(api: ScriptAPI, options: ScriptRunnerOptions = {}) { + this.api = api; + this.timeout = options.timeout || 100; // 100ms default + + this.vm = new VM({ + timeout: this.timeout, + sandbox: { + scene: api.scene, + physics: api.physics, + particles: api.particles, + math: api.math, + time: api.time, + state: api.state, + console: api.console, + // Whitelist safe globals + Math: Math, + Array: Array, + Object: Object, + JSON: JSON, + // Block dangerous globals + require: undefined, + process: undefined, + global: undefined, + eval: undefined, + Function: undefined, + }, + eval: false, + wasm: false, + }); + } + + run(code: string, context?: Record): any { + try { + // Wrap code in function for better error messages + const wrappedCode = ` + (function() { + ${code} + })() + `; + + // Add context variables to sandbox + if (context) { + Object.entries(context).forEach(([key, value]) => { + this.vm.sandbox[key] = value; + }); + } + + return this.vm.run(wrappedCode); + } catch (error) { + if (error.message.includes('Script execution timed out')) { + throw new ScriptTimeoutError(`Script exceeded ${this.timeout}ms timeout`); + } + throw new ScriptExecutionError(`Script error: ${error.message}`); + } + } + + runFunction(code: string, args: any[]): any { + const wrappedCode = ` + (function(...args) { + ${code} + }) + `; + + const fn = this.vm.run(wrappedCode); + return fn(...args); + } +} + +export class ScriptTimeoutError extends Error {} +export class ScriptExecutionError extends Error {} +``` + +### React Integration + +```typescript +// packages/renderer-browser/src/layers/three/ScriptedScene.tsx + +export function ScriptedScene({ + scripts, + frame, + deltaTime +}: ScriptedSceneProps) { + const scriptRunner = useRef(null); + const api = useScriptAPI(); + + useEffect(() => { + if (!scripts) return; + + scriptRunner.current = new ScriptRunner(api); + + // Run onInit + if (scripts.hooks?.onInit) { + try { + scriptRunner.current.run(scripts.hooks.onInit); + } catch (error) { + console.error('Script onInit error:', error); + } + } + + return () => { + // Run onDestroy + if (scripts.hooks?.onDestroy && scriptRunner.current) { + try { + scriptRunner.current.run(scripts.hooks.onDestroy); + } catch (error) { + console.error('Script onDestroy error:', error); + } + } + }; + }, [scripts]); + + useFrame(() => { + if (!scripts?.hooks?.onFrame || !scriptRunner.current) return; + + // Update time context + api.time.frame = frame; + api.time.deltaTime = deltaTime; + + try { + scriptRunner.current.run(scripts.hooks.onFrame); + } catch (error) { + console.error('Script onFrame error:', error); + } + }); + + return null; +} +``` + +## Implementation Checklist + +### Phase 1: Core Scripting Package +- [ ] Create `@rendervid/scripting` package +- [ ] Add `vm2` dependency (or `isolated-vm` for better performance) +- [ ] Implement `ScriptRunner` class +- [ ] Implement timeout mechanism +- [ ] Implement memory limits +- [ ] Add error handling and reporting + +### Phase 2: Script API +- [ ] Implement scene API (getById, add, remove) +- [ ] Implement physics API (forces, impulses, raycast) +- [ ] Implement particles API (emit, burst) +- [ ] Implement math utilities (vec3, random, noise) +- [ ] Implement state management +- [ ] Implement safe console logging + +### Phase 3: Proxy Objects +- [ ] Implement `MeshProxy` +- [ ] Implement `CameraProxy` +- [ ] Implement `LightProxy` +- [ ] Ensure proxies prevent direct Three.js access + +### Phase 4: React Integration +- [ ] Create `ScriptedScene` component +- [ ] Create `ScriptedMesh` component +- [ ] Integrate with `ThreeLayer` +- [ ] Add script error boundaries + +### Phase 5: Behavior Library +- [ ] Create preset behaviors (orbit, follow, bounce, etc.) +- [ ] Implement behavior composition +- [ ] Add behavior parameters + +### Phase 6: Testing +- [ ] Unit tests for ScriptRunner +- [ ] Test timeout enforcement +- [ ] Test memory limits +- [ ] Test API surface +- [ ] Test error handling +- [ ] Security tests (ensure sandbox works) + +### Phase 7: Documentation & Examples +- [ ] Scripting guide +- [ ] API reference +- [ ] Security best practices +- [ ] Example: Orbiting objects +- [ ] Example: Follow camera +- [ ] Example: Procedural generation +- [ ] Example: AI pathfinding +- [ ] Example: State machine + +## API Design + +### Basic Frame Script + +```json +{ + "type": "three", + "props": { + "scripts": { + "hooks": { + "onFrame": "const box = scene.getById('box'); box.setRotation([0, time.frame * 0.01, 0]);" + } + }, + "meshes": [ + { "id": "box", "geometry": { "type": "box" } } + ] + } +} +``` + +### Collision Response + +```json +{ + "meshes": [{ + "id": "ball", + "geometry": { "type": "sphere" }, + "rigidBody": { "type": "dynamic" }, + "scripts": { + "onCollision": "if (collision.impulse > 5) { particles.burst('impact', collision.point, 100); }" + } + }] +} +``` + +### Complex Behavior + +```json +{ + "scripts": { + "behaviors": { + "orbit": { + "code": "const angle = time.frame * params.speed; const radius = params.radius; mesh.setPosition([Math.cos(angle) * radius, params.height, Math.sin(angle) * radius]);", + "params": { "speed": 0.02, "radius": 5, "height": 2 } + } + } + }, + "meshes": [{ + "id": "satellite", + "geometry": { "type": "sphere" }, + "scripts": { + "behaviors": ["orbit"] + } + }] +} +``` + +### Procedural Generation + +```json +{ + "scripts": { + "hooks": { + "onInit": "for (let i = 0; i < 100; i++) { const pos = math.random.vector3([-10, 0, -10], [10, 5, 10]); scene.add({ id: 'cube_' + i, geometry: { type: 'box', width: 0.5, height: 0.5, depth: 0.5 }, position: pos, rigidBody: { type: 'dynamic' } }); }" + } + } +} +``` + +### AI Pathfinding + +```json +{ + "scripts": { + "globals": { + "waypoints": [[0, 0, 0], [5, 0, 5], [10, 0, 0], [5, 0, -5]], + "currentWaypoint": 0 + }, + "hooks": { + "onFrame": "const agent = scene.getById('agent'); const target = state.get('waypoints')[state.get('currentWaypoint')]; const distance = math.vec3.distance(agent.position, target); if (distance < 0.5) { state.set('currentWaypoint', (state.get('currentWaypoint') + 1) % state.get('waypoints').length); } const direction = math.vec3.normalize(math.vec3.subtract(target, agent.position)); physics.setVelocity('agent', math.vec3.multiply(direction, 2));" + } + } +} +``` + +## Dependencies +- #GAMING-002 (Three.js integration) +- #GAMING-003 (collision events) + +## Acceptance Criteria +- [ ] Scripts execute in isolated VM +- [ ] Timeout enforcement works +- [ ] Memory limits work +- [ ] All API methods functional +- [ ] No security vulnerabilities +- [ ] Error handling is robust +- [ ] All tests pass +- [ ] Documentation complete +- [ ] At least 5 example templates + +## Related Issues +- #GAMING-002 (Three.js integration) +- #GAMING-003 (collision events) +- #GAMING-004 (particle control) + +## Notes +- Consider TypeScript support (compile to JS before execution) +- Add script validation/linting +- Consider visual scripting editor (future) +- Add performance profiling for scripts +- Consider WASM for compute-heavy scripts (future) diff --git a/.github/issues/gaming-008-pixijs-layer.md b/.github/issues/gaming-008-pixijs-layer.md new file mode 100644 index 0000000..aa4b9dc --- /dev/null +++ b/.github/issues/gaming-008-pixijs-layer.md @@ -0,0 +1,557 @@ +# [GAMING-008] PixiJS 2D Layer Integration + +## Overview +Create a new `"type": "pixi"` layer that integrates PixiJS for high-performance 2D game-style graphics: sprites, tilemaps, 2D particles, and filters. + +## Motivation +Enable 2D game aesthetics for marketing videos: retro games, platformers, arcade-style animations, pixel art, and 2D motion graphics. PixiJS is the industry standard for 2D WebGL rendering. + +## Technical Approach + +Integrate PixiJS as a new layer type alongside Three.js, with its own rendering pipeline and features optimized for 2D. + +### Type Definitions + +```typescript +// packages/core/src/types/pixi.ts + +export interface PixiLayer extends LayerBase { + type: 'pixi'; + props: PixiLayerProps; +} + +export interface PixiLayerProps { + /** Background color */ + backgroundColor?: Color; + + /** Sprites in the scene */ + sprites?: PixiSprite[]; + + /** Tilemaps */ + tilemaps?: PixiTilemap[]; + + /** 2D particle systems */ + particles?: PixiParticleSystem[]; + + /** Graphics (vector shapes) */ + graphics?: PixiGraphic[]; + + /** Text objects */ + texts?: PixiText[]; + + /** Filters (post-processing) */ + filters?: PixiFilter[]; + + /** 2D physics */ + physics?: { + enabled: boolean; + engine: 'matter'; + gravity?: { x: number; y: number }; + }; +} + +export interface PixiSprite { + id: string; + + /** Texture source */ + texture: string; // URL or data URI + + /** Position in pixels */ + position: [number, number]; + + /** Anchor point (0-1) */ + anchor?: [number, number]; + + /** Scale */ + scale?: [number, number] | number; + + /** Rotation in radians */ + rotation?: number; + + /** Opacity */ + alpha?: number; + + /** Tint color */ + tint?: Color; + + /** Blend mode */ + blendMode?: 'normal' | 'add' | 'multiply' | 'screen'; + + /** Sprite sheet animation */ + animation?: { + /** Frame definitions */ + frames: SpriteFrame[]; + + /** Current animation */ + current?: string; + + /** Animations */ + animations: Record; + }; + + /** Physics body */ + rigidBody?: { + type: 'dynamic' | 'static' | 'kinematic'; + mass?: number; + friction?: number; + restitution?: number; + velocity?: [number, number]; + }; + + /** Keyframe animations */ + animations?: KeyframeAnimation[]; +} + +export interface SpriteFrame { + x: number; + y: number; + width: number; + height: number; +} + +export interface SpriteAnimation { + frames: number[]; // Frame indices + speed: number; // Frames per second + loop?: boolean; +} + +export interface PixiTilemap { + id: string; + + /** Tileset texture */ + tileset: string; + + /** Tile size in pixels */ + tileSize: [number, number]; + + /** Map data (2D array of tile indices) */ + map: number[][]; + + /** Position */ + position?: [number, number]; + + /** Collision layer (for physics) */ + collision?: boolean[][]; +} + +export interface PixiParticleSystem { + id: string; + + /** Particle texture */ + texture: string; + + /** Emitter configuration */ + emitter: { + type: 'point' | 'rectangle' | 'circle'; + position: [number, number]; + size?: [number, number]; // For rectangle + radius?: number; // For circle + rate?: number; // Particles per second + burst?: Array<{ frame: number; count: number }>; + }; + + /** Particle properties */ + particle: { + lifetime: number | { min: number; max: number }; + speed: number | { min: number; max: number }; + angle: number | { min: number; max: number }; + scale: number | { start: number; end: number }; + alpha: number | { start: number; end: number }; + color?: string | { start: string; end: string }; + rotation?: number | { min: number; max: number }; + rotationSpeed?: number | { min: number; max: number }; + }; + + /** Forces */ + forces?: Array<{ + type: 'gravity' | 'wind'; + strength: number; + direction?: [number, number]; + }>; +} + +export interface PixiGraphic { + id: string; + + /** Shape type */ + shape: 'rectangle' | 'circle' | 'ellipse' | 'polygon' | 'line'; + + /** Position */ + position: [number, number]; + + /** Shape-specific properties */ + width?: number; + height?: number; + radius?: number; + radiusX?: number; + radiusY?: number; + points?: number[]; + + /** Fill */ + fill?: { + color: Color; + alpha?: number; + }; + + /** Stroke */ + stroke?: { + color: Color; + width: number; + alpha?: number; + }; + + /** Animations */ + animations?: KeyframeAnimation[]; +} + +export interface PixiText { + id: string; + + /** Text content */ + text: string; + + /** Position */ + position: [number, number]; + + /** Style */ + style: { + fontFamily?: string; + fontSize?: number; + fontWeight?: string; + fill?: Color | string[]; + stroke?: Color; + strokeThickness?: number; + align?: 'left' | 'center' | 'right'; + wordWrap?: boolean; + wordWrapWidth?: number; + dropShadow?: boolean; + dropShadowColor?: Color; + dropShadowBlur?: number; + dropShadowAngle?: number; + dropShadowDistance?: number; + }; + + /** Anchor */ + anchor?: [number, number]; + + /** Animations */ + animations?: KeyframeAnimation[]; +} + +export type PixiFilter = + | { type: 'blur'; strength?: number } + | { type: 'glow'; color?: Color; distance?: number; outerStrength?: number } + | { type: 'pixelate'; size?: number } + | { type: 'colorMatrix'; matrix?: number[] } + | { type: 'displacement'; texture: string; scale?: [number, number] } + | { type: 'noise'; noise?: number; seed?: number } + | { type: 'oldFilm'; sepia?: number; noise?: number; scratch?: number } + | { type: 'crt'; curvature?: number; lineWidth?: number; lineContrast?: number } + | { type: 'glitch'; slices?: number; offset?: number; direction?: number }; +``` + +### Implementation + +```typescript +// packages/renderer-browser/src/layers/PixiLayer.tsx + +import * as PIXI from 'pixi.js'; +import { useEffect, useRef } from 'react'; + +export function PixiLayer({ layer, frame, fps }: PixiLayerProps) { + const canvasRef = useRef(null); + const appRef = useRef(null); + const spritesRef = useRef>(new Map()); + + useEffect(() => { + if (!canvasRef.current) return; + + // Initialize PixiJS + const app = new PIXI.Application({ + view: canvasRef.current, + width: layer.size.width, + height: layer.size.height, + backgroundColor: layer.props.backgroundColor || 0x000000, + antialias: true, + resolution: window.devicePixelRatio || 1, + }); + + appRef.current = app; + + // Load textures and create sprites + initializeScene(app, layer.props); + + return () => { + app.destroy(true); + }; + }, []); + + useEffect(() => { + if (!appRef.current) return; + + // Update animations + updateAnimations(appRef.current, spritesRef.current, frame, fps); + + // Render frame + appRef.current.render(); + }, [frame]); + + return ( + + ); +} + +async function initializeScene(app: PIXI.Application, props: PixiLayerProps) { + // Load textures + const textures = new Map(); + + for (const sprite of props.sprites || []) { + if (!textures.has(sprite.texture)) { + const texture = await PIXI.Texture.fromURL(sprite.texture); + textures.set(sprite.texture, texture); + } + } + + // Create sprites + for (const spriteConfig of props.sprites || []) { + const texture = textures.get(spriteConfig.texture)!; + const sprite = new PIXI.Sprite(texture); + + sprite.position.set(...spriteConfig.position); + sprite.anchor.set(...(spriteConfig.anchor || [0.5, 0.5])); + + if (typeof spriteConfig.scale === 'number') { + sprite.scale.set(spriteConfig.scale); + } else if (spriteConfig.scale) { + sprite.scale.set(...spriteConfig.scale); + } + + if (spriteConfig.rotation) { + sprite.rotation = spriteConfig.rotation; + } + + if (spriteConfig.alpha !== undefined) { + sprite.alpha = spriteConfig.alpha; + } + + if (spriteConfig.tint) { + sprite.tint = PIXI.utils.string2hex(spriteConfig.tint); + } + + app.stage.addChild(sprite); + } + + // Create tilemaps + for (const tilemapConfig of props.tilemaps || []) { + const tilemap = await createTilemap(tilemapConfig, textures); + app.stage.addChild(tilemap); + } + + // Apply filters + if (props.filters && props.filters.length > 0) { + app.stage.filters = props.filters.map(createFilter); + } +} +``` + +## Implementation Checklist + +### Phase 1: Core Package Setup +- [ ] Create PixiJS layer type in `@rendervid/core` +- [ ] Add `pixi.js` dependency to renderer packages +- [ ] Create type definitions + +### Phase 2: Basic Rendering +- [ ] Implement `PixiLayer` component +- [ ] Support sprite rendering +- [ ] Support texture loading +- [ ] Support basic transformations (position, rotation, scale) +- [ ] Support blend modes and tint + +### Phase 3: Sprite Animations +- [ ] Implement sprite sheet parsing +- [ ] Implement animation playback +- [ ] Support multiple animations per sprite +- [ ] Support animation events + +### Phase 4: Tilemaps +- [ ] Implement tilemap rendering +- [ ] Support multiple layers +- [ ] Support collision detection +- [ ] Optimize rendering (culling) + +### Phase 5: 2D Particles +- [ ] Implement particle emitters +- [ ] Support particle forces +- [ ] Optimize for thousands of particles +- [ ] Support texture atlases + +### Phase 6: Filters +- [ ] Implement blur filter +- [ ] Implement glow filter +- [ ] Implement pixelate filter +- [ ] Implement CRT/retro filters +- [ ] Implement glitch filter +- [ ] Support custom filters + +### Phase 7: 2D Physics (Matter.js) +- [ ] Integrate Matter.js +- [ ] Sync sprites with physics bodies +- [ ] Support collision detection +- [ ] Support joints and constraints + +### Phase 8: Testing +- [ ] Unit tests for sprite rendering +- [ ] Test animations +- [ ] Test tilemaps +- [ ] Test particles +- [ ] Performance tests + +### Phase 9: Documentation & Examples +- [ ] PixiJS layer guide +- [ ] Example: Platformer scene +- [ ] Example: Arcade game +- [ ] Example: Pixel art animation +- [ ] Example: Retro CRT effect +- [ ] Example: 2D particle effects + +## API Design + +### Basic Sprite + +```json +{ + "type": "pixi", + "props": { + "backgroundColor": "#1a1a2e", + "sprites": [ + { + "id": "character", + "texture": "character.png", + "position": [400, 300], + "scale": 2, + "animations": [ + { + "property": "position.x", + "keyframes": [ + { "frame": 0, "value": 100 }, + { "frame": 120, "value": 700, "easing": "easeInOutQuad" } + ] + } + ] + } + ] + } +} +``` + +### Sprite Sheet Animation + +```json +{ + "sprites": [{ + "id": "player", + "texture": "spritesheet.png", + "position": [400, 300], + "animation": { + "frames": [ + { "x": 0, "y": 0, "width": 32, "height": 32 }, + { "x": 32, "y": 0, "width": 32, "height": 32 }, + { "x": 64, "y": 0, "width": 32, "height": 32 } + ], + "animations": { + "walk": { "frames": [0, 1, 2], "speed": 10, "loop": true }, + "idle": { "frames": [0], "speed": 1, "loop": true } + }, + "current": "walk" + } + }] +} +``` + +### Tilemap + +```json +{ + "tilemaps": [{ + "id": "level1", + "tileset": "tiles.png", + "tileSize": [32, 32], + "position": [0, 0], + "map": [ + [1, 1, 1, 1, 1, 1, 1, 1], + [1, 0, 0, 0, 0, 0, 0, 1], + [1, 0, 2, 0, 0, 3, 0, 1], + [1, 1, 1, 1, 1, 1, 1, 1] + ] + }] +} +``` + +### Particles + +```json +{ + "particles": [{ + "id": "explosion", + "texture": "particle.png", + "emitter": { + "type": "point", + "position": [400, 300], + "burst": [{ "frame": 60, "count": 100 }] + }, + "particle": { + "lifetime": 1, + "speed": { "min": 50, "max": 200 }, + "angle": { "min": 0, "max": 6.28 }, + "scale": { "start": 1, "end": 0 }, + "alpha": { "start": 1, "end": 0 } + }, + "forces": [ + { "type": "gravity", "strength": 100 } + ] + }] +} +``` + +### Filters + +```json +{ + "filters": [ + { "type": "pixelate", "size": 4 }, + { "type": "crt", "curvature": 3, "lineWidth": 1 }, + { "type": "glow", "color": "#00ff00", "distance": 10 } + ] +} +``` + +## Dependencies +None (independent layer type) + +## Acceptance Criteria +- [ ] PixiJS renders correctly +- [ ] Sprite animations work +- [ ] Tilemaps render efficiently +- [ ] Particles perform well (1000+) +- [ ] All filters work +- [ ] Works in browser and Node.js +- [ ] All tests pass +- [ ] Documentation complete +- [ ] At least 5 example templates + +## Related Issues +- #GAMING-009 (2D physics with Matter.js) + +## Notes +- Consider Aseprite file format support +- Add sprite packing tool integration +- Support for Tiled map editor format +- Consider WebGL shader support for custom effects diff --git a/.github/issues/gaming-009-matter-physics.md b/.github/issues/gaming-009-matter-physics.md new file mode 100644 index 0000000..a07d533 --- /dev/null +++ b/.github/issues/gaming-009-matter-physics.md @@ -0,0 +1,556 @@ +# [GAMING-009] Matter.js 2D Physics Integration + +## Overview +Integrate Matter.js physics engine for 2D physics simulation in PixiJS layers, enabling realistic 2D game mechanics. + +## Motivation +2D physics is essential for platformer-style videos, arcade games, and realistic 2D motion. Matter.js is lightweight, well-maintained, and perfect for 2D game physics. + +## Technical Approach + +Integrate Matter.js with PixiJS layer, syncing physics bodies with sprite positions and rotations. + +### Type Extensions + +```typescript +// packages/core/src/types/pixi.ts + +export interface PixiLayerProps { + // ... existing properties + + /** 2D physics configuration */ + physics?: { + enabled: boolean; + gravity?: { x: number; y: number }; + timestep?: number; + velocityIterations?: number; + positionIterations?: number; + }; +} + +export interface PixiSprite { + // ... existing properties + + /** Physics rigid body */ + rigidBody?: { + type: 'dynamic' | 'static' | 'kinematic'; + mass?: number; + friction?: number; + frictionAir?: number; + frictionStatic?: number; + restitution?: number; + density?: number; + velocity?: [number, number]; + angularVelocity?: number; + collisionFilter?: { + category?: number; + mask?: number; + group?: number; + }; + }; + + /** Collider shape */ + collider?: { + type: 'rectangle' | 'circle' | 'polygon' | 'fromSprite'; + width?: number; + height?: number; + radius?: number; + vertices?: Array<[number, number]>; + isSensor?: boolean; + }; + + /** Collision events */ + collisionEvents?: { + onCollisionStart?: CollisionAction[]; + onCollisionEnd?: CollisionAction[]; + }; +} + +export interface PixiConstraint { + id: string; + type: 'distance' | 'spring' | 'revolute' | 'prismatic'; + bodyA: string; + bodyB: string; + pointA?: [number, number]; + pointB?: [number, number]; + length?: number; + stiffness?: number; + damping?: number; +} +``` + +### Implementation + +```typescript +// packages/physics/src/engines/matter2d/MatterPhysicsEngine.ts + +import Matter from 'matter-js'; + +export class MatterPhysicsEngine implements PhysicsEngine2D { + private engine: Matter.Engine; + private world: Matter.World; + private bodies = new Map(); + private constraints = new Map(); + + constructor(config: PhysicsWorldConfig2D) { + this.engine = Matter.Engine.create({ + gravity: { + x: config.gravity?.x || 0, + y: config.gravity?.y || 1, + }, + }); + + this.world = this.engine.world; + + // Setup collision events + Matter.Events.on(this.engine, 'collisionStart', this.handleCollisionStart.bind(this)); + Matter.Events.on(this.engine, 'collisionEnd', this.handleCollisionEnd.bind(this)); + } + + async init(): Promise { + // Matter.js doesn't require async initialization + } + + step(deltaTime: number): void { + Matter.Engine.update(this.engine, deltaTime * 1000); + } + + createRigidBody(config: RigidBody2DConfig): RigidBody2D { + let body: Matter.Body; + + // Create body based on collider type + switch (config.collider.type) { + case 'rectangle': + body = Matter.Bodies.rectangle( + config.position[0], + config.position[1], + config.collider.width!, + config.collider.height!, + this.getBodyOptions(config) + ); + break; + + case 'circle': + body = Matter.Bodies.circle( + config.position[0], + config.position[1], + config.collider.radius!, + this.getBodyOptions(config) + ); + break; + + case 'polygon': + body = Matter.Bodies.fromVertices( + config.position[0], + config.position[1], + config.collider.vertices!, + this.getBodyOptions(config) + ); + break; + } + + // Set body type + if (config.type === 'static') { + Matter.Body.setStatic(body, true); + } else if (config.type === 'kinematic') { + Matter.Body.setStatic(body, true); + body.isStatic = false; // Kinematic hack + } + + // Set initial velocity + if (config.velocity) { + Matter.Body.setVelocity(body, { + x: config.velocity[0], + y: config.velocity[1], + }); + } + + if (config.angularVelocity) { + Matter.Body.setAngularVelocity(body, config.angularVelocity); + } + + Matter.World.add(this.world, body); + + const rigidBody = new MatterRigidBody(body, config.id); + this.bodies.set(config.id, body); + + return rigidBody; + } + + createConstraint(config: ConstraintConfig): Constraint2D { + const bodyA = this.bodies.get(config.bodyA); + const bodyB = this.bodies.get(config.bodyB); + + if (!bodyA || !bodyB) { + throw new Error('Bodies not found for constraint'); + } + + let constraint: Matter.Constraint; + + switch (config.type) { + case 'distance': + constraint = Matter.Constraint.create({ + bodyA, + bodyB, + pointA: config.pointA ? { x: config.pointA[0], y: config.pointA[1] } : undefined, + pointB: config.pointB ? { x: config.pointB[0], y: config.pointB[1] } : undefined, + length: config.length, + stiffness: config.stiffness || 1, + damping: config.damping || 0, + }); + break; + + case 'spring': + constraint = Matter.Constraint.create({ + bodyA, + bodyB, + pointA: config.pointA ? { x: config.pointA[0], y: config.pointA[1] } : undefined, + pointB: config.pointB ? { x: config.pointB[0], y: config.pointB[1] } : undefined, + stiffness: config.stiffness || 0.5, + damping: config.damping || 0.1, + }); + break; + + case 'revolute': + constraint = Matter.Constraint.create({ + bodyA, + bodyB, + pointA: config.pointA ? { x: config.pointA[0], y: config.pointA[1] } : { x: 0, y: 0 }, + pointB: config.pointB ? { x: config.pointB[0], y: config.pointB[1] } : { x: 0, y: 0 }, + length: 0, + stiffness: 1, + }); + break; + } + + Matter.World.add(this.world, constraint); + this.constraints.set(config.id, constraint); + + return new MatterConstraint(constraint, config.id); + } + + raycast(origin: [number, number], direction: [number, number], maxDistance: number): RaycastHit2D | null { + const endPoint = [ + origin[0] + direction[0] * maxDistance, + origin[1] + direction[1] * maxDistance, + ]; + + const collisions = Matter.Query.ray( + this.world.bodies, + { x: origin[0], y: origin[1] }, + { x: endPoint[0], y: endPoint[1] } + ); + + if (collisions.length === 0) return null; + + // Return closest collision + const closest = collisions[0]; + return { + point: [closest.body.position.x, closest.body.position.y], + normal: [0, 0], // Matter.js doesn't provide normal easily + distance: Math.hypot( + closest.body.position.x - origin[0], + closest.body.position.y - origin[1] + ), + body: new MatterRigidBody(closest.body, ''), + }; + } + + applyForce(bodyId: string, force: [number, number], point?: [number, number]): void { + const body = this.bodies.get(bodyId); + if (!body) return; + + Matter.Body.applyForce( + body, + point ? { x: point[0], y: point[1] } : body.position, + { x: force[0], y: force[1] } + ); + } + + private getBodyOptions(config: RigidBody2DConfig): Matter.IBodyDefinition { + return { + friction: config.friction, + frictionAir: config.frictionAir, + frictionStatic: config.frictionStatic, + restitution: config.restitution, + density: config.density, + mass: config.mass, + collisionFilter: config.collisionFilter, + isSensor: config.collider.isSensor, + }; + } + + private handleCollisionStart(event: Matter.IEventCollision): void { + for (const pair of event.pairs) { + // Emit collision events + this.emitCollisionEvent('start', pair); + } + } + + private handleCollisionEnd(event: Matter.IEventCollision): void { + for (const pair of event.pairs) { + this.emitCollisionEvent('end', pair); + } + } + + destroy(): void { + Matter.World.clear(this.world, false); + Matter.Engine.clear(this.engine); + } +} + +class MatterRigidBody implements RigidBody2D { + constructor(private body: Matter.Body, public id: string) {} + + getPosition(): [number, number] { + return [this.body.position.x, this.body.position.y]; + } + + getRotation(): number { + return this.body.angle; + } + + setPosition(position: [number, number]): void { + Matter.Body.setPosition(this.body, { x: position[0], y: position[1] }); + } + + setRotation(angle: number): void { + Matter.Body.setAngle(this.body, angle); + } + + setVelocity(velocity: [number, number]): void { + Matter.Body.setVelocity(this.body, { x: velocity[0], y: velocity[1] }); + } + + getVelocity(): [number, number] { + return [this.body.velocity.x, this.body.velocity.y]; + } + + applyForce(force: [number, number], point?: [number, number]): void { + Matter.Body.applyForce( + this.body, + point ? { x: point[0], y: point[1] } : this.body.position, + { x: force[0], y: force[1] } + ); + } + + applyImpulse(impulse: [number, number], point?: [number, number]): void { + // Matter.js doesn't have impulse, convert to force + const force = [impulse[0] / this.body.mass, impulse[1] / this.body.mass]; + this.applyForce(force as [number, number], point); + } + + setEnabled(enabled: boolean): void { + this.body.isSleeping = !enabled; + } + + destroy(): void { + // Handled by engine + } +} +``` + +### React Integration + +```typescript +// packages/renderer-browser/src/layers/pixi/PhysicsSprite.tsx + +export function PhysicsSprite({ + sprite, + physics, + pixiSprite, +}: PhysicsSpriteProps) { + const rigidBodyRef = useRef(null); + + useEffect(() => { + if (!physics || !sprite.rigidBody) return; + + const body = physics.createRigidBody({ + id: sprite.id, + type: sprite.rigidBody.type, + position: sprite.position, + collider: sprite.collider || { + type: 'rectangle', + width: pixiSprite.width, + height: pixiSprite.height, + }, + ...sprite.rigidBody, + }); + + rigidBodyRef.current = body; + + return () => { + body.destroy(); + }; + }, [physics, sprite]); + + useFrame(() => { + if (!rigidBodyRef.current) return; + + // Sync PixiJS sprite with physics body + const position = rigidBodyRef.current.getPosition(); + const rotation = rigidBodyRef.current.getRotation(); + + pixiSprite.position.set(...position); + pixiSprite.rotation = rotation; + }); + + return null; +} +``` + +## Implementation Checklist + +### Phase 1: Core Integration +- [ ] Add Matter.js to `@rendervid/physics` +- [ ] Implement `MatterPhysicsEngine` class +- [ ] Implement `MatterRigidBody` wrapper +- [ ] Implement collision detection + +### Phase 2: Body Types +- [ ] Support rectangle bodies +- [ ] Support circle bodies +- [ ] Support polygon bodies +- [ ] Support compound bodies +- [ ] Auto-generate colliders from sprites + +### Phase 3: Constraints +- [ ] Implement distance constraints +- [ ] Implement spring constraints +- [ ] Implement revolute joints +- [ ] Implement prismatic joints + +### Phase 4: Advanced Features +- [ ] Collision filtering (layers/groups) +- [ ] Sensors (trigger volumes) +- [ ] Sleeping/waking optimization +- [ ] Continuous collision detection + +### Phase 5: React Integration +- [ ] Create `PhysicsWorld2D` component +- [ ] Create `PhysicsSprite` component +- [ ] Sync sprites with physics +- [ ] Handle collision events + +### Phase 6: Testing +- [ ] Unit tests for physics engine +- [ ] Test all body types +- [ ] Test constraints +- [ ] Test collision detection +- [ ] Performance tests + +### Phase 7: Documentation & Examples +- [ ] 2D physics guide +- [ ] Example: Bouncing balls +- [ ] Example: Platformer physics +- [ ] Example: Angry Birds style +- [ ] Example: Chain/rope simulation +- [ ] Example: Ragdoll physics + +## API Design + +### Basic Physics + +```json +{ + "type": "pixi", + "props": { + "physics": { + "enabled": true, + "gravity": { "x": 0, "y": 1 } + }, + "sprites": [ + { + "id": "ball", + "texture": "ball.png", + "position": [400, 100], + "rigidBody": { + "type": "dynamic", + "mass": 1, + "restitution": 0.8, + "friction": 0.5 + }, + "collider": { + "type": "circle", + "radius": 25 + } + }, + { + "id": "ground", + "texture": "ground.png", + "position": [400, 550], + "rigidBody": { + "type": "static" + }, + "collider": { + "type": "rectangle", + "width": 800, + "height": 50 + } + } + ] + } +} +``` + +### Constraints + +```json +{ + "sprites": [ + { "id": "anchor", "position": [400, 100], "rigidBody": { "type": "static" } }, + { "id": "ball", "position": [400, 200], "rigidBody": { "type": "dynamic" } } + ], + "constraints": [ + { + "id": "rope", + "type": "distance", + "bodyA": "anchor", + "bodyB": "ball", + "length": 100, + "stiffness": 0.9 + } + ] +} +``` + +### Collision Events + +```json +{ + "sprites": [{ + "id": "projectile", + "rigidBody": { "type": "dynamic" }, + "collisionEvents": { + "onCollisionStart": [ + { "type": "spawnParticles", "particleId": "explosion", "count": 50 }, + { "type": "destroy", "delay": 0 } + ] + } + }] +} +``` + +## Dependencies +- #GAMING-008 (PixiJS layer) + +## Acceptance Criteria +- [ ] Physics simulation works correctly +- [ ] All body types supported +- [ ] Constraints work correctly +- [ ] Collision detection accurate +- [ ] Performance good (100+ bodies) +- [ ] Works in browser and Node.js +- [ ] All tests pass +- [ ] Documentation complete +- [ ] At least 5 example templates + +## Related Issues +- #GAMING-008 (PixiJS layer) +- #GAMING-003 (collision events pattern) + +## Notes +- Consider Box2D.js as alternative (more features, heavier) +- Add debug rendering (wireframes) +- Support for one-way platforms +- Consider soft body physics (future) diff --git a/.github/issues/gaming-010-behavior-presets.md b/.github/issues/gaming-010-behavior-presets.md new file mode 100644 index 0000000..4dfa1b5 --- /dev/null +++ b/.github/issues/gaming-010-behavior-presets.md @@ -0,0 +1,494 @@ +# [GAMING-010] Behavior Preset Library + +## Overview +Create a library of reusable, AI-friendly behavior presets that can be applied to meshes and sprites without custom scripting. + +## Motivation +Make game-like behaviors accessible to AI agents and non-programmers. Presets provide safe, tested, parameterized behaviors that work out of the box. + +## Technical Approach + +Create a behavior system where behaviors are JSON-configurable functions with parameters. Behaviors can be combined and chained. + +### Type Definitions + +```typescript +// packages/core/src/types/behaviors.ts + +export interface BehaviorConfig { + /** Behavior type */ + type: string; + + /** Behavior parameters */ + params?: Record; + + /** When to start (frame number) */ + startFrame?: number; + + /** When to end (frame number) */ + endFrame?: number; + + /** Loop behavior */ + loop?: boolean; + + /** Priority (higher = runs first) */ + priority?: number; +} + +export interface ThreeMeshConfig { + // ... existing properties + + /** Behavior presets */ + behaviors?: BehaviorConfig[]; +} + +export interface PixiSprite { + // ... existing properties + + /** Behavior presets */ + behaviors?: BehaviorConfig[]; +} +``` + +### Behavior Presets + +```typescript +// packages/behaviors/src/presets/index.ts + +export const BEHAVIOR_PRESETS = { + // Movement behaviors + orbit: { + description: 'Orbit around a point', + params: { + center: { type: 'vector3', default: [0, 0, 0] }, + radius: { type: 'number', default: 5 }, + speed: { type: 'number', default: 0.02 }, + axis: { type: 'vector3', default: [0, 1, 0] }, + startAngle: { type: 'number', default: 0 }, + }, + execute: (mesh, params, time) => { + const angle = params.startAngle + time.frame * params.speed; + const x = params.center[0] + Math.cos(angle) * params.radius; + const z = params.center[2] + Math.sin(angle) * params.radius; + mesh.setPosition([x, params.center[1], z]); + }, + }, + + follow: { + description: 'Follow another object', + params: { + target: { type: 'string', required: true }, + distance: { type: 'number', default: 2 }, + speed: { type: 'number', default: 0.1 }, + smoothing: { type: 'number', default: 0.1 }, + }, + execute: (mesh, params, time, scene) => { + const target = scene.getById(params.target); + if (!target) return; + + const direction = math.vec3.subtract(target.position, mesh.position); + const currentDistance = math.vec3.length(direction); + + if (currentDistance > params.distance) { + const normalized = math.vec3.normalize(direction); + const movement = math.vec3.multiply(normalized, params.speed); + const newPos = math.vec3.add(mesh.position, movement); + mesh.setPosition(newPos); + } + }, + }, + + lookAt: { + description: 'Always look at a target', + params: { + target: { type: 'string', required: true }, + axis: { type: 'vector3', default: [0, 1, 0] }, + }, + execute: (mesh, params, time, scene) => { + const target = scene.getById(params.target); + if (!target) return; + + // Calculate rotation to face target + const direction = math.vec3.subtract(target.position, mesh.position); + const angle = Math.atan2(direction[0], direction[2]); + mesh.setRotation([0, angle, 0]); + }, + }, + + bounce: { + description: 'Bounce up and down', + params: { + height: { type: 'number', default: 2 }, + speed: { type: 'number', default: 1 }, + easing: { type: 'string', default: 'easeInOutQuad' }, + }, + execute: (mesh, params, time) => { + const t = (Math.sin(time.frame * params.speed * 0.1) + 1) / 2; + const easedT = applyEasing(t, params.easing); + const y = mesh.position[1] + easedT * params.height; + mesh.setPosition([mesh.position[0], y, mesh.position[2]]); + }, + }, + + patrol: { + description: 'Move between waypoints', + params: { + waypoints: { type: 'array', required: true }, + speed: { type: 'number', default: 0.1 }, + loop: { type: 'boolean', default: true }, + pauseTime: { type: 'number', default: 30 }, + }, + state: { + currentWaypoint: 0, + pauseFrames: 0, + }, + execute: (mesh, params, time, scene, state) => { + if (state.pauseFrames > 0) { + state.pauseFrames--; + return; + } + + const target = params.waypoints[state.currentWaypoint]; + const distance = math.vec3.distance(mesh.position, target); + + if (distance < 0.1) { + state.currentWaypoint++; + if (state.currentWaypoint >= params.waypoints.length) { + if (params.loop) { + state.currentWaypoint = 0; + } else { + return; + } + } + state.pauseFrames = params.pauseTime; + } else { + const direction = math.vec3.normalize(math.vec3.subtract(target, mesh.position)); + const movement = math.vec3.multiply(direction, params.speed); + mesh.setPosition(math.vec3.add(mesh.position, movement)); + } + }, + }, + + // Physics behaviors + explodeOnImpact: { + description: 'Explode when collision impulse exceeds threshold', + params: { + impulseThreshold: { type: 'number', default: 5 }, + particleSystem: { type: 'string', required: true }, + particleCount: { type: 'number', default: 100 }, + destroyOnExplode: { type: 'boolean', default: true }, + }, + triggers: ['collision'], + execute: (mesh, params, time, scene, state, event) => { + if (event.type === 'collision' && event.impulse > params.impulseThreshold) { + scene.particles.burst(params.particleSystem, mesh.position, params.particleCount); + if (params.destroyOnExplode) { + mesh.destroy(); + } + } + }, + }, + + applyForceOnTrigger: { + description: 'Apply force when triggered', + params: { + triggerFrame: { type: 'number', required: true }, + force: { type: 'vector3', required: true }, + point: { type: 'vector3', default: null }, + }, + execute: (mesh, params, time, scene) => { + if (time.frame === params.triggerFrame) { + scene.physics.applyForce(mesh.id, params.force, params.point); + } + }, + }, + + // Animation behaviors + pulse: { + description: 'Pulse scale in and out', + params: { + minScale: { type: 'number', default: 0.8 }, + maxScale: { type: 'number', default: 1.2 }, + speed: { type: 'number', default: 1 }, + }, + execute: (mesh, params, time) => { + const t = (Math.sin(time.frame * params.speed * 0.1) + 1) / 2; + const scale = params.minScale + (params.maxScale - params.minScale) * t; + mesh.setScale([scale, scale, scale]); + }, + }, + + spin: { + description: 'Continuous rotation', + params: { + axis: { type: 'string', default: 'y' }, + speed: { type: 'number', default: 0.02 }, + }, + execute: (mesh, params, time) => { + const rotation = [...mesh.rotation]; + const axisIndex = { x: 0, y: 1, z: 2 }[params.axis]; + rotation[axisIndex] += params.speed; + mesh.setRotation(rotation); + }, + }, + + fadeInOut: { + description: 'Fade opacity in and out', + params: { + minOpacity: { type: 'number', default: 0 }, + maxOpacity: { type: 'number', default: 1 }, + speed: { type: 'number', default: 1 }, + }, + execute: (mesh, params, time) => { + const t = (Math.sin(time.frame * params.speed * 0.1) + 1) / 2; + const opacity = params.minOpacity + (params.maxOpacity - params.minOpacity) * t; + mesh.setMaterial({ opacity }); + }, + }, + + // Procedural behaviors + randomWalk: { + description: 'Random movement within bounds', + params: { + bounds: { type: 'vector3', default: [10, 10, 10] }, + speed: { type: 'number', default: 0.1 }, + changeInterval: { type: 'number', default: 60 }, + }, + state: { + direction: [0, 0, 0], + framesSinceChange: 0, + }, + execute: (mesh, params, time, scene, state) => { + state.framesSinceChange++; + + if (state.framesSinceChange >= params.changeInterval) { + state.direction = math.random.vector3([-1, -1, -1], [1, 1, 1]); + state.direction = math.vec3.normalize(state.direction); + state.framesSinceChange = 0; + } + + const movement = math.vec3.multiply(state.direction, params.speed); + let newPos = math.vec3.add(mesh.position, movement); + + // Clamp to bounds + newPos = [ + math.clamp(newPos[0], -params.bounds[0], params.bounds[0]), + math.clamp(newPos[1], -params.bounds[1], params.bounds[1]), + math.clamp(newPos[2], -params.bounds[2], params.bounds[2]), + ]; + + mesh.setPosition(newPos); + }, + }, + + flocking: { + description: 'Boids flocking behavior', + params: { + separationDistance: { type: 'number', default: 2 }, + alignmentDistance: { type: 'number', default: 5 }, + cohesionDistance: { type: 'number', default: 5 }, + separationWeight: { type: 'number', default: 1.5 }, + alignmentWeight: { type: 'number', default: 1.0 }, + cohesionWeight: { type: 'number', default: 1.0 }, + maxSpeed: { type: 'number', default: 0.2 }, + group: { type: 'string', default: 'flock' }, + }, + state: { + velocity: [0, 0, 0], + }, + execute: (mesh, params, time, scene, state) => { + const neighbors = scene.getAll().filter( + m => m.id !== mesh.id && m.behaviors?.some(b => b.type === 'flocking' && b.params.group === params.group) + ); + + // Separation, alignment, cohesion calculations + // ... (full boids algorithm) + + mesh.setPosition(math.vec3.add(mesh.position, state.velocity)); + }, + }, +}; +``` + +### Behavior System + +```typescript +// packages/behaviors/src/BehaviorSystem.ts + +export class BehaviorSystem { + private behaviors = new Map(); + private presets = BEHAVIOR_PRESETS; + + addBehavior(meshId: string, config: BehaviorConfig) { + const preset = this.presets[config.type]; + if (!preset) { + throw new Error(`Unknown behavior: ${config.type}`); + } + + const instance: BehaviorInstance = { + config, + preset, + state: preset.state ? { ...preset.state } : {}, + enabled: true, + }; + + if (!this.behaviors.has(meshId)) { + this.behaviors.set(meshId, []); + } + + this.behaviors.get(meshId)!.push(instance); + } + + update(frame: number, deltaTime: number, scene: SceneAPI) { + for (const [meshId, behaviors] of this.behaviors) { + const mesh = scene.getById(meshId); + if (!mesh) continue; + + // Sort by priority + behaviors.sort((a, b) => (b.config.priority || 0) - (a.config.priority || 0)); + + for (const behavior of behaviors) { + if (!behavior.enabled) continue; + + // Check frame range + if (behavior.config.startFrame && frame < behavior.config.startFrame) continue; + if (behavior.config.endFrame && frame > behavior.config.endFrame) continue; + + // Execute behavior + try { + behavior.preset.execute( + mesh, + behavior.config.params || {}, + { frame, deltaTime, elapsed: frame / 60 }, + scene, + behavior.state + ); + } catch (error) { + console.error(`Behavior ${behavior.config.type} error:`, error); + } + } + } + } + + handleEvent(meshId: string, event: any) { + const behaviors = this.behaviors.get(meshId); + if (!behaviors) return; + + for (const behavior of behaviors) { + if (behavior.preset.triggers?.includes(event.type)) { + behavior.preset.execute( + scene.getById(meshId), + behavior.config.params || {}, + { frame: event.frame, deltaTime: 0, elapsed: 0 }, + scene, + behavior.state, + event + ); + } + } + } +} +``` + +## Implementation Checklist + +### Phase 1: Core System +- [ ] Create `@rendervid/behaviors` package +- [ ] Implement `BehaviorSystem` class +- [ ] Add behavior registration +- [ ] Add behavior execution +- [ ] Add state management + +### Phase 2: Movement Behaviors +- [ ] Implement orbit +- [ ] Implement follow +- [ ] Implement lookAt +- [ ] Implement bounce +- [ ] Implement patrol +- [ ] Implement randomWalk + +### Phase 3: Physics Behaviors +- [ ] Implement explodeOnImpact +- [ ] Implement applyForceOnTrigger +- [ ] Implement attractToPoint +- [ ] Implement repelFromPoint + +### Phase 4: Animation Behaviors +- [ ] Implement pulse +- [ ] Implement spin +- [ ] Implement fadeInOut +- [ ] Implement colorCycle + +### Phase 5: Advanced Behaviors +- [ ] Implement flocking (boids) +- [ ] Implement pathfinding +- [ ] Implement state machine +- [ ] Implement procedural animation + +### Phase 6: Integration +- [ ] Integrate with Three.js layer +- [ ] Integrate with PixiJS layer +- [ ] Add to capabilities API +- [ ] Update MCP server + +### Phase 7: Testing +- [ ] Unit tests for each behavior +- [ ] Integration tests +- [ ] Test behavior combinations +- [ ] Performance tests + +### Phase 8: Documentation +- [ ] Behavior library reference +- [ ] Example for each behavior +- [ ] Behavior composition guide +- [ ] Custom behavior creation guide + +## API Design + +```json +{ + "meshes": [{ + "id": "satellite", + "geometry": { "type": "sphere" }, + "behaviors": [ + { + "type": "orbit", + "params": { + "center": [0, 0, 0], + "radius": 5, + "speed": 0.02 + } + }, + { + "type": "pulse", + "params": { + "minScale": 0.9, + "maxScale": 1.1, + "speed": 2 + } + } + ] + }] +} +``` + +## Dependencies +- #GAMING-002 (Three.js integration) +- #GAMING-008 (PixiJS integration) + +## Acceptance Criteria +- [ ] All listed behaviors implemented +- [ ] Behaviors can be combined +- [ ] State management works +- [ ] Performance is good +- [ ] All tests pass +- [ ] Documentation complete +- [ ] AI capabilities API updated + +## Related Issues +- #GAMING-007 (scripting for custom behaviors) + +## Notes +- Behaviors should be deterministic for reproducible renders +- Consider visual behavior editor (future) +- Add behavior marketplace/sharing (future) diff --git a/.github/issues/gaming-011-ai-capabilities.md b/.github/issues/gaming-011-ai-capabilities.md new file mode 100644 index 0000000..fa8c5ca --- /dev/null +++ b/.github/issues/gaming-011-ai-capabilities.md @@ -0,0 +1,502 @@ +# [GAMING-011] AI Capabilities API and MCP Integration + +## Overview +Update the capabilities API and MCP server to expose all gaming features to AI agents, with comprehensive documentation and examples. + +## Motivation +AI agents need to discover and understand gaming features to generate game-style videos. The capabilities API must be self-describing and include all new gaming features. + +## Technical Approach + +Extend existing capabilities API with gaming features, update MCP server tools, and create AI-friendly documentation. + +### Capabilities API Extensions + +```typescript +// packages/core/src/capabilities/gaming.ts + +export function getGamingCapabilities(): GamingCapabilities { + return { + physics: { + '3d': { + engine: 'rapier', + features: { + rigidBodies: ['dynamic', 'static', 'kinematic'], + colliders: ['cuboid', 'sphere', 'capsule', 'cylinder', 'cone', 'trimesh'], + joints: ['fixed', 'revolute', 'prismatic', 'spherical'], + forces: ['force', 'impulse', 'torque'], + collision: { + events: true, + filtering: true, + ccd: true, + }, + }, + limits: { + maxBodies: 10000, + maxColliders: 10000, + recommendedBodies: 500, + }, + }, + '2d': { + engine: 'matter', + features: { + rigidBodies: ['dynamic', 'static', 'kinematic'], + colliders: ['rectangle', 'circle', 'polygon'], + constraints: ['distance', 'spring', 'revolute', 'prismatic'], + forces: ['force', 'impulse'], + collision: { + events: true, + filtering: true, + sensors: true, + }, + }, + limits: { + maxBodies: 5000, + recommendedBodies: 300, + }, + }, + }, + + particles: { + '3d': { + maxCount: 100000, + recommendedCount: 10000, + emitters: ['point', 'sphere', 'box', 'cone', 'mesh'], + forces: ['gravity', 'wind', 'turbulence', 'attraction', 'vortex'], + features: { + collision: true, + textureAtlas: true, + softParticles: true, + trails: false, + }, + }, + '2d': { + maxCount: 50000, + recommendedCount: 5000, + emitters: ['point', 'rectangle', 'circle'], + forces: ['gravity', 'wind'], + features: { + textureAtlas: true, + trails: true, + }, + }, + }, + + postProcessing: { + effects: [ + { + name: 'bloom', + description: 'Glow effect for bright areas', + params: { + intensity: { type: 'number', min: 0, max: 10, default: 1 }, + threshold: { type: 'number', min: 0, max: 1, default: 0.9 }, + radius: { type: 'number', min: 0, max: 1, default: 0.85 }, + }, + }, + { + name: 'depthOfField', + description: 'Focus blur effect', + params: { + focusDistance: { type: 'number', min: 0, max: 1, default: 0.5 }, + focalLength: { type: 'number', min: 0, max: 1, default: 0.02 }, + bokehScale: { type: 'number', min: 0, max: 10, default: 2 }, + }, + }, + // ... all other effects + ], + }, + + animations: { + keyframes: { + properties: [ + 'position', 'position.x', 'position.y', 'position.z', + 'rotation', 'rotation.x', 'rotation.y', 'rotation.z', + 'scale', 'scale.x', 'scale.y', 'scale.z', + 'material.color', 'material.opacity', 'material.metalness', 'material.roughness', + 'camera.fov', 'camera.position', 'camera.rotation', + 'light.intensity', 'light.color', 'light.position', + ], + easingFunctions: [ + 'linear', + 'easeIn', 'easeOut', 'easeInOut', + 'easeInQuad', 'easeOutQuad', 'easeInOutQuad', + 'easeInCubic', 'easeOutCubic', 'easeInOutCubic', + 'easeInElastic', 'easeOutElastic', 'easeInOutElastic', + 'easeInBounce', 'easeOutBounce', 'easeInOutBounce', + // ... all easing functions + ], + loopModes: ['none', 'repeat', 'pingpong'], + }, + }, + + behaviors: { + presets: Object.entries(BEHAVIOR_PRESETS).map(([name, preset]) => ({ + name, + description: preset.description, + params: preset.params, + triggers: preset.triggers || [], + category: categorize(name), + })), + categories: ['movement', 'physics', 'animation', 'procedural', 'ai'], + }, + + scripting: { + enabled: true, + language: 'javascript', + hooks: ['onInit', 'onFrame', 'onDestroy', 'onCollision'], + api: { + scene: ['getById', 'getAll', 'add', 'remove', 'getCamera', 'getLights'], + physics: ['applyForce', 'applyImpulse', 'setVelocity', 'getVelocity', 'raycast'], + particles: ['emit', 'burst'], + math: { + vec3: ['add', 'subtract', 'multiply', 'dot', 'cross', 'normalize', 'length', 'distance'], + random: ['float', 'int', 'choice', 'vector3'], + noise: ['perlin', 'simplex'], + utils: ['lerp', 'clamp', 'map'], + }, + state: ['get', 'set', 'has'], + }, + limits: { + timeout: 100, + memoryMB: 50, + }, + security: { + sandbox: true, + allowedGlobals: ['Math', 'Array', 'Object', 'JSON'], + blockedGlobals: ['require', 'process', 'eval', 'Function'], + }, + }, + + layers: { + three: { + geometries: ['box', 'sphere', 'cylinder', 'cone', 'torus', 'plane', 'gltf', 'text3d'], + materials: ['basic', 'standard', 'physical', 'toon', 'shader'], + lights: ['ambient', 'directional', 'point', 'spot', 'hemisphere'], + features: ['shadows', 'fog', 'toneMapping', 'antialias'], + }, + pixi: { + objects: ['sprite', 'tilemap', 'graphic', 'text'], + animations: ['spriteSheet', 'keyframe'], + filters: ['blur', 'glow', 'pixelate', 'crt', 'glitch', 'oldFilm'], + features: ['blendModes', 'masks', 'tint'], + }, + }, + }; +} +``` + +### MCP Server Tools + +```typescript +// mcp/src/tools/gaming.ts + +export const gamingTools = [ + { + name: 'create_physics_scene', + description: 'Create a 3D scene with physics simulation', + inputSchema: { + type: 'object', + properties: { + objects: { + type: 'array', + description: 'Objects with physics properties', + items: { + type: 'object', + properties: { + geometry: { type: 'object' }, + position: { type: 'array' }, + rigidBody: { type: 'object' }, + collider: { type: 'object' }, + }, + }, + }, + gravity: { + type: 'array', + description: 'Gravity vector [x, y, z]', + default: [0, -9.81, 0], + }, + }, + }, + }, + + { + name: 'add_particle_effect', + description: 'Add particle system to scene', + inputSchema: { + type: 'object', + properties: { + type: { + type: 'string', + enum: ['explosion', 'fire', 'smoke', 'magic', 'confetti', 'custom'], + }, + position: { type: 'array' }, + count: { type: 'number' }, + customConfig: { type: 'object' }, + }, + }, + }, + + { + name: 'apply_post_processing', + description: 'Add cinematic post-processing effects', + inputSchema: { + type: 'object', + properties: { + effects: { + type: 'array', + items: { + type: 'object', + properties: { + type: { type: 'string' }, + params: { type: 'object' }, + }, + }, + }, + }, + }, + }, + + { + name: 'add_behavior', + description: 'Add preset behavior to object', + inputSchema: { + type: 'object', + properties: { + objectId: { type: 'string' }, + behavior: { + type: 'string', + enum: Object.keys(BEHAVIOR_PRESETS), + }, + params: { type: 'object' }, + }, + }, + }, + + { + name: 'create_2d_game_scene', + description: 'Create 2D game-style scene with PixiJS', + inputSchema: { + type: 'object', + properties: { + sprites: { type: 'array' }, + physics: { type: 'object' }, + filters: { type: 'array' }, + }, + }, + }, + + { + name: 'get_gaming_examples', + description: 'Get example templates for gaming features', + inputSchema: { + type: 'object', + properties: { + category: { + type: 'string', + enum: ['physics', 'particles', 'postProcessing', 'behaviors', '2d', 'all'], + }, + }, + }, + }, +]; +``` + +### AI-Friendly Documentation + +```markdown +// docs/ai-guide/gaming-features.md + +# Gaming Features for AI Agents + +## Quick Start + +Generate game-style videos using physics, particles, and behaviors. + +### Physics Scene (3D) + +```json +{ + "type": "three", + "props": { + "physics": { "enabled": true, "gravity": [0, -9.81, 0] }, + "meshes": [ + { + "id": "ball", + "geometry": { "type": "sphere", "radius": 0.5 }, + "position": [0, 5, 0], + "rigidBody": { "type": "dynamic", "mass": 1, "restitution": 0.8 }, + "collider": { "type": "sphere" } + }, + { + "id": "ground", + "geometry": { "type": "plane", "width": 20, "height": 20 }, + "position": [0, 0, 0], + "rotation": [-1.5708, 0, 0], + "rigidBody": { "type": "static" }, + "collider": { "type": "cuboid" } + } + ] + } +} +``` + +### Particle Effects + +Common presets: +- **explosion**: Burst of particles with gravity +- **fire**: Rising particles with turbulence +- **smoke**: Slow-moving particles with fade +- **magic**: Colorful particles with attraction +- **confetti**: Falling particles with rotation + +### Behaviors + +Apply preset behaviors without scripting: + +```json +{ + "behaviors": [ + { "type": "orbit", "params": { "radius": 5, "speed": 0.02 } }, + { "type": "pulse", "params": { "minScale": 0.9, "maxScale": 1.1 } } + ] +} +``` + +### Post-Processing + +Add cinematic effects: + +```json +{ + "postProcessing": { + "bloom": { "intensity": 2, "threshold": 0.8 }, + "depthOfField": { "focusDistance": 0.5, "bokehScale": 3 } + } +} +``` + +## Use Cases + +### Product Drop Video +- Physics: Dynamic product falling onto static surface +- Particles: Impact particles on collision +- Post-processing: Bloom for dramatic effect +- Behavior: Camera orbit around product + +### Explosion Effect +- Particles: Large burst with gravity and turbulence +- Physics: Fragments flying outward +- Post-processing: Motion blur and chromatic aberration +- Behavior: Camera shake + +### 2D Platformer Scene +- PixiJS: Sprite animations and tilemaps +- Physics: 2D collision and jumping +- Filters: Pixelate for retro look +- Behavior: Patrol for enemy movement + +## Best Practices + +1. **Start simple**: Use behaviors before custom scripts +2. **Performance**: Keep particle counts reasonable (<10k for 3D) +3. **Determinism**: Avoid random() in scripts for reproducible renders +4. **Composition**: Combine multiple behaviors for complex effects +5. **Testing**: Use debug visualization to verify physics + +## Common Patterns + +### Chain Reaction +```json +{ + "meshes": [ + { + "id": "domino1", + "rigidBody": { "type": "dynamic" }, + "behaviors": [ + { "type": "applyForceOnTrigger", "params": { "triggerFrame": 30, "force": [10, 0, 0] } } + ] + } + ] +} +``` + +### Following Camera +```json +{ + "camera": { + "behaviors": [ + { "type": "follow", "params": { "target": "player", "distance": 5, "smoothing": 0.1 } } + ] + } +} +``` + +### Procedural Animation +```json +{ + "scripts": { + "hooks": { + "onFrame": "for (let i = 0; i < 10; i++) { const mesh = scene.getById('particle_' + i); const offset = Math.sin(time.frame * 0.1 + i) * 2; mesh.setPosition([i * 2, offset, 0]); }" + } + } +} +``` +``` + +## Implementation Checklist + +### Phase 1: Capabilities API +- [ ] Extend capabilities with gaming features +- [ ] Add detailed parameter documentation +- [ ] Include limits and recommendations +- [ ] Add feature detection + +### Phase 2: MCP Server +- [ ] Add gaming-specific tools +- [ ] Update existing tools for gaming features +- [ ] Add example retrieval +- [ ] Add validation + +### Phase 3: Documentation +- [ ] Create AI guide for gaming features +- [ ] Document all behaviors with examples +- [ ] Create use case library +- [ ] Add troubleshooting guide + +### Phase 4: Examples +- [ ] Create 20+ gaming example templates +- [ ] Cover all major features +- [ ] Include common patterns +- [ ] Add complexity levels (beginner to advanced) + +### Phase 5: Testing +- [ ] Test AI agent generation +- [ ] Validate all examples +- [ ] Test MCP tools +- [ ] Performance benchmarks + +### Phase 6: Skills Documentation +- [ ] Generate skills docs for gaming features +- [ ] Update skills registry +- [ ] Add to MCP server metadata + +## Dependencies +- All previous gaming issues (#GAMING-001 through #GAMING-010) + +## Acceptance Criteria +- [ ] Capabilities API complete and accurate +- [ ] MCP server tools work correctly +- [ ] AI guide is comprehensive +- [ ] 20+ example templates created +- [ ] All examples validated +- [ ] Skills documentation generated +- [ ] AI agents can successfully generate gaming videos + +## Related Issues +- All gaming issues (#GAMING-001 through #GAMING-010) + +## Notes +- Keep documentation concise and example-driven +- Focus on common use cases +- Provide copy-paste ready examples +- Include performance guidelines diff --git a/.github/issues/gaming-012-editor-support.md b/.github/issues/gaming-012-editor-support.md new file mode 100644 index 0000000..5ee90f7 --- /dev/null +++ b/.github/issues/gaming-012-editor-support.md @@ -0,0 +1,778 @@ +# [GAMING-012] Template Editor Support for Gaming Features + +## Overview +Extend the `@rendervid/editor` package to provide UI controls for editing all gaming features: physics, particles, post-processing, animations, behaviors, and scripting. + +## Motivation +Users need a visual interface to configure gaming features without writing JSON manually. The editor should provide intuitive controls for physics properties, particle emitters, behavior parameters, and animation keyframes. + +## Technical Approach + +Extend existing editor components with new panels and controls for gaming features. Use progressive disclosure to avoid overwhelming users. + +### Architecture + +``` +Editor +├── LayerPanel (existing) +│ ├── ThreeLayerEditor (enhanced) +│ │ ├── PhysicsPanel (NEW) +│ │ ├── ParticlesPanel (NEW) +│ │ ├── PostProcessingPanel (NEW) +│ │ └── AnimationsPanel (enhanced) +│ └── PixiLayerEditor (NEW) +│ ├── SpriteEditor +│ ├── TilemapEditor +│ ├── Physics2DPanel +│ └── FiltersPanel +├── BehaviorsPanel (NEW) +├── ScriptsPanel (NEW) +└── PropertiesPanel (enhanced) +``` + +### UI Components + +```typescript +// packages/editor/src/panels/PhysicsPanel.tsx + +export function PhysicsPanel({ layer, onChange }: PhysicsPanelProps) { + const physics = layer.props.physics; + + return ( + + onChange({ ...layer, props: { ...layer.props, physics: { ...physics, enabled } } })} + /> + + {physics?.enabled && ( + <> + onChange({ ...layer, props: { ...layer.props, physics: { ...physics, gravity } } })} + /> + + onChange({ ...layer, props: { ...layer.props, physics: { ...physics, timestep } } })} + /> + + onChange({ ...layer, props: { ...layer.props, physics: { ...physics, debug } } })} + /> + + )} + + ); +} + +// packages/editor/src/panels/MeshPhysicsPanel.tsx + +export function MeshPhysicsPanel({ mesh, onChange }: MeshPhysicsPanelProps) { + const rigidBody = mesh.rigidBody; + + return ( + + onChange({ ...particle, emitter: { ...particle.emitter, type } })} + /> + + onChange({ ...particle, emitter: { ...particle.emitter, position } })} + /> + + {/* Emitter-specific controls */} + {particle.emitter.type === 'sphere' && ( + onChange({ ...particle, emitter: { ...particle.emitter, radius } })} + /> + )} + + {/* Particle properties */} + + + + onChange({ ...particle, particle: { ...particle.particle, lifetime } })} + /> + + onChange({ ...particle, particle: { ...particle.particle, color } })} + /> + + {/* Forces */} + + + onChange({ ...particle, forces })} + /> + + ); +} + +// packages/editor/src/panels/PostProcessingPanel.tsx + +export function PostProcessingPanel({ layer, onChange }: PostProcessingPanelProps) { + const postProcessing = layer.props.postProcessing || {}; + + return ( + + + + { + if (enabled) { + onChange({ ...layer, props: { ...layer.props, postProcessing: { ...postProcessing, bloom: { intensity: 1 } } } }); + } else { + const { bloom, ...rest } = postProcessing; + onChange({ ...layer, props: { ...layer.props, postProcessing: rest } }); + } + }} + /> + + {postProcessing.bloom && ( + <> + onChange({ ...layer, props: { ...layer.props, postProcessing: { ...postProcessing, bloom: { ...postProcessing.bloom, intensity } } } })} + /> + + onChange({ ...layer, props: { ...layer.props, postProcessing: { ...postProcessing, bloom: { ...postProcessing.bloom, threshold } } } })} + /> + + )} + + + + {/* Similar controls for DOF */} + + + + {/* Similar controls for motion blur */} + + + + {/* Similar controls for glitch */} + + + + {/* Other effects */} + + + + ); +} + +// packages/editor/src/panels/BehaviorsPanel.tsx + +export function BehaviorsPanel({ mesh, onChange }: BehaviorsPanelProps) { + const behaviors = mesh.behaviors || []; + + return ( + + { + const newAnimation: KeyframeAnimation = { + property, + keyframes: [ + { frame: 0, value: 0 }, + { frame: 60, value: 1 }, + ], + }; + onChange({ ...mesh, animations: [...animations, newAnimation] }); + }} + /> + + {animations.map((animation, index) => ( + { + const newAnimations = [...animations]; + newAnimations[index] = updated; + onChange({ ...mesh, animations: newAnimations }); + }} + onDelete={() => { + const newAnimations = animations.filter((_, i) => i !== index); + onChange({ ...mesh, animations: newAnimations }); + }} + /> + ))} + + ); +} + +// packages/editor/src/panels/KeyframeAnimationEditor.tsx + +export function KeyframeAnimationEditor({ animation, onChange, onDelete }: KeyframeAnimationEditorProps) { + return ( + + + {animation.property} + + + + onChange({ ...animation, keyframes })} + /> + + + + {animation.keyframes.map((keyframe, index) => ( + { + const newKeyframes = [...animation.keyframes]; + newKeyframes[index] = updated; + onChange({ ...animation, keyframes: newKeyframes }); + }} + onDelete={() => { + if (animation.keyframes.length > 2) { + const newKeyframes = animation.keyframes.filter((_, i) => i !== index); + onChange({ ...animation, keyframes: newKeyframes }); + } + }} + /> + ))} + + + + + + + + + + + +
+ + + + diff --git a/mcp/src/index.ts b/mcp/src/index.ts index 1dc4f31..f1188d0 100644 --- a/mcp/src/index.ts +++ b/mcp/src/index.ts @@ -18,13 +18,9 @@ import { executeListRenderJobs, } from './tools/render_video_async.js'; import { validateTemplateTool, executeValidateTemplate } from './tools/validate_template.js'; -import { getCapabilitiesTool, executeGetCapabilities } from './tools/get_capabilities.js'; -import { listExamplesTool, executeListExamples } from './tools/list_examples.js'; +import { getDocsTool, executeGetDocs } from './tools/get_docs.js'; import { getExampleTool, executeGetExample } from './tools/get_example.js'; -import { getComponentDocsTool, executeGetComponentDocs } from './tools/get_component_docs.js'; import { getComponentDefaultsTool, executeGetComponentDefaults } from './tools/get_component_defaults.js'; -import { getAnimationDocsTool, executeGetAnimationDocs } from './tools/get_animation_docs.js'; -import { getEasingDocsTool, executeGetEasingDocs } from './tools/get_easing_docs.js'; const logger = createLogger('mcp-server'); @@ -66,13 +62,9 @@ class RendervidMcpServer { listRenderJobsTool, renderImageTool, validateTemplateTool, - getCapabilitiesTool, - listExamplesTool, + getDocsTool, getExampleTool, - getComponentDocsTool, getComponentDefaultsTool, - getAnimationDocsTool, - getEasingDocsTool, ], }; }); @@ -111,34 +103,18 @@ class RendervidMcpServer { result = await executeValidateTemplate(args); break; - case 'get_capabilities': - result = await executeGetCapabilities(); - break; - - case 'list_examples': - result = await executeListExamples(args); + case 'get_docs': + result = await executeGetDocs(args); break; case 'get_example': result = await executeGetExample(args); break; - case 'get_component_docs': - result = await executeGetComponentDocs(args); - break; - case 'get_component_defaults': result = await executeGetComponentDefaults(args); break; - case 'get_animation_docs': - result = await executeGetAnimationDocs(args); - break; - - case 'get_easing_docs': - result = await executeGetEasingDocs(args); - break; - default: throw new Error(`Unknown tool: ${name}`); } @@ -243,12 +219,9 @@ class RendervidMcpServer { 'render_video', 'render_image', 'validate_template', - 'get_capabilities', - 'list_examples', + 'get_docs', 'get_example', - 'get_component_docs', - 'get_animation_docs', - 'get_easing_docs', + 'get_component_defaults', ], }); } diff --git a/mcp/src/tools/get_animation_docs.ts b/mcp/src/tools/get_animation_docs.ts deleted file mode 100644 index fcdc51f..0000000 --- a/mcp/src/tools/get_animation_docs.ts +++ /dev/null @@ -1,333 +0,0 @@ -import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { createLogger } from '../utils/logger.js'; - -const logger = createLogger('get_animation_docs'); - -const GetAnimationDocsInputSchema = z.object({ - animationType: z.string().optional().describe('Animation type: "entrance", "exit", "emphasis", or "all"'), - effect: z.string().optional().describe('Specific animation effect name (optional)'), -}); - -export const getAnimationDocsTool = { - name: 'get_animation_docs', - description: `Get detailed documentation for Rendervid animation effects. - -Use this when you need to know what animation effects are available and how to use them. - -Parameters: -- animationType: "entrance", "exit", "emphasis", or "all" (default: "all") -- effect: Get detailed docs for a specific effect (optional) - -Examples: -- get_animation_docs({ animationType: "entrance" }) -- get_animation_docs({ effect: "fadeIn" }) -- get_animation_docs({}) - lists all animations`, - inputSchema: zodToJsonSchema(GetAnimationDocsInputSchema), -}; - -const ANIMATION_DOCS = { - entrance: { - description: 'Animations that introduce elements into the scene', - effects: { - fadeIn: { - description: 'Fade in from transparent to opaque', - duration: '15-30 frames typical', - easing: 'easeOutCubic recommended', - }, - fadeInUp: { - description: 'Fade in while moving up', - duration: '20-30 frames typical', - easing: 'easeOutCubic recommended', - }, - fadeInDown: { - description: 'Fade in while moving down', - duration: '20-30 frames typical', - easing: 'easeOutCubic recommended', - }, - slideInUp: { - description: 'Slide in from bottom', - duration: '20-40 frames typical', - easing: 'easeOutCubic or easeOutBack recommended', - }, - slideInDown: { - description: 'Slide in from top', - duration: '20-40 frames typical', - easing: 'easeOutCubic or easeOutBack recommended', - }, - slideInLeft: { - description: 'Slide in from left', - duration: '20-40 frames typical', - easing: 'easeOutCubic recommended', - }, - slideInRight: { - description: 'Slide in from right', - duration: '20-40 frames typical', - easing: 'easeOutCubic recommended', - }, - scaleIn: { - description: 'Scale up from zero', - duration: '15-25 frames typical', - easing: 'easeOutBack for bounce effect', - }, - zoomIn: { - description: 'Zoom in with slight rotation', - duration: '20-30 frames typical', - easing: 'easeOutCubic recommended', - }, - rotateIn: { - description: 'Rotate in while fading', - duration: '25-40 frames typical', - easing: 'easeOutBack recommended', - }, - bounceIn: { - description: 'Bounce in with spring effect', - duration: '30-50 frames typical', - easing: 'easeOutBack or easeOutBounce recommended', - }, - }, - example: { - type: 'entrance', - effect: 'fadeIn', - delay: 30, - duration: 20, - easing: 'easeOutCubic', - }, - }, - exit: { - description: 'Animations that remove elements from the scene', - effects: { - fadeOut: { - description: 'Fade out from opaque to transparent', - duration: '15-30 frames typical', - easing: 'easeInCubic recommended', - }, - fadeOutUp: { - description: 'Fade out while moving up', - duration: '20-30 frames typical', - easing: 'easeInCubic recommended', - }, - fadeOutDown: { - description: 'Fade out while moving down', - duration: '20-30 frames typical', - easing: 'easeInCubic recommended', - }, - slideOutUp: { - description: 'Slide out to top', - duration: '20-40 frames typical', - easing: 'easeInCubic recommended', - }, - slideOutDown: { - description: 'Slide out to bottom', - duration: '20-40 frames typical', - easing: 'easeInCubic recommended', - }, - slideOutLeft: { - description: 'Slide out to left', - duration: '20-40 frames typical', - easing: 'easeInCubic recommended', - }, - slideOutRight: { - description: 'Slide out to right', - duration: '20-40 frames typical', - easing: 'easeInCubic recommended', - }, - scaleOut: { - description: 'Scale down to zero', - duration: '15-25 frames typical', - easing: 'easeInBack for anticipation', - }, - zoomOut: { - description: 'Zoom out with slight rotation', - duration: '20-30 frames typical', - easing: 'easeInCubic recommended', - }, - rotateOut: { - description: 'Rotate out while fading', - duration: '25-40 frames typical', - easing: 'easeInBack recommended', - }, - }, - example: { - type: 'exit', - effect: 'fadeOut', - delay: 120, - duration: 20, - easing: 'easeInCubic', - }, - }, - emphasis: { - description: 'Animations that draw attention to existing elements', - effects: { - pulse: { - description: 'Scale up and down repeatedly', - duration: '30-60 frames typical', - easing: 'easeInOutCubic recommended', - }, - shake: { - description: 'Shake horizontally', - duration: '20-40 frames typical', - easing: 'linear recommended', - }, - bounce: { - description: 'Bounce up and down', - duration: '30-60 frames typical', - easing: 'easeOutBounce recommended', - }, - swing: { - description: 'Swing like a pendulum', - duration: '40-60 frames typical', - easing: 'easeInOutSine recommended', - }, - wobble: { - description: 'Wobble rotation back and forth', - duration: '30-50 frames typical', - easing: 'easeInOutSine recommended', - }, - flash: { - description: 'Flash opacity on and off', - duration: '20-40 frames typical', - easing: 'linear recommended', - }, - rubberBand: { - description: 'Stretch and squash like rubber', - duration: '40-60 frames typical', - easing: 'easeInOutCubic recommended', - }, - heartbeat: { - description: 'Pulse with heartbeat rhythm', - duration: '60-90 frames typical', - easing: 'easeInOutQuad recommended', - }, - float: { - description: 'Float up and down smoothly', - duration: '60-120 frames typical', - easing: 'easeInOutSine recommended', - }, - spin: { - description: 'Continuous rotation', - duration: '60-120 frames typical', - easing: 'linear recommended', - }, - }, - example: { - type: 'emphasis', - effect: 'pulse', - delay: 60, - duration: 40, - easing: 'easeInOutCubic', - }, - }, -}; - -export async function executeGetAnimationDocs(args: unknown): Promise { - try { - const input = GetAnimationDocsInputSchema.parse(args); - const animationType = input.animationType?.toLowerCase() || 'all'; - const effect = input.effect?.toLowerCase(); - - logger.info('Getting animation documentation', { animationType, effect }); - - // If specific effect requested, find it - if (effect) { - for (const [type, data] of Object.entries(ANIMATION_DOCS)) { - if (data.effects[effect as keyof typeof data.effects]) { - const effectData = data.effects[effect as keyof typeof data.effects] as any; - return JSON.stringify({ - effect, - type, - description: effectData.description, - duration: effectData.duration, - easing: effectData.easing, - usage: { - type, - effect, - delay: 30, - duration: 20, - easing: 'easeOutCubic', - }, - notes: [ - `Delay and duration are in FRAMES (at 30fps: 30 frames = 1 second)`, - `Typical delay: 0-60 frames (0-2 seconds)`, - `Combine with easing functions for smooth motion`, - ], - }, null, 2); - } - } - - return JSON.stringify({ - error: `Unknown animation effect: ${effect}`, - suggestion: 'Use get_animation_docs({ animationType: "entrance" }) to list available effects', - }, null, 2); - } - - // Return all or specific type - if (animationType === 'all') { - const summary = { - entrance: Object.keys(ANIMATION_DOCS.entrance.effects), - exit: Object.keys(ANIMATION_DOCS.exit.effects), - emphasis: Object.keys(ANIMATION_DOCS.emphasis.effects), - }; - - return JSON.stringify({ - animations: summary, - total: { - entrance: summary.entrance.length, - exit: summary.exit.length, - emphasis: summary.emphasis.length, - }, - usage: { - structure: { - type: 'entrance | exit | emphasis', - effect: 'fadeIn | slideUp | pulse | ...', - delay: 'number (frames)', - duration: 'number (frames)', - easing: 'easeOutCubic | linear | ...', - }, - example: ANIMATION_DOCS.entrance.example, - notes: [ - 'Add animations array to any layer', - 'Multiple animations can be applied to one layer', - 'Timing is in FRAMES: 30 frames = 1 second at 30fps', - 'Use get_animation_docs({ effect: "fadeIn" }) for specific effect details', - ], - }, - }, null, 2); - } - - const typeData = ANIMATION_DOCS[animationType as keyof typeof ANIMATION_DOCS]; - if (!typeData) { - return JSON.stringify({ - error: `Unknown animation type: ${animationType}`, - available: ['entrance', 'exit', 'emphasis', 'all'], - }, null, 2); - } - - return JSON.stringify({ - type: animationType, - description: typeData.description, - effects: typeData.effects, - example: typeData.example, - notes: [ - 'Delay and duration are in FRAMES (at 30fps: 30 frames = 1 second)', - 'Combine with easing functions for smooth motion', - `Use get_animation_docs({ effect: "fadeIn" }) for specific effect details`, - ], - }, null, 2); - } catch (error) { - logger.error('Failed to get animation docs', { error }); - - if (error instanceof z.ZodError) { - return JSON.stringify({ - error: 'Invalid input', - details: error.errors, - }, null, 2); - } - - const errorMessage = error instanceof Error ? error.message : String(error); - return JSON.stringify({ - error: 'Failed to get animation documentation', - details: errorMessage, - }, null, 2); - } -} diff --git a/mcp/src/tools/get_capabilities.ts b/mcp/src/tools/get_capabilities.ts deleted file mode 100644 index 30a9956..0000000 --- a/mcp/src/tools/get_capabilities.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { RendervidEngine } from '@rendervid/core'; -import { createLogger } from '../utils/logger.js'; - -const logger = createLogger('get_capabilities'); - -export const getCapabilitiesTool = { - name: 'get_capabilities', - description: `Discover all available features and capabilities of the Rendervid engine. - -This tool returns a comprehensive overview of what Rendervid can do, including: - -**Layer Types:** -- text: Rich text with typography, fonts, alignment -- image: Display images with fit options (cover, contain, fill) -- video: Play video clips with timing controls -- shape: Rectangles, ellipses, polygons, stars, paths -- audio: Background music and sound effects -- group: Container for organizing layers -- lottie: Lottie animations -- gif: Animated GIF images with frame-synced playback -- caption: Subtitles/captions with SRT/VTT parsing -- custom: Custom React components (create new components with inline React code!) - -**Scene & Layer Visibility:** -- Scenes and layers support a "hidden" property (boolean) -- hidden: true — the scene/layer is skipped during rendering -- Useful for temporarily disabling content without deleting it -- Example: { "id": "scene-1", "hidden": true, ... } or { "id": "layer-1", "hidden": true, ... } - -**Animation Presets (40+):** -- Entrance: fadeIn, slideIn, zoomIn, bounceIn, rotateIn, etc. -- Exit: fadeOut, slideOut, zoomOut, bounceOut, rotateOut, etc. -- Emphasis: pulse, shake, bounce, swing, wobble, flash, etc. - -**Easing Functions (30+):** -- Linear, ease, easeIn, easeOut, easeInOut -- Cubic bezier variants (Quad, Cubic, Quart, Quint, Sine, Expo, Circ, Back, Elastic, Bounce) -- Custom cubic-bezier and spring functions - -**Output Formats:** -- Video: MP4, WebM, MOV, GIF -- Image: PNG, JPEG, WebP -- Codecs: H.264, H.265, VP8, VP9, AV1, ProRes - -**Styling Features:** -- Blend modes (multiply, screen, overlay, etc.) -- Filters (blur, brightness, grayscale, etc.) -- Fonts (built-in, Google Fonts, custom fonts) -- Tailwind CSS support - -Use this to understand what's possible when creating templates, especially for AI-generated content. -The capabilities object includes detailed schemas and examples for each element type.`, - inputSchema: { - type: 'object', - properties: {}, - }, -}; - -export async function executeGetCapabilities(): Promise { - try { - logger.info('Getting capabilities'); - - // Create engine instance - const engine = new RendervidEngine(); - - // Get capabilities - const capabilities = engine.getCapabilities(); - - // Format for better readability - const formatted = { - version: capabilities.version, - runtime: capabilities.runtime, - - elements: Object.entries(capabilities.elements).map(([type, info]) => ({ - type, - description: info.description, - category: info.category, - animatable: info.animatable, - allowChildren: info.allowChildren, - exampleProps: info.example, - })), - - animations: { - entrance: capabilities.animations.entrance, - exit: capabilities.animations.exit, - emphasis: capabilities.animations.emphasis, - total: capabilities.animations.entrance.length + - capabilities.animations.exit.length + - capabilities.animations.emphasis.length, - }, - - easings: { - available: capabilities.easings, - count: capabilities.easings.length, - }, - - styling: { - blendModes: capabilities.blendModes, - filters: capabilities.filters, - }, - - fonts: { - builtin: capabilities.fonts.builtin, - supportsGoogleFonts: capabilities.fonts.googleFonts, - supportsCustomFonts: capabilities.fonts.customFonts, - }, - - output: { - video: { - formats: capabilities.output.video.formats, - codecs: capabilities.output.video.codecs, - maxResolution: `${capabilities.output.video.maxWidth}x${capabilities.output.video.maxHeight}`, - maxDuration: `${capabilities.output.video.maxDuration}s`, - maxFps: capabilities.output.video.maxFps, - }, - image: { - formats: capabilities.output.image.formats, - maxResolution: `${capabilities.output.image.maxWidth}x${capabilities.output.image.maxHeight}`, - }, - }, - - features: capabilities.features, - }; - - logger.info('Capabilities retrieved', { - elementCount: formatted.elements.length, - animationCount: formatted.animations.total, - easingCount: formatted.easings.count, - }); - - return JSON.stringify(formatted, null, 2); - } catch (error) { - logger.error('Failed to get capabilities', { error }); - - const errorMessage = error instanceof Error ? error.message : String(error); - - return JSON.stringify({ - error: 'Failed to get capabilities', - details: errorMessage, - }, null, 2); - } -} diff --git a/mcp/src/tools/get_component_docs.ts b/mcp/src/tools/get_component_docs.ts deleted file mode 100644 index bfe2012..0000000 --- a/mcp/src/tools/get_component_docs.ts +++ /dev/null @@ -1,750 +0,0 @@ -import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { createLogger } from '../utils/logger.js'; - -const logger = createLogger('get_component_docs'); - -const GetComponentDocsInputSchema = z.object({ - componentType: z.string().describe('The component/layer type to get documentation for'), -}); - -export const getComponentDocsTool = { - name: 'get_component_docs', - description: `Get detailed documentation for a specific Rendervid component/layer type. - -Use this tool when you need detailed information about how to use a specific component. -This is more token-efficient than loading all documentation at once. - -Available component types: -- text: Text layers with custom styling -- image: Image layers with various fit modes -- shape: Geometric shapes (rectangle, ellipse, triangle, star, polygon) -- video: Video layers with playback control -- audio: Audio layers for background music/effects -- gif: Animated GIF images with frame-synced playback -- caption: Subtitles/captions with SRT/VTT support -- custom: Custom React components - -Common properties available on ALL layer types: -- hidden: boolean (optional) — Hide the layer from rendering without deleting it -- locked: boolean (optional) — Lock the layer in the editor (prevents accidental edits) - -Scenes also support: -- hidden: boolean (optional) — Hide entire scene from rendering - -Example: get_component_docs({ componentType: "text" })`, - inputSchema: zodToJsonSchema(GetComponentDocsInputSchema), -}; - -const COMPONENT_DOCS = { - text: { - type: 'text', - description: 'Display text with customizable styling', - required: ['id', 'type', 'position', 'size', 'props'], - props: { - text: { - type: 'string', - required: true, - description: 'The text content to display. Use {{variableName}} for dynamic values', - example: '{{title}}', - }, - fontSize: { - type: 'number', - required: false, - default: 16, - description: 'Font size in pixels', - example: 48, - }, - fontFamily: { - type: 'string', - required: false, - default: 'Inter', - description: 'Font family name. Supports Google Fonts and system fonts', - example: 'Inter, Roboto, Poppins, Montserrat, Open Sans', - }, - fontWeight: { - type: 'string | number', - required: false, - default: 'normal', - description: 'Font weight: "normal", "bold", or numeric (100-900)', - example: 'bold', - }, - color: { - type: 'string', - required: false, - default: '#000000', - description: 'Text color in hex, rgb, rgba, or CSS color name', - example: '#ffffff', - }, - textAlign: { - type: 'string', - required: false, - default: 'left', - description: 'Text alignment: "left", "center", "right", "justify"', - example: 'center', - }, - lineHeight: { - type: 'number', - required: false, - default: 1.2, - description: 'Line height multiplier', - example: 1.5, - }, - letterSpacing: { - type: 'number', - required: false, - default: 0, - description: 'Letter spacing in pixels', - example: 2, - }, - textTransform: { - type: 'string', - required: false, - description: 'Text transformation: "uppercase", "lowercase", "capitalize"', - example: 'uppercase', - }, - textShadow: { - type: 'string | object', - required: false, - description: 'CSS text-shadow or object with color, blur, offsetX, offsetY', - example: '2px 2px 4px rgba(0,0,0,0.5)', - }, - opacity: { - type: 'number', - required: false, - default: 1, - description: 'Opacity from 0 to 1', - example: 0.8, - }, - }, - example: { - id: 'title', - type: 'text', - position: { x: 160, y: 440 }, - size: { width: 1600, height: 200 }, - props: { - text: '{{title}}', - fontSize: 72, - fontWeight: 'bold', - color: '#ffffff', - textAlign: 'center', - letterSpacing: 4, - }, - animations: [ - { - type: 'entrance', - effect: 'fadeIn', - delay: 30, - duration: 20, - }, - ], - }, - }, - - image: { - type: 'image', - description: 'Display an image with various fit modes', - required: ['id', 'type', 'position', 'size', 'props'], - props: { - src: { - type: 'string', - required: true, - description: 'Image URL (http/https) or local file path', - example: 'https://example.com/image.jpg', - }, - fit: { - type: 'string', - required: false, - default: 'contain', - description: 'How to fit the image: "contain" (fit inside), "cover" (fill), "fill" (stretch)', - example: 'cover', - }, - opacity: { - type: 'number', - required: false, - default: 1, - description: 'Image opacity from 0 to 1', - example: 0.7, - }, - borderRadius: { - type: 'number', - required: false, - default: 0, - description: 'Border radius in pixels for rounded corners', - example: 16, - }, - }, - example: { - id: 'background-image', - type: 'image', - position: { x: 0, y: 0 }, - size: { width: 1920, height: 1080 }, - props: { - src: 'https://example.com/background.jpg', - fit: 'cover', - opacity: 0.3, - }, - }, - }, - - shape: { - type: 'shape', - description: 'Display geometric shapes', - required: ['id', 'type', 'position', 'size', 'props'], - props: { - shape: { - type: 'string', - required: true, - description: 'Shape type: "rectangle", "ellipse", "triangle", "star", "polygon"', - example: 'rectangle', - }, - fill: { - type: 'string', - required: false, - description: 'Fill color in hex, rgb, rgba, or CSS color name. Can also be a gradient object', - example: '#2563eb', - }, - stroke: { - type: 'string', - required: false, - description: 'Stroke/border color', - example: '#ffffff', - }, - strokeWidth: { - type: 'number', - required: false, - default: 0, - description: 'Stroke width in pixels', - example: 2, - }, - gradient: { - type: 'object', - required: false, - description: 'Gradient fill with type ("linear"|"radial") and colors array', - example: { - type: 'linear', - angle: 45, - colors: [ - { offset: 0, color: '#667eea' }, - { offset: 1, color: '#764ba2' }, - ], - }, - }, - borderRadius: { - type: 'number', - required: false, - default: 0, - description: 'Border radius for rectangles', - example: 16, - }, - sides: { - type: 'number', - required: false, - description: 'Number of sides for polygons', - example: 6, - }, - points: { - type: 'number', - required: false, - description: 'Number of points for stars', - example: 5, - }, - }, - example: { - id: 'background', - type: 'shape', - position: { x: 0, y: 0 }, - size: { width: 1920, height: 1080 }, - props: { - shape: 'rectangle', - gradient: { - type: 'linear', - angle: 135, - colors: [ - { offset: 0, color: '#667eea' }, - { offset: 1, color: '#764ba2' }, - ], - }, - }, - }, - }, - - video: { - type: 'video', - description: 'Display video with playback control', - required: ['id', 'type', 'position', 'size', 'props'], - props: { - src: { - type: 'string', - required: true, - description: 'Video URL (http/https) or local file path', - example: 'https://example.com/video.mp4', - }, - volume: { - type: 'number', - required: false, - default: 1, - description: 'Volume from 0 to 1', - example: 0.5, - }, - loop: { - type: 'boolean', - required: false, - default: false, - description: 'Whether to loop the video', - example: true, - }, - fit: { - type: 'string', - required: false, - default: 'contain', - description: 'How to fit the video: "contain", "cover", "fill"', - example: 'cover', - }, - startTime: { - type: 'number', - required: false, - default: 0, - description: 'Start playback at this time (seconds)', - example: 5, - }, - }, - example: { - id: 'background-video', - type: 'video', - position: { x: 0, y: 0 }, - size: { width: 1920, height: 1080 }, - props: { - src: 'https://example.com/background.mp4', - fit: 'cover', - volume: 0.3, - loop: true, - }, - }, - }, - - audio: { - type: 'audio', - description: 'Add audio/music to the video', - required: ['id', 'type', 'props'], - props: { - src: { - type: 'string', - required: true, - description: 'Audio URL (http/https) or local file path', - example: 'https://example.com/music.mp3', - }, - volume: { - type: 'number', - required: false, - default: 1, - description: 'Volume from 0 to 1', - example: 0.5, - }, - loop: { - type: 'boolean', - required: false, - default: false, - description: 'Whether to loop the audio', - example: true, - }, - startTime: { - type: 'number', - required: false, - default: 0, - description: 'Start playback at this time (seconds)', - example: 2, - }, - fadeIn: { - type: 'number', - required: false, - description: 'Fade in duration in seconds', - example: 1, - }, - fadeOut: { - type: 'number', - required: false, - description: 'Fade out duration in seconds', - example: 2, - }, - }, - example: { - id: 'background-music', - type: 'audio', - props: { - src: 'https://example.com/background-music.mp3', - volume: 0.3, - loop: true, - fadeIn: 1, - fadeOut: 2, - }, - }, - note: 'Audio layers do not need position or size properties', - }, - - gif: { - type: 'gif', - description: 'Display animated GIF images synced to the video timeline', - required: ['id', 'type', 'position', 'size', 'props'], - props: { - src: { - type: 'string', - required: true, - description: 'GIF source URL or data URI', - example: 'https://example.com/animation.gif', - }, - fit: { - type: 'string', - required: false, - default: 'cover', - description: 'How to fit the GIF: "cover", "contain", "fill", "none"', - example: 'contain', - }, - loop: { - type: 'boolean', - required: false, - default: true, - description: 'Whether to loop the GIF animation', - example: true, - }, - speed: { - type: 'number', - required: false, - default: 1, - description: 'Playback speed multiplier (1 = normal)', - example: 1.5, - }, - }, - example: { - id: 'animated-sticker', - type: 'gif', - position: { x: 100, y: 100 }, - size: { width: 300, height: 300 }, - props: { - src: 'https://example.com/sticker.gif', - fit: 'contain', - loop: true, - speed: 1, - }, - }, - }, - - caption: { - type: 'caption', - description: 'Display subtitles/captions with SRT or VTT format support. Cues appear and disappear based on timestamps synced to the video timeline.', - required: ['id', 'type', 'position', 'size', 'props'], - props: { - content: { - type: 'string', - required: false, - description: 'Raw subtitle content in SRT, VTT, or plain text format', - example: '1\n00:00:01,000 --> 00:00:04,000\nHello World\n\n2\n00:00:05,000 --> 00:00:08,000\nWelcome to the video', - }, - format: { - type: 'string', - required: false, - description: 'Subtitle format: "srt", "vtt", or "plain" (auto-detected if omitted)', - example: 'srt', - }, - cues: { - type: 'array', - required: false, - description: 'Pre-parsed cue array (alternative to content string). Each cue has startTime, endTime (seconds), and text.', - example: [ - { startTime: 1, endTime: 4, text: 'Hello World' }, - { startTime: 5, endTime: 8, text: 'Welcome' }, - ], - }, - fontSize: { - type: 'number', - required: false, - default: 32, - description: 'Font size in pixels', - example: 48, - }, - color: { - type: 'string', - required: false, - default: '#ffffff', - description: 'Text color', - example: '#ffffff', - }, - backgroundColor: { - type: 'string', - required: false, - default: 'rgba(0, 0, 0, 0.75)', - description: 'Background color behind the caption text', - example: 'rgba(0, 0, 0, 0.75)', - }, - textAlign: { - type: 'string', - required: false, - default: 'center', - description: 'Text alignment: "left", "center", "right"', - example: 'center', - }, - fontFamily: { - type: 'string', - required: false, - default: 'sans-serif', - description: 'Font family', - example: 'Inter, sans-serif', - }, - }, - example: { - id: 'subtitles', - type: 'caption', - position: { x: 0, y: 800 }, - size: { width: 1920, height: 280 }, - props: { - content: '1\n00:00:01,000 --> 00:00:04,000\nHello World\n\n2\n00:00:05,000 --> 00:00:08,000\nWelcome to the video', - format: 'srt', - fontSize: 48, - color: '#ffffff', - backgroundColor: 'rgba(0, 0, 0, 0.75)', - textAlign: 'center', - }, - }, - }, - - custom: { - type: 'custom', - description: `Use custom React components for advanced effects and animations. - -IMPORTANT: You can create NEW custom components using THREE methods: - -1. **inline** - Write React code directly in the template (RECOMMENDED for AI agents) -2. **url** - Load components from a CDN or URL -3. **reference** - Use pre-registered components - -Custom components enable effects that built-in components cannot achieve: -- Animated counters and timers -- Particle systems and physics -- Data visualizations and charts -- 3D effects and transformations -- Procedural graphics and SVG animations -- Complex frame-based animations`, - required: ['id', 'type', 'position', 'size', 'customComponent'], - - howToDefineComponents: { - description: `Define custom components in the template's "customComponents" field at the root level:`, - - method1_inline: { - type: 'inline', - description: 'Write React code as a string in the template. Best for AI-generated components.', - structure: { - customComponents: { - 'ComponentName': { - type: 'inline', - code: 'function ComponentName(props) { /* React code here */ return React.createElement(...); }', - description: 'Optional description of what the component does' - } - } - }, - exampleTemplate: { - name: 'Animated Counter Example', - customComponents: { - 'AnimatedCounter': { - type: 'inline', - code: 'function AnimatedCounter(props) { const progress = Math.min(props.frame / (props.fps * 2), 1); const value = Math.floor(props.from + (props.to - props.from) * progress); return React.createElement("div", { style: { fontSize: "72px", fontWeight: "bold", color: "#00ffff" } }, value); }', - description: 'Animated number counter with easing' - } - }, - output: { type: 'video', width: 1920, height: 1080, fps: 30, duration: 3 }, - composition: { - scenes: [{ - layers: [{ - id: 'counter', - type: 'custom', - position: { x: 960, y: 540 }, - size: { width: 400, height: 200 }, - customComponent: { - name: 'AnimatedCounter', - props: { from: 0, to: 100 } - } - }] - }] - } - }, - componentInterface: { - description: 'Every custom component receives these props automatically:', - props: { - frame: 'Current frame number (0, 1, 2, ...)', - fps: 'Frames per second (e.g., 30, 60)', - sceneDuration: 'Total frames in the scene', - layerSize: { width: 'Layer width in pixels', height: 'Layer height in pixels' }, - yourCustomProps: 'Any props you define in customComponent.props' - } - }, - rules: [ - 'Use React.createElement() to create elements (no JSX)', - 'Components must be deterministic (same frame = same output)', - 'Calculate animations based on props.frame', - 'No side effects (fetch, setTimeout, setInterval)', - 'No external state or imports', - 'Return a single React element' - ] - }, - - method2_url: { - type: 'url', - description: 'Load component from a CDN or HTTPS URL', - structure: { - customComponents: { - 'MyChart': { - type: 'url', - url: 'https://cdn.example.com/MyChart.js' - } - } - }, - note: 'Must use HTTPS. Component file must export as default or named export.' - }, - - method3_reference: { - type: 'reference', - description: 'Reference a pre-registered component from the built-in library', - structure: { - customComponents: { - 'Particles': { - type: 'reference', - reference: 'particle-system' - } - } - }, - availableBuiltInComponents: [ - 'particle-system', - 'typewriter-effect', - 'glitch-effect', - 'three-scene', - 'svg-drawing', - 'metaballs', - 'aurora-background', - 'wave-background' - ] - } - }, - - customComponent: { - name: { - type: 'string', - required: true, - description: 'Name of the component defined in customComponents field', - example: 'AnimatedCounter', - }, - props: { - type: 'object', - required: false, - description: 'Custom props to pass to the component (in addition to auto props)', - example: { from: 0, to: 100, color: '#00ffff' }, - }, - }, - - fullWorkingExample: { - description: 'Complete template with inline custom component (ready to render)', - template: { - name: 'Fast Clock Example', - output: { type: 'video', width: 1920, height: 1080, fps: 60, duration: 5 }, - customComponents: { - 'FastClock': { - type: 'inline', - code: 'function FastClock(props) { const time = (props.frame / props.fps) * (props.speed || 1); const seconds = Math.floor(time % 60); const angle = seconds * 6; const rad = (angle - 90) * Math.PI / 180; const cx = props.layerSize.width / 2; const cy = props.layerSize.height / 2; const length = Math.min(cx, cy) * 0.8; const x2 = cx + Math.cos(rad) * length; const y2 = cy + Math.sin(rad) * length; return React.createElement("svg", { width: props.layerSize.width, height: props.layerSize.height }, React.createElement("circle", { cx: cx, cy: cy, r: Math.min(cx, cy) * 0.9, fill: "transparent", stroke: props.color || "#fff", strokeWidth: 4 }), React.createElement("line", { x1: cx, y1: cy, x2: x2, y2: y2, stroke: "#ff0000", strokeWidth: 3 })); }', - description: 'Animated analog clock with rotating second hand' - } - }, - composition: { - scenes: [{ - layers: [{ - id: 'clock', - type: 'custom', - position: { x: 960, y: 540 }, - size: { width: 400, height: 400 }, - customComponent: { - name: 'FastClock', - props: { speed: 10, color: '#ffffff' } - } - }] - }] - } - } - }, - - example: { - description: 'Using a custom component in a layer', - id: 'my-custom-element', - type: 'custom', - position: { x: 960, y: 540 }, - size: { width: 800, height: 400 }, - customComponent: { - name: 'AnimatedCounter', - props: { - from: 0, - to: 100, - color: '#00ffff' - } - } - }, - - importantNotes: [ - '✅ You CAN create new components with inline React code', - '✅ Components are defined in template.customComponents (root level)', - '✅ Use type: "inline" to write React code directly in the template', - '✅ Components receive frame, fps, sceneDuration, layerSize automatically', - '✅ Same component can be used multiple times with different props', - '⚠️ Components must be deterministic (frame-based, no randomness)', - '⚠️ Use React.createElement(), not JSX syntax', - '⚠️ No external dependencies or imports allowed in inline code' - ] - }, -}; - -export async function executeGetComponentDocs(args: unknown): Promise { - try { - const input = GetComponentDocsInputSchema.parse(args); - const componentType = input.componentType.toLowerCase(); - - logger.info('Getting component documentation', { componentType }); - - const docs = COMPONENT_DOCS[componentType as keyof typeof COMPONENT_DOCS]; - - if (!docs) { - const available = Object.keys(COMPONENT_DOCS).join(', '); - return JSON.stringify({ - error: `Unknown component type: ${componentType}`, - available: available, - suggestion: `Use one of: ${available}`, - }, null, 2); - } - - logger.info('Component documentation retrieved', { componentType }); - - const response: any = { - componentType: docs.type, - description: docs.description, - required: docs.required, - example: docs.example, - }; - - // Add optional fields if they exist - if ('props' in docs) response.props = docs.props; - if ('customComponent' in docs) response.customComponent = docs.customComponent; - if ('note' in docs) response.note = docs.note; - if ('availableComponents' in docs) response.availableComponents = docs.availableComponents; - - return JSON.stringify(response, null, 2); - } catch (error) { - logger.error('Failed to get component docs', { error }); - - if (error instanceof z.ZodError) { - return JSON.stringify({ - error: 'Invalid input', - details: error.errors, - }, null, 2); - } - - const errorMessage = error instanceof Error ? error.message : String(error); - return JSON.stringify({ - error: 'Failed to get component documentation', - details: errorMessage, - }, null, 2); - } -} diff --git a/mcp/src/tools/get_docs.ts b/mcp/src/tools/get_docs.ts new file mode 100644 index 0000000..17f5840 --- /dev/null +++ b/mcp/src/tools/get_docs.ts @@ -0,0 +1,65 @@ +import { getDocumentation, getDocumentationTopics } from '@rendervid/core'; +import { createLogger } from '../utils/logger.js'; + +const logger = createLogger('get_docs'); + +const availableTopics = [ + 'overview', 'template', 'scene', 'layer', + 'layer/text', 'layer/image', 'layer/video', 'layer/shape', + 'layer/audio', 'layer/group', 'layer/lottie', 'layer/gif', + 'layer/caption', 'layer/canvas', 'layer/three', 'layer/custom', + 'animations', 'easings', 'transitions', 'filters', + 'fonts', 'style', 'motion-blur', 'inputs', +]; + +export const getDocsTool = { + name: 'get_docs', + description: `Get documentation for a specific Rendervid topic. Returns structured docs with properties, types, defaults, and examples. + +Start with "overview" for a summary, then drill into specific topics as needed. + +Topics: ${availableTopics.join(', ')}`, + inputSchema: { + type: 'object', + properties: { + topic: { + type: 'string', + description: `Documentation topic. Available: ${availableTopics.join(', ')}`, + enum: availableTopics, + }, + }, + required: ['topic'], + }, +}; + +export async function executeGetDocs(args: unknown): Promise { + try { + const { topic } = args as { topic?: string }; + + if (!topic) { + return JSON.stringify({ + error: 'Missing required parameter: topic', + availableTopics: getDocumentationTopics(), + hint: 'Start with get_docs({ topic: "overview" }) for a summary of all capabilities.', + }, null, 2); + } + + logger.info('Getting docs', { topic }); + + const docs = getDocumentation(topic); + + logger.info('Docs retrieved', { topic, title: docs.title }); + + return JSON.stringify(docs, null, 2); + } catch (error) { + logger.error('Failed to get docs', { error }); + + const errorMessage = error instanceof Error ? error.message : String(error); + + return JSON.stringify({ + error: 'Failed to get documentation', + details: errorMessage, + availableTopics: getDocumentationTopics(), + }, null, 2); + } +} diff --git a/mcp/src/tools/get_easing_docs.ts b/mcp/src/tools/get_easing_docs.ts deleted file mode 100644 index 927d083..0000000 --- a/mcp/src/tools/get_easing_docs.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { z } from 'zod'; -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { createLogger } from '../utils/logger.js'; - -const logger = createLogger('get_easing_docs'); - -const GetEasingDocsInputSchema = z.object({ - category: z.string().optional().describe('Easing category: "basic", "in", "out", "inout", "back", "bounce", "elastic", or "all"'), -}); - -export const getEasingDocsTool = { - name: 'get_easing_docs', - description: `Get documentation for easing functions (motion curves). - -Easing functions control the speed/acceleration of animations over time. - -Categories: -- basic: Simple linear easing -- in: Start slow, end fast (acceleration) -- out: Start fast, end slow (deceleration) - RECOMMENDED for most animations -- inout: Start and end slow, fast in middle -- back: Overshoot and return (anticipation/follow-through) -- bounce: Bouncing effect -- elastic: Springy, elastic effect - -Examples: -- get_easing_docs({ category: "out" }) - Most commonly used -- get_easing_docs({ category: "all" }) - List all easings`, - inputSchema: zodToJsonSchema(GetEasingDocsInputSchema), -}; - -const EASING_DOCS = { - basic: { - description: 'Simple linear easing with constant speed', - easings: { - linear: { - description: 'Constant speed from start to end', - use: 'Mechanical movements, continuous rotations', - curve: 'Straight line', - }, - }, - }, - in: { - description: 'Start slow, accelerate to fast (ease in)', - easings: { - easeInQuad: { - description: 'Quadratic acceleration', - use: 'Objects falling, entering with weight', - curve: 'Gentle acceleration', - }, - easeInCubic: { - description: 'Cubic acceleration - smooth and natural', - use: 'Exit animations, objects moving away', - curve: 'Medium acceleration', - }, - easeInQuart: { - description: 'Quartic acceleration', - use: 'Heavy, weighty movements', - curve: 'Strong acceleration', - }, - easeInQuint: { - description: 'Quintic acceleration', - use: 'Very heavy objects', - curve: 'Very strong acceleration', - }, - easeInSine: { - description: 'Sinusoidal acceleration', - use: 'Smooth, gentle starts', - curve: 'Gentle sine curve', - }, - easeInExpo: { - description: 'Exponential acceleration', - use: 'Dramatic, explosive starts', - curve: 'Dramatic acceleration', - }, - easeInCirc: { - description: 'Circular acceleration', - use: 'Smooth, natural movements', - curve: 'Circular curve', - }, - }, - }, - out: { - description: 'Start fast, decelerate to slow (ease out) - MOST COMMON', - easings: { - easeOutQuad: { - description: 'Quadratic deceleration', - use: 'Gentle entrances, UI elements appearing', - curve: 'Gentle deceleration', - recommended: true, - }, - easeOutCubic: { - description: 'Cubic deceleration - very natural and commonly used', - use: 'DEFAULT CHOICE for most entrance animations', - curve: 'Medium deceleration', - recommended: true, - }, - easeOutQuart: { - description: 'Quartic deceleration', - use: 'Smooth, settling movements', - curve: 'Strong deceleration', - }, - easeOutQuint: { - description: 'Quintic deceleration', - use: 'Very smooth stops', - curve: 'Very strong deceleration', - }, - easeOutSine: { - description: 'Sinusoidal deceleration', - use: 'Gentle, flowing movements', - curve: 'Gentle sine curve', - recommended: true, - }, - easeOutExpo: { - description: 'Exponential deceleration', - use: 'Dramatic stops, zoom effects', - curve: 'Dramatic deceleration', - }, - easeOutCirc: { - description: 'Circular deceleration', - use: 'Natural, smooth stops', - curve: 'Circular curve', - }, - }, - }, - inout: { - description: 'Start and end slow, fast in middle (ease in-out)', - easings: { - easeInOutQuad: { - description: 'Quadratic in-out', - use: 'Smooth transitions between states', - curve: 'S-curve, gentle', - }, - easeInOutCubic: { - description: 'Cubic in-out - natural and balanced', - use: 'Emphasis animations, continuous movements', - curve: 'S-curve, medium', - recommended: true, - }, - easeInOutQuart: { - description: 'Quartic in-out', - use: 'Smooth, weighty movements', - curve: 'S-curve, strong', - }, - easeInOutQuint: { - description: 'Quintic in-out', - use: 'Very smooth transitions', - curve: 'S-curve, very strong', - }, - easeInOutSine: { - description: 'Sinusoidal in-out', - use: 'Gentle, flowing transitions', - curve: 'Smooth S-curve', - recommended: true, - }, - easeInOutExpo: { - description: 'Exponential in-out', - use: 'Dramatic movements', - curve: 'Steep S-curve', - }, - easeInOutCirc: { - description: 'Circular in-out', - use: 'Natural transitions', - curve: 'Circular S-curve', - }, - }, - }, - back: { - description: 'Overshoot target then return (anticipation/follow-through)', - easings: { - easeInBack: { - description: 'Pull back before accelerating forward', - use: 'Anticipation before movement, wind-up effects', - curve: 'Goes negative before positive', - }, - easeOutBack: { - description: 'Overshoot then settle - playful and bouncy', - use: 'POPULAR for entrance animations, playful UI', - curve: 'Overshoots then returns', - recommended: true, - }, - easeInOutBack: { - description: 'Pull back, overshoot, then settle', - use: 'Exaggerated, cartoon-style movements', - curve: 'Overshoots both ends', - }, - }, - }, - bounce: { - description: 'Bouncing ball physics', - easings: { - easeInBounce: { - description: 'Bounce before accelerating', - use: 'Bounce before entering', - curve: 'Multiple bounces at start', - }, - easeOutBounce: { - description: 'Bounce after landing - very playful', - use: 'Bouncing entrances, playful UI elements', - curve: 'Multiple bounces at end', - recommended: true, - }, - easeInOutBounce: { - description: 'Bounce at both ends', - use: 'Exaggerated bouncy effects', - curve: 'Bounces at start and end', - }, - }, - }, - elastic: { - description: 'Spring/elastic physics - overshoots multiple times', - easings: { - easeInElastic: { - description: 'Elastic wind-up', - use: 'Spring loading, elastic pull-back', - curve: 'Oscillates before moving', - }, - easeOutElastic: { - description: 'Elastic spring forward - very bouncy', - use: 'Spring-loaded entrances, elastic UI', - curve: 'Oscillates after moving', - recommended: false, - note: 'Use sparingly - can be too much', - }, - easeInOutElastic: { - description: 'Elastic at both ends', - use: 'Extreme elastic effects', - curve: 'Oscillates at both ends', - recommended: false, - note: 'Very exaggerated - rarely needed', - }, - }, - }, -}; - -const QUICK_RECOMMENDATIONS = { - 'entrance-animations': 'easeOutCubic or easeOutBack', - 'exit-animations': 'easeInCubic', - 'emphasis-animations': 'easeInOutCubic or easeInOutSine', - 'playful-ui': 'easeOutBack or easeOutBounce', - 'smooth-professional': 'easeOutCubic or easeOutQuad', - 'mechanical': 'linear', - 'dramatic': 'easeOutExpo or easeInExpo', -}; - -export async function executeGetEasingDocs(args: unknown): Promise { - try { - const input = GetEasingDocsInputSchema.parse(args); - const category = input.category?.toLowerCase() || 'all'; - - logger.info('Getting easing documentation', { category }); - - if (category === 'all') { - const allEasings: Record = {}; - let total = 0; - - for (const [cat, data] of Object.entries(EASING_DOCS)) { - allEasings[cat] = Object.keys(data.easings); - total += allEasings[cat].length; - } - - return JSON.stringify({ - easings: allEasings, - total, - quickRecommendations: QUICK_RECOMMENDATIONS, - mostCommon: [ - 'easeOutCubic - Best default for entrances', - 'easeInCubic - Best default for exits', - 'easeInOutCubic - Best default for emphasis', - 'easeOutBack - Playful, bouncy entrances', - 'linear - Continuous motion', - ], - usage: { - example: { - type: 'entrance', - effect: 'fadeIn', - delay: 30, - duration: 20, - easing: 'easeOutCubic', - }, - note: 'Easing is optional - defaults to linear if not specified', - }, - }, null, 2); - } - - const categoryData = EASING_DOCS[category as keyof typeof EASING_DOCS]; - if (!categoryData) { - return JSON.stringify({ - error: `Unknown easing category: ${category}`, - available: ['basic', 'in', 'out', 'inout', 'back', 'bounce', 'elastic', 'all'], - suggestion: 'Use category: "out" for most entrance animations', - }, null, 2); - } - - const response: any = { - category, - description: categoryData.description, - easings: categoryData.easings, - count: Object.keys(categoryData.easings).length, - }; - - // Add recommendations for this category - if (category === 'out') { - response.note = 'RECOMMENDED category for most entrance animations'; - response.topPicks = ['easeOutCubic', 'easeOutBack', 'easeOutQuad']; - } else if (category === 'in') { - response.note = 'Best for exit animations'; - response.topPicks = ['easeInCubic']; - } else if (category === 'inout') { - response.note = 'Best for emphasis animations'; - response.topPicks = ['easeInOutCubic', 'easeInOutSine']; - } - - return JSON.stringify(response, null, 2); - } catch (error) { - logger.error('Failed to get easing docs', { error }); - - if (error instanceof z.ZodError) { - return JSON.stringify({ - error: 'Invalid input', - details: error.errors, - }, null, 2); - } - - const errorMessage = error instanceof Error ? error.message : String(error); - return JSON.stringify({ - error: 'Failed to get easing documentation', - details: errorMessage, - }, null, 2); - } -} diff --git a/mcp/src/tools/get_example.ts b/mcp/src/tools/get_example.ts index 9c79f2e..d333d5f 100644 --- a/mcp/src/tools/get_example.ts +++ b/mcp/src/tools/get_example.ts @@ -1,53 +1,22 @@ import { zodToJsonSchema } from 'zod-to-json-schema'; import { GetExampleInputSchema } from '../types.js'; -import { readExampleTemplate, readExampleReadme, exampleExists } from '../utils/examples.js'; +import { readExampleTemplate, readExampleReadme, exampleExists, listAllExamples, getExampleCategories } from '../utils/examples.js'; import { createLogger } from '../utils/logger.js'; const logger = createLogger('get_example'); export const getExampleTool = { name: 'get_example', - description: `Load a specific example template by path. - -This tool retrieves a complete example template including: -- template: The full Rendervid JSON template ready to use -- readme: Documentation explaining how the template works (if available) -- path: The example path for reference - -The template can be used immediately with render_video or render_image tools. -You can also modify the template's inputs or structure before rendering. - -**Example paths format:** -category/example-name - -**Common examples:** -- getting-started/01-hello-world: Simplest template, animated text -- getting-started/02-first-video: Basic 5-second video -- social-media/instagram-story: 1080x1920 Instagram story template -- social-media/youtube-thumbnail: 1280x720 YouTube thumbnail -- marketing/product-showcase: Product feature video -- data-visualization/animated-bar-chart: Animated bar chart - -**Using the template:** -1. Load example: get_example({ examplePath: "category/name" }) -2. Review template structure and inputs -3. Customize inputs with your own values -4. Render with render_video or render_image - -**Customizing:** -Templates have an "inputs" array defining what can be customized. -Each input has a key, type, label, and default value. -Pass your custom values to the inputs parameter when rendering. - -Example: -{ - "inputs": [ - { "key": "title", "type": "string", "default": "Hello" }, - { "key": "color", "type": "color", "default": "#3B82F6" } - ] -} + description: `Browse and load Rendervid example templates. + +Without examplePath: lists all available examples (50+) organized by category. +With examplePath: loads a specific example template ready to render. + +Categories: getting-started, social-media, marketing, data-visualization, ecommerce, events, content, education, real-estate, streaming, fitness, food, advanced, showcase + +Example paths: "getting-started/01-hello-world", "social-media/instagram-story", "marketing/product-showcase" -Render with: { inputs: { title: "My Title", color: "#FF0000" } }`, +After loading, use render_video or render_image to render the template.`, inputSchema: zodToJsonSchema(GetExampleInputSchema), }; @@ -56,6 +25,11 @@ export async function executeGetExample(args: unknown): Promise { // Parse input const input = GetExampleInputSchema.parse(args); + // If no examplePath provided, list examples + if (!input.examplePath) { + return await listExamples(input.category); + } + logger.info('Getting example', { examplePath: input.examplePath }); // Check if example exists @@ -64,7 +38,7 @@ export async function executeGetExample(args: unknown): Promise { if (!exists) { return JSON.stringify({ error: `Example not found: ${input.examplePath}`, - suggestion: 'Use list_examples to see available examples', + suggestion: 'Use get_example() without arguments to list all available examples', hint: 'Path format should be: category/example-name (e.g., "getting-started/01-hello-world")', }, null, 2); } @@ -140,7 +114,40 @@ export async function executeGetExample(args: unknown): Promise { return JSON.stringify({ error: 'Failed to load example', details: errorMessage, - suggestion: 'Check that the example path is correct using list_examples', + suggestion: 'Use get_example() without arguments to list all available examples', }, null, 2); } } + +async function listExamples(category?: string): Promise { + logger.info('Listing examples', { category: category || 'all' }); + + const examples = await listAllExamples(category); + + // Group by category + const byCategory: Record = {}; + for (const example of examples) { + if (!byCategory[example.category]) { + byCategory[example.category] = []; + } + byCategory[example.category].push(example); + } + + const allCategories = await getExampleCategories(); + + logger.info('Examples listed', { + total: examples.length, + categories: Object.keys(byCategory).length, + }); + + return JSON.stringify({ + totalExamples: examples.length, + categories: Object.keys(byCategory), + allCategories, + examples: byCategory, + usage: { + message: 'Use get_example with examplePath to load a specific template', + example: 'get_example({ examplePath: "getting-started/01-hello-world" })', + }, + }, null, 2); +} diff --git a/mcp/src/tools/list_examples.ts b/mcp/src/tools/list_examples.ts deleted file mode 100644 index a09962d..0000000 --- a/mcp/src/tools/list_examples.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { zodToJsonSchema } from 'zod-to-json-schema'; -import { ListExamplesInputSchema } from '../types.js'; -import { listAllExamples, getExampleCategories } from '../utils/examples.js'; -import { createLogger } from '../utils/logger.js'; - -const logger = createLogger('list_examples'); - -export const listExamplesTool = { - name: 'list_examples', - description: `Browse the collection of 50+ ready-to-use Rendervid template examples. - -This tool lists available example templates organized by category. Each example includes: -- name: Template name -- category: Category (getting-started, social-media, marketing, etc.) -- path: Path to use with get_example tool -- description: What the template does -- outputType: 'video' or 'image' -- dimensions: Output resolution (e.g., "1920x1080") - -**Categories:** -- getting-started: Simple examples to learn basics (Hello World, First Video, etc.) -- social-media: Platform-specific templates (Instagram, TikTok, YouTube, Twitter, LinkedIn) -- marketing: Promotional content (Product Showcase, Sale Announcement, Testimonials, Logo Reveal) -- data-visualization: Animated charts (Bar Chart, Line Graph, Pie Chart, Counter, Dashboard) -- ecommerce: Online store content (Flash Sale, Product Launch, Comparison, Discount) -- events: Event announcements (Countdown, Save the Date, Webinar, Conference) -- content: Creator content (Podcast Teaser, Blog Promo, Quote Card, News Headline) -- education: Educational content (Course Intro, Lesson Title, Certificate) -- real-estate: Property listings (Listing, Price Drop, Open House) -- streaming: Streamer content (Stream Starting, End Screen, Highlight Intro) -- fitness: Fitness content (Workout Timer, Progress Tracker) -- food: Restaurant content (Menu Item, Daily Special, Recipe Card) -- advanced: Advanced techniques (Parallax, Kinetic Typography) -- showcase: Feature demonstrations (All Fonts, All Animations, All Easing, etc.) - -Use the category parameter to filter by category, or omit to see all examples. -After finding an example, use get_example to load its template and customize it.`, - inputSchema: zodToJsonSchema(ListExamplesInputSchema), -}; - -export async function executeListExamples(args: unknown): Promise { - try { - // Parse input - const input = ListExamplesInputSchema.parse(args); - - logger.info('Listing examples', { category: input.category || 'all' }); - - // Get examples - const examples = await listAllExamples(input.category); - - // Group by category for better display - const byCategory: Record = {}; - - for (const example of examples) { - if (!byCategory[example.category]) { - byCategory[example.category] = []; - } - byCategory[example.category].push(example); - } - - // Get all categories for reference - const allCategories = await getExampleCategories(); - - logger.info('Examples listed', { - total: examples.length, - categories: Object.keys(byCategory).length, - }); - - return JSON.stringify({ - totalExamples: examples.length, - categories: Object.keys(byCategory), - allCategories, - examples: byCategory, - usage: { - message: 'Use get_example with the "path" field to load a specific example template', - example: 'get_example({ examplePath: "getting-started/01-hello-world" })', - }, - }, null, 2); - } catch (error) { - logger.error('Failed to list examples', { error }); - - const errorMessage = error instanceof Error ? error.message : String(error); - - return JSON.stringify({ - error: 'Failed to list examples', - details: errorMessage, - }, null, 2); - } -} diff --git a/mcp/src/tools/render_image.ts b/mcp/src/tools/render_image.ts index 0f70ee0..7e3a21b 100644 --- a/mcp/src/tools/render_image.ts +++ b/mcp/src/tools/render_image.ts @@ -11,44 +11,12 @@ const logger = createLogger('render_image'); export const renderImageTool = { name: 'render_image', - description: `Generate a static image from a Rendervid template at a specific frame. - -USE FOR: -Social media posts (Instagram, Twitter, LinkedIn), video thumbnails (YouTube, Vimeo), -blog post headers, presentation slides, infographics, quote graphics, product mockups, -preview images, marketing materials, web banners - -OUTPUT: -- Format: PNG (lossless), JPEG (compressed), or WebP (modern) -- Location: Specified by outputPath parameter -- Quality: 1-100 for JPEG/WebP (default: 90) -- Frame: Captures specified frame number (default: 0) -- Max resolution: 7680x4320 (8K) - -TEMPLATE REQUIREMENTS: -- Same JSON structure as video templates -- Animations evaluated at specified frame -- Supports all layer types (text, image, shape, custom) -- Use output.type: "video" or "image" (both work) -- ⚠️ MUST include "inputs": [] field (even if empty for static templates) - -REQUIRED TEMPLATE FIELDS: -{ - "name": "string", // Template name (REQUIRED) - "output": { ... }, // Output configuration (REQUIRED) - "inputs": [], // Input definitions (REQUIRED - use [] if no dynamic inputs) - "composition": { ... } // Scenes and layers (REQUIRED) -} + description: `Render a static image from a Rendervid template at a specific frame. -TYPICAL USE: -1. Render frame 0 as thumbnail -2. Capture mid-animation frame for preview -3. Generate social media image with custom text -4. Create static graphics from animated template +Parameters: template (JSON object), outputPath, format (png/jpeg/webp), quality (1-100 for JPEG/WebP), frame number (default: 0), renderWaitTime. +Same template structure as render_video. Pass template as JSON OBJECT, always include "inputs": []. -⚠️ CRITICAL: Pass template as JSON OBJECT, not string -✅ CORRECT: { "template": {"name": "Image", "inputs": [], ...} } -❌ COMMON ERROR: Missing "inputs" field - always include it!`, +Use get_docs({ topic: "overview" }) for capabilities. Use get_docs({ topic: "template" }) for structure.`, inputSchema: zodToJsonSchema(RenderImageInputSchema), }; @@ -137,6 +105,7 @@ export async function executeRenderImage(args: unknown): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any const renderer = createNodeRenderer({ componentDefaultsManager: defaultsManager as any, + gpu: { encoding: 'none' }, }); // Merge inputs with template defaults diff --git a/mcp/src/tools/render_video.ts b/mcp/src/tools/render_video.ts index 5415903..db2cd98 100644 --- a/mcp/src/tools/render_video.ts +++ b/mcp/src/tools/render_video.ts @@ -15,667 +15,28 @@ const logger = createLogger('render_video'); export const renderVideoTool = { name: 'render_video', - description: `Generate a video file from a Rendervid JSON template. Use for videos ≤30 seconds. For longer videos, use start_render_async. - -USE FOR: -Social media content (Instagram Reels, TikTok, YouTube Shorts), product demonstrations, -promotional videos, explainer animations, tutorial videos, video ads, portfolio showcases, -event announcements, presentations, marketing campaigns - -OUTPUT CONFIGURATION (YOU CHOOSE): -- Format: MP4 (default/best), WebM (web), MOV (macOS) -- Quality: draft (fast preview), standard (balanced), high (production - default), lossless (uncompressed) -- Resolution: Choose based on platform: - * TikTok/Reels/Shorts: 1080x1920 (9:16 portrait) - * YouTube/Instagram: 1920x1080 (16:9 landscape) - * Twitter: 1280x720 (smaller, faster) - * Max: 7680x4320 (8K) -- FPS: 24 (cinematic), 30 (standard/default), 60 (smooth) -- renderWaitTime: **AUTO-ADJUSTED** (100ms for text-only, 500ms for images/video automatically) - * Manual override: 100 (fast), 200 (safer), 500+ (complex media) - * 💡 TIP: If images don't appear, increase to 800-1000ms - -MOTION BLUR (Optional - for cinematic effect): -Add natural motion blur like professional cameras. ⚠️ WARNING: Multiplies render time by sample count! - -WHEN TO USE: -✅ Fast-moving animations, camera pans, product showcases, sports/action content -❌ Static text/images, long videos (use start_render_async), simple graphics - -QUALITY PRESETS (Recommended): -- "quality": "low" → 5 samples, fast (~5× slower) -- "quality": "medium" → 10 samples, balanced (~10× slower) - DEFAULT -- "quality": "high" → 16 samples, cinematic (~16× slower) -- "quality": "ultra" → 32 samples, maximum blur (~32× slower) - -SIMPLE EXAMPLE (Use quality preset): -{ - "motionBlur": { - "enabled": true, - "quality": "medium" - } -} - -ADVANCED EXAMPLE (Custom parameters): -{ - "motionBlur": { - "enabled": true, - "samples": 12, // 2-32 temporal samples (higher = smoother blur) - "shutterAngle": 180, // 0-360° (180° = cinematic standard, 360° = max blur) - "adaptive": true, // Auto-reduce samples on static frames (30-50% faster) - "minSamples": 3, // Minimum samples for adaptive mode - "motionThreshold": 0.01 // Motion sensitivity (lower = more aggressive) - } -} - -PARAMETERS: - -Basic Settings: -- enabled: true/false (enable motion blur) -- quality: "low" | "medium" | "high" | "ultra" (overrides samples/shutterAngle) -- samples: 2-32 (number of sub-frames per frame, higher = smoother) -- shutterAngle: 0-360° (exposure time: 180° = cinematic, 360° = full frame) - -Performance Optimization: -- adaptive: true/false (reduce samples on static frames, saves 30-50%) -- minSamples: 2-32 (minimum samples for adaptive mode, must be ≤ samples) -- motionThreshold: 0.0001-1.0 (motion detection sensitivity) -- variableSampleRate: true/false (auto-adjust samples based on motion magnitude) -- maxSamples: 2-32 (maximum for variable rate, must be ≥ samples) -- preview: true/false (fast preview mode, uses 2 samples for ~2× render time) - -Quality Enhancement: -- stochastic: true/false (random sampling to reduce banding artifacts) - -Fine-Tuning: -- blurAmount: 0-2 (blur multiplier: 0=none, 1=normal, 2=double) -- blurAxis: "x" | "y" | "both" (blur direction, default: both) - -ADVANCED EXAMPLES: - -High Quality with Optimization: -{ - "motionBlur": { - "enabled": true, - "quality": "high", - "adaptive": true, - "stochastic": true - } -} - -Variable Sample Rate (Smart Optimization): -{ - "motionBlur": { - "enabled": true, - "samples": 6, // Minimum samples (slow motion) - "maxSamples": 20, // Maximum samples (fast motion) - "variableSampleRate": true - } -} - -Preview Mode (Fast Iteration): -{ - "motionBlur": { - "enabled": true, - "preview": true // Forces 2 samples, ~2× render time - } -} - -Per-Layer Control (Scene-Level): -{ - "composition": { - "scenes": [{ - "motionBlur": { "quality": "high" }, // Scene override - "layers": [ - { "id": "hero" }, // Inherits high quality - { "id": "ui", "motionBlur": { "enabled": false } } // Layer override - ] - }] - } -} - -⚠️ PERFORMANCE WARNING: -- Render time = base time × sample count -- 10 samples = ~10× slower (5 min → 50 min) -- Use "adaptive": true to save 30-50% on mixed content -- Use "variableSampleRate": true for automatic optimization -- Use "preview": true during development (~2× instead of 10×) -- For long videos with motion blur, use start_render_async - -⚠️ CRITICAL: Images require time to load! -- Server automatically uses 500ms renderWaitTime when images/videos detected -- If images still don't appear: manually set "renderWaitTime": 800 -- Text-only templates use 100ms (fast) - -⚠️ TIMEOUT WARNING: Videos >30s may timeout (60s MCP limit). Use start_render_async for longer videos. - -⚠️ CRITICAL: Pass template as JSON OBJECT, not string -❌ WRONG: { "template": "{\\"name\\":\\"Video\\"}" } -✅ CORRECT: { "template": {"name": "Video"} } - -⚠️ ALWAYS VALIDATE FIRST -Workflow: validate_template → fix errors → render_video -Prevents: 404 image errors, syntax issues, wasted render time - -⚠️ REQUIRED TEMPLATE FIELDS (ALWAYS INCLUDE): -{ - "name": "string", // Template name (REQUIRED) - "output": { ... }, // Output configuration (REQUIRED) - "inputs": [], // Input definitions array (REQUIRED - use empty array [] if no dynamic inputs) - "composition": { ... } // Scenes and layers (REQUIRED) -} - -❌ COMMON ERROR: Missing "inputs" field -✅ FIX: Always include "inputs": [] even if you have no dynamic variables - -CRITICAL TEMPLATE RULES: - -1. TIMING IS IN FRAMES, NOT SECONDS - - At 30 fps: 30 frames = 1 second, 150 frames = 5 seconds - - duration: 5 means 5 SECONDS (converted to frames internally) - - startFrame: 0, endFrame: 150 means frames 0-150 (5 seconds at 30fps) - - Animation delay/duration are in FRAMES: delay: 30 = 1 second delay - -2. ALL LAYERS MUST HAVE position AND size - - position: { x: number, y: number } (pixels from top-left) - - size: { width: number, height: number } (pixels) - - Example: position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 } - -3. LAYER PROPERTIES GO IN props OBJECT - - Correct: "props": { "text": "Hello", "fontSize": 48, "color": "#ffffff" } - - Wrong: "text": "Hello", "fontSize": 48 (these must be inside props) - -4. INPUTS FIELD IS ALWAYS REQUIRED - - MUST include "inputs" field in template (even if empty: "inputs": []) - - For static videos with no variables: "inputs": [] - - For dynamic videos with {{variables}}: define each input with ALL fields: - * key: unique identifier (string) - * type: "string" | "number" | "boolean" | "color" - * label: display name (string) - * description: what this input does (string) - REQUIRED - * required: true/false (boolean) - REQUIRED - * default: default value - REQUIRED - - Example: { "key": "title", "type": "string", "label": "Title", "description": "Main title text", "required": true, "default": "Hello" } - -5. ANIMATIONS USE FRAME-BASED TIMING - - type: "entrance" | "exit" | "emphasis" - - effect: "fadeIn", "slideUp", "scaleIn", etc. - - delay: number (frames to wait before starting) - - duration: number (frames the animation lasts) - - Example: { "type": "entrance", "effect": "fadeIn", "delay": 30, "duration": 20 } - This means: wait 1 second (30 frames), then fade in over 0.67 seconds (20 frames) - -COMPLETE TEMPLATE STRUCTURE (Static - No Variables): -{ - "name": "Video Name", - "output": { - "type": "video", - "width": 1920, - "height": 1080, - "fps": 30, - "duration": 5 - }, - "inputs": [], - "composition": { - "scenes": [ - { - "id": "main", - "startFrame": 0, - "endFrame": 150, - "layers": [ - { - "id": "background", - "type": "shape", - "position": { "x": 0, "y": 0 }, - "size": { "width": 1920, "height": 1080 }, - "props": { - "shape": "rectangle", - "fill": "#2563eb" - } - }, - { - "id": "title", - "type": "text", - "position": { "x": 160, "y": 440 }, - "size": { "width": 1600, "height": 200 }, - "props": { - "text": "Hello World", - "fontSize": 72, - "fontWeight": "bold", - "color": "#ffffff", - "textAlign": "center" - } - } - ] - } - ] - } -} - -COMPLETE TEMPLATE STRUCTURE (Dynamic - With Variables): -{ - "name": "Video Name", - "output": { - "type": "video", - "width": 1920, - "height": 1080, - "fps": 30, - "duration": 5 - }, - "inputs": [ - { - "key": "title", - "type": "string", - "label": "Title Text", - "description": "Main title text to display", - "required": true, - "default": "Hello World" - } - ], - "defaults": { - "title": "Hello World" - }, - "composition": { - "scenes": [ - { - "id": "main", - "startFrame": 0, - "endFrame": 150, - "layers": [ - { - "id": "background", - "type": "shape", - "position": { "x": 0, "y": 0 }, - "size": { "width": 1920, "height": 1080 }, - "props": { - "shape": "rectangle", - "fill": "#2563eb" - } - }, - { - "id": "title", - "type": "text", - "position": { "x": 160, "y": 440 }, - "size": { "width": 1600, "height": 200 }, - "props": { - "text": "{{title}}", - "fontSize": 72, - "fontWeight": "bold", - "color": "#ffffff", - "textAlign": "center" - }, - "animations": [ - { - "type": "entrance", - "effect": "fadeIn", - "delay": 30, - "duration": 20, - "easing": "easeOutCubic" - } - ] - } - ] - } - ] - } -} - -NEED MORE DETAILS? Use these tools for just-in-time documentation: -- get_component_docs({ componentType: "text" }) - Detailed component/layer documentation -- get_animation_docs({ animationType: "entrance" }) - Animation effects reference -- get_easing_docs({ category: "out" }) - Easing functions guide -- get_capabilities({}) - Full list of available features -- list_examples({}) - Browse example templates - -LAYER TYPES: text, image, shape, video, audio, gif, caption, custom -ANIMATION TYPES: entrance, exit, emphasis -EASING CATEGORIES: basic, in, out (recommended), inout, back, bounce, elastic - -VISIBILITY CONTROL: -- Scenes and layers support "hidden": true/false (optional, default: false) -- Hidden scenes are skipped during rendering entirely -- Hidden layers are not rendered but remain in the template -- Example: { "id": "debug-overlay", "type": "text", "hidden": true, ... } - -⚠️ CRITICAL: ANIMATION RESTRICTIONS - CAUSES BLACK SCENES IF VIOLATED - -**SUPPORTED ANIMATIONS ONLY:** -✅ SAFE (Standard entrance/exit effects): -- fadeIn, fadeOut (opacity transitions) -- slideInUp, slideInDown, slideInLeft, slideInRight, slideOutUp, slideOutDown, slideOutLeft, slideOutRight (position transitions) -- scaleIn, scaleOut (size transitions) - -❌ NEVER USE (Will create BLACK SCENES): -- Custom keyframe animations with "type": "custom" -- Animations that change size or position in keyframes -- Complex transform animations -- CSS-based animations - -**ANIMATION STRUCTURE (REQUIRED FORMAT):** -{ - "animations": [ - { - "type": "entrance", // or "exit" - "effect": "fadeIn", // Use supported effects only - "delay": 30, // Frames to wait before starting - "duration": 20, // Frames the animation lasts - "easing": "easeOutCubic" // Optional easing function - } - ] -} - -**EXAMPLE - SMOOTH SCENE TRANSITIONS:** -// Scene 1 elements - fade out before scene ends -{ - "animations": [ - { - "type": "entrance", - "effect": "fadeIn", - "delay": 15, - "duration": 20 - }, - { - "type": "exit", - "effect": "fadeOut", - "delay": 130, // Start fade 20 frames before scene end (if scene is 150 frames) - "duration": 20 - } - ] -} - -// Scene 2 background - fade in at scene start -{ - "animations": [ - { - "type": "entrance", - "effect": "fadeIn", - "delay": 0, // Start immediately - "duration": 20 - } - ] -} - -⚠️ CRITICAL: POSITIONING RULES - ELEMENTS MUST BE VISIBLE - -**CANVAS BOUNDS (Example for 1080x1920 vertical video):** -- Width: 0 to 1080 (pixels) -- Height: 0 to 1920 (pixels) -- Origin: Top-left corner (0, 0) - -**SAFE POSITIONING ZONES:** - - +-------------------------+ y: 0 - | Title Area (Safe) | y: 100-400 - |-------------------------| - | | - | Main Content Area | y: 400-1500 - | (Safe Zone) | - | | - |-------------------------| - | Bottom Area (Safe) | y: 1500-1800 - +-------------------------+ y: 1920 - x: 0 x: 1080 - -❌ COMMON POSITIONING ERRORS (Causes invisible/black content): -- Negative positions: x: -100 or y: -50 -- Beyond canvas: y: 2000 for 1920px height video -- Partially off-screen: x: 1000 with width: 200 (goes to x: 1200, exceeds 1080) - -✅ CORRECT POSITIONING: -- Title: { "x": 90, "y": 200 } with size { "width": 900, "height": 100 } -- Main content: { "x": 100, "y": 500 } with size { "width": 880, "height": 800 } -- Always ensure: x + width ≤ canvas width, y + height ≤ canvas height - -⚠️ CRITICAL: VIDEO BACKGROUNDS - AVOID LOOP FLASH - -**THE PROBLEM:** If scene duration > video source duration, video loops and causes visible flash/jump - -**THE SOLUTION:** Make scene duration EXACTLY match video source duration - -**EXAMPLE:** -- Video source: 5 seconds -- FPS: 30 -- Scene frames: 5 × 30 = 150 frames -- Scene config: startFrame: 0, endFrame: 150 (exactly 5 seconds) - -❌ WRONG (Causes flash): -{ - "startFrame": 0, - "endFrame": 225, // 7.5 seconds - video will loop at 5s and flash! - "layers": [{ - "type": "video", - "props": { - "src": "/path/to/5-second-video.mp4", - "loop": true // Will cause visible flash at 5s mark - } - }] -} - -✅ CORRECT (No flash): -{ - "startFrame": 0, - "endFrame": 150, // Exactly 5 seconds - no loop needed! - "layers": [{ - "type": "video", - "props": { - "src": "/path/to/5-second-video.mp4", - "muted": true // Loop not needed when duration matches - } - }] -} - -⚠️ QUALITY SETTINGS - FOR PRODUCTION VIDEOS - -**WHEN TO USE HIGH QUALITY:** -Use "quality": "high" for: -- Final production videos -- Client deliverables -- Videos with text that must be sharp -- Videos for large screens - -**OUTPUT:** -- Uses software encoding (libx264) - slower but excellent quality -- Bitrate: 8-10 Mbps (file size: ~8-12 MB for 15s video) -- No compression artifacts -- Crystal clear text and images - -**WHEN TO USE STANDARD QUALITY:** -Use "quality": "standard" (default) for: -- Quick previews -- Social media content (compressed by platforms anyway) -- Drafts and iterations - -**OUTPUT:** -- Uses hardware encoding (faster) -- Bitrate: ~1-2 Mbps (file size: ~1-2 MB for 15s video) -- Good enough for most uses -- Faster render times - -CUSTOM COMPONENTS (ADVANCED - For animated counters, charts, particles, etc.): - -**CRITICAL JSX RESTRICTION - READ CAREFULLY:** -Custom components MUST use React.createElement(), NOT JSX syntax! - -❌ WRONG (JSX - Will create BLACK VIDEO): -"code": "import React from 'react'; export default function Counter({ frame }) { return
{frame}
; }" - -✅ CORRECT (React.createElement): -"code": "function Counter(props) { return React.createElement('div', null, props.frame); }" - -**RULES FOR INLINE CUSTOM COMPONENTS:** -1. ✅ Use React.createElement() - NEVER use JSX (
,
, etc.) -2. ✅ Plain function: function ComponentName(props) { ... } -3. ✅ Access props: props.frame, props.fps, props.layerSize -4. ❌ NO imports: no "import React" or "import anything" -5. ❌ NO exports: no "export default" or "export" -6. ❌ NO async/await, side effects, or external libraries -7. ✅ React is globally available - just use React.createElement() - -**COMPONENT PROPS (automatically provided):** -{ - frame: number, // Current frame (0, 1, 2, ...) - fps: number, // Frames per second (30, 60) - sceneDuration: number, // Total frames in scene - layerSize: { - width: number, - height: number - }, - // + your custom props from customComponent.props -} - -**SIMPLE CUSTOM COMPONENT EXAMPLE:** -{ - "customComponents": { - "AnimatedCounter": { - "type": "inline", - "code": "function AnimatedCounter(props) { const duration = 2 * props.fps; const progress = Math.min(props.frame / duration, 1); const value = Math.floor(props.from + (props.to - props.from) * progress); return React.createElement('div', { style: { fontSize: '72px', fontWeight: 'bold', color: '#00ffff' } }, value); }" - } - }, - "composition": { - "scenes": [{ - "layers": [{ - "id": "counter", - "type": "custom", - "position": { "x": 960, "y": 540 }, - "size": { "width": 400, "height": 200 }, - "customComponent": { - "name": "AnimatedCounter", - "props": { - "from": 0, - "to": 100 - } - } - }] - }] - } -} - -**JSX TO React.createElement CONVERSION:** - -Simple element: -JSX:
Hello
-→ React.createElement('div', null, 'Hello') - -With props: -JSX:
Hello
-→ React.createElement('div', { style: { color: 'red' } }, 'Hello') - -Nested elements: -JSX:
{value}
-→ React.createElement('div', null, React.createElement('span', null, value)) - -Multiple children: -JSX:

A

B

-→ React.createElement('div', null, React.createElement('p', null, 'A'), React.createElement('p', null, 'B')) - -**WHEN TO USE CUSTOM COMPONENTS:** -✓ Animated counters (0 → 100) -✓ Timers and clocks -✓ Particle effects -✓ Custom charts/graphs -✓ Progress bars -✓ Complex animations beyond built-in effects - -For simple text/images/shapes, use built-in layer types instead! - -VIDEO LAYER EXAMPLE (FOR VIDEO BACKGROUNDS): -{ - "id": "background-video", - "type": "video", - "position": { "x": 0, "y": 0 }, - "size": { "width": 1920, "height": 1080 }, - "props": { - "src": "/path/to/video.mp4", - "fit": "cover", - "loop": true, - "muted": true, - "playbackRate": 1, - "startTime": 0 - } -} - -WHEN TO USE VIDEO LAYERS: -✓ Use for animated backgrounds (loops, motion footage) -✓ Local file paths: "/Users/name/Downloads/video.mp4" (absolute paths only) -✓ Always set "muted": true for background videos -✓ Use "loop": true to repeat the video -✓ Layer order matters: video background should be FIRST layer (rendered behind text/shapes) - -IMAGE LAYER EXAMPLE (REQUIRED FOR PROMOTIONAL VIDEOS): -{ - "id": "product-photo", - "type": "image", - "position": { "x": 100, "y": 100 }, - "size": { "width": 800, "height": 600 }, - "props": { - "src": "https://www.photomaticai.com/images/.../image.webp", - "fit": "cover" - } -} - -⚠️ IMAGE SOURCE OPTIONS: - -✅ LOCAL FILES - AUTOMATICALLY HANDLED (Recommended for local development): - - MCP server automatically converts local files to base64 data URLs - - Absolute paths: "/Users/name/Downloads/image.jpg" - - Tilde paths: "~/Downloads/image.jpg" (auto-expands to home directory) - - Works on macOS, Linux, Windows - - **AUTO-RESIZE**: Files > 500 KB automatically resized to ~30-50 KB for optimal performance - - **BASE64**: Converted to data URLs seamlessly (no HTTP server needed) - - No internet connection required - - All formats: JPG, PNG, WebP, GIF, BMP, SVG - - ⚠️ Use absolute paths, NOT relative paths like "./image.jpg" - -✅ HTTPS URLs (Recommended for production): - - Unsplash: "https://images.unsplash.com/photo-..." - - Pexels: "https://images.pexels.com/photos/..." - - Photomatic AI: "https://www.photomaticai.com/images/processed/..." - - Any accessible HTTPS image URL - -✅ DATA URLs (Advanced): - - Base64: "data:image/png;base64,iVBORw0KG..." - - Local files are automatically converted to data URLs - -❌ WRONG: These will FAIL: - - "/mnt/user-data/uploads/image.webp" (Linux path, invalid on macOS) - - "/home/claude/image.webp" (Linux path, invalid on macOS) - - "C:\\Users\\name\\image.jpg" (Windows path, use forward slashes) - - "./image.webp" or "../images/photo.jpg" (relative paths not supported) - -🎯 AUTOMATIC IMAGE RESIZING: - - Large images (> 500 KB) are automatically resized - - Example: 1154 KB → 36 KB (preserves quality and aspect ratio) - - Ensures fast loading and no browser issues - - WebP files converted to JPEG for better compatibility - -WHEN TO USE IMAGE LAYERS: -✓ ALWAYS include image layers for: promotional videos, product showcases, portfolios, presentations -✓ If creating a promo for "Product X", include actual Product X images - not just text/shapes -✓ Use Photomatic AI URLs: https://www.photomaticai.com/images/processed/... -✓ Multiple images make videos more engaging: add 2-5 image layers showing different angles/features - -CRITICAL FOR IMAGE/VIDEO RENDERING: -When your template includes image or video layers, set: "renderWaitTime": 500 -Media is pre-loaded during initialization, but a wait ensures proper rendering. -Example: { "template": {...}, "renderWaitTime": 500 } -Note: Video layers need 500-1000ms; images need 300-500ms; simple text templates: 100-200ms - -COMMON MISTAKES TO AVOID: -❌ MISSING "inputs" FIELD - THIS IS THE #1 ERROR! Always include "inputs": [] even for static templates -❌ Creating promotional videos with ONLY text and shapes - MUST include image layers showing the actual product/content -❌ Using seconds for animation timing (use frames!) -❌ Missing size property on layers -❌ Putting layer props outside props object -❌ Missing required fields in input definitions (description, required, default) -❌ Using wrong position values (position is top-left corner, not center) -❌ endFrame less than or equal to startFrame -❌ DUPLICATE LAYER IDS: Layer IDs must be unique across ALL scenes (use "scene1-bg", "scene2-bg" not "background" in every scene) - -TEMPLATE VARIABLES: -✓ Use {{variableName}} in props to reference inputs: "text": "{{title}}" -✓ Define the variable in defaults: "defaults": { "title": "Hello" } -✓ Note: "UNUSED_INPUT" warnings appear even when using {{variables}} - these can be ignored -❌ Animation delay + duration exceeding scene duration`, + description: `Render a video from a Rendervid JSON template. For videos >30s, use start_render_async. + +Parameters: template (JSON object), outputPath, format (mp4/webm/mov/gif), quality (draft/standard/high/lossless), fps override, renderWaitTime, motionBlur config. +renderWaitTime auto-adjusts: 100ms for text-only, 500ms for images/video. Override manually if images don't appear (800-1000ms). + +Workflow: validate_template -> render_video +Use get_docs({ topic: "overview" }) to explore all capabilities. +Use get_docs({ topic: "template" }) for template structure. +Use get_docs({ topic: "layer/text" }) etc. for layer-specific props. +Use get_docs({ topic: "animations" }) for animation presets. +Use get_example() to browse and load example templates. + +Key rules: +- Pass template as JSON OBJECT, not string +- Always include "inputs": [] (even if empty) +- Template needs: name, output, inputs, composition +- Timing: output.duration is in seconds; animation delay/duration are in frames (30 frames = 1s at 30fps) +- All layers need: id (unique across all scenes), type, position {x, y}, size {width, height} +- Layer-specific properties go inside "props" object +- Scenes/layers support "hidden": true to skip during rendering +- Image/video src: use absolute local paths or HTTPS URLs (local files auto-convert to base64) +- Custom components: use React.createElement(), NOT JSX. No imports/exports.`, inputSchema: zodToJsonSchema(RenderVideoInputSchema), }; @@ -915,6 +276,7 @@ export async function executeRenderVideo(args: unknown): Promise { // eslint-disable-next-line @typescript-eslint/no-explicit-any renderer = createNodeRenderer({ componentDefaultsManager: defaultsManager as any, + gpu: { encoding: 'none' }, }); } catch (rendererError) { logger.error('Failed to create renderer', { error: rendererError }); @@ -945,8 +307,10 @@ export async function executeRenderVideo(args: unknown): Promise { outputPath: outputPath, codec: codecSettings.codec, quality: codecSettings.quality, + preset: codecSettings.preset, renderWaitTime: renderWaitTime, motionBlur: input.motionBlur, + useStreaming: true, onProgress: (progress: any) => { logger.info('Render progress', { phase: progress.phase, @@ -1116,25 +480,36 @@ function detectMediaLayers(template: any): boolean { return false; } -function getCodecSettings(format: string, quality: string): { codec: 'libx264' | 'libx265' | 'libvpx' | 'libvpx-vp9' | 'prores'; quality: number } { - // Map format and quality to codec settings +function getCodecSettings(format: string, quality: string): { + codec: 'libx264' | 'libx265' | 'libvpx' | 'libvpx-vp9' | 'prores'; + quality: number; + preset: 'fast' | 'medium' | 'slow' | 'veryslow'; +} { const codecMap: Record = { mp4: 'libx264', webm: 'libvpx-vp9', mov: 'libx264', - gif: 'libx264', // GIF not directly supported by these codecs, will need special handling + gif: 'libx264', }; const qualityMap: Record = { - draft: 28, // Lower quality, faster - standard: 23, // Balanced - high: 18, // Higher quality - lossless: 0, // Lossless + draft: 28, + standard: 23, + high: 18, + lossless: 0, + }; + + const presetMap: Record = { + draft: 'fast', + standard: 'medium', + high: 'slow', + lossless: 'medium', // CRF 0 is mathematically lossless regardless of preset }; return { codec: codecMap[format] || 'libx264', quality: qualityMap[quality] || 23, + preset: presetMap[quality] || 'medium', }; } diff --git a/mcp/src/tools/render_video_async.ts b/mcp/src/tools/render_video_async.ts index 58848a5..eecff26 100644 --- a/mcp/src/tools/render_video_async.ts +++ b/mcp/src/tools/render_video_async.ts @@ -278,6 +278,7 @@ async function executeRenderInBackground(jobId: string): Promise { const defaultsManager = createDefaultComponentDefaultsManager(); const renderer = createNodeRenderer({ componentDefaultsManager: defaultsManager as any, + gpu: { encoding: 'none' }, }); // Get codec settings @@ -315,8 +316,10 @@ async function executeRenderInBackground(jobId: string): Promise { outputPath: job.renderOptions.outputPath, codec: codecSettings.codec, quality: codecSettings.quality, + preset: codecSettings.preset, renderWaitTime: renderWaitTime, motionBlur: job.renderOptions.motionBlur, + useStreaming: true, onProgress: (progress: any) => { jobManager.updateProgress(jobId, { phase: progress.phase, @@ -371,6 +374,7 @@ function detectMediaLayers(template: any): boolean { function getCodecSettings(format: string, quality: string): { codec: 'libx264' | 'libx265' | 'libvpx' | 'libvpx-vp9' | 'prores'; quality: number; + preset: 'fast' | 'medium' | 'slow' | 'veryslow'; } { const codecMap: Record = { mp4: 'libx264', @@ -386,8 +390,16 @@ function getCodecSettings(format: string, quality: string): { lossless: 0, }; + const presetMap: Record = { + draft: 'fast', + standard: 'medium', + high: 'slow', + lossless: 'medium', // CRF 0 is mathematically lossless regardless of preset + }; + return { codec: codecMap[format] || 'libx264', quality: qualityMap[quality] || 23, + preset: presetMap[quality] || 'medium', }; } diff --git a/mcp/src/types.ts b/mcp/src/types.ts index b56fcff..2c68d81 100644 --- a/mcp/src/types.ts +++ b/mcp/src/types.ts @@ -107,18 +107,14 @@ export const ValidateTemplateInputSchema = z.object({ template: z.any(), }); -/** - * Zod schema for list_examples tool input - */ -export const ListExamplesInputSchema = z.object({ - category: z.string().optional(), -}); - /** * Zod schema for get_example tool input */ export const GetExampleInputSchema = z.object({ - examplePath: z.string(), + examplePath: z.string().optional() + .describe('Path to load a specific example (e.g., "getting-started/01-hello-world"). Omit to list all examples.'), + category: z.string().optional() + .describe('Filter examples by category when listing (e.g., "social-media", "marketing"). Only used when examplePath is omitted.'), }); /** diff --git a/package.json b/package.json index 51a40e9..0fe6966 100644 --- a/package.json +++ b/package.json @@ -60,5 +60,6 @@ "engines": { "node": ">=20.0.0", "pnpm": ">=9.0.0" - } + }, + "packageManager": "pnpm@10.28.2+sha512.41872f037ad22f7348e3b1debbaf7e867cfd448f2726d9cf74c08f19507c31d2c8e7a11525b983febc2df640b5438dee6023ebb1f84ed43cc2d654d2bc326264" } diff --git a/packages/core/src/docs/descriptions.ts b/packages/core/src/docs/descriptions.ts new file mode 100644 index 0000000..d72fa04 --- /dev/null +++ b/packages/core/src/docs/descriptions.ts @@ -0,0 +1,131 @@ +/** + * Human-curated descriptions, tips, and recommendations for documentation. + * This is the single maintenance file for authored content. + * Everything else is derived from TypeScript types and runtime registries. + */ + +/** Layer type one-line descriptions */ +export const LAYER_DESCRIPTIONS: Record = { + text: 'Rich text with typography, fonts, alignment, spans, and text effects', + image: 'Display images with fit modes (cover, contain, fill)', + video: 'Video playback with timing, speed, and volume controls', + shape: 'Geometric shapes: rectangle, ellipse, polygon, star, SVG path', + audio: 'Audio playback with effects chain, volume envelope, and stereo pan', + group: 'Container for organizing and clipping child layers', + lottie: 'Lottie/Bodymovin JSON animations with speed and direction control', + gif: 'Animated GIF images with frame-synced playback', + caption: 'Subtitles/captions with SRT/VTT parsing and timed display', + canvas: 'Programmatic 2D drawing with paths, gradients, shapes, and text', + three: '3D scenes with cameras, lights, meshes, materials, and shaders', + custom: 'Custom React components for advanced effects and animations', +}; + +/** Transition type descriptions */ +export const TRANSITION_DESCRIPTIONS: Record = { + cut: 'Instant switch between scenes (no animation)', + fade: 'Crossfade between scenes', + slide: 'Slide new scene in from a direction', + wipe: 'Wipe new scene across from a direction', + zoom: 'Zoom in or out to reveal new scene', + rotate: 'Rotate to reveal new scene', + flip: '3D flip effect between scenes', + blur: 'Blur transition between scenes', + circle: 'Circular reveal/iris effect', + push: 'Push old scene out while new scene enters', + crosszoom: 'Cross zoom effect between scenes', + glitch: 'Digital glitch effect transition', + dissolve: 'Dissolve/pixelate between scenes', + cube: '3D cube rotation between scenes', + swirl: 'Swirl/spiral effect between scenes', + 'diagonal-wipe': 'Diagonal wipe across the screen', + iris: 'Iris in/out circular transition', +}; + +/** Filter type descriptions with value info */ +export const FILTER_DESCRIPTIONS: Record = { + blur: { description: 'Gaussian blur', unit: 'pixels', default: '0', range: '0+' }, + brightness: { description: 'Brightness adjustment', unit: 'multiplier', default: '1', range: '0+' }, + contrast: { description: 'Contrast adjustment', unit: 'multiplier', default: '1', range: '0+' }, + grayscale: { description: 'Convert to grayscale', unit: 'percentage', default: '0', range: '0-100' }, + 'hue-rotate': { description: 'Rotate hue', unit: 'degrees', default: '0', range: '0-360' }, + invert: { description: 'Invert colors', unit: 'percentage', default: '0', range: '0-100' }, + opacity: { description: 'Layer opacity via filter', unit: 'multiplier', default: '1', range: '0-1' }, + saturate: { description: 'Color saturation', unit: 'multiplier', default: '1', range: '0+' }, + sepia: { description: 'Sepia tone effect', unit: 'percentage', default: '0', range: '0-100' }, + 'drop-shadow': { description: 'Drop shadow effect', unit: 'CSS shadow string', default: 'none', range: 'N/A' }, +}; + +/** Easing category descriptions */ +export const EASING_CATEGORY_DESCRIPTIONS: Record = { + linear: 'Constant speed, no acceleration', + quad: 'Quadratic curve - gentle acceleration/deceleration', + cubic: 'Cubic curve - smooth and natural (most commonly used)', + quart: 'Quartic curve - strong acceleration/deceleration', + quint: 'Quintic curve - very strong acceleration/deceleration', + sine: 'Sinusoidal curve - gentle and flowing', + expo: 'Exponential curve - dramatic acceleration/deceleration', + circ: 'Circular curve - natural and smooth', + back: 'Overshoot and return - anticipation/follow-through', + elastic: 'Spring/elastic physics - bouncy oscillation', + bounce: 'Bouncing ball physics', +}; + +/** Easing recommendations for common use cases */ +export const EASING_RECOMMENDATIONS: Record = { + 'entrance-animations': 'easeOutCubic (smooth) or easeOutBack (playful)', + 'exit-animations': 'easeInCubic', + 'emphasis-animations': 'easeInOutCubic or easeInOutSine', + 'playful-ui': 'easeOutBack or easeOutBounce', + 'smooth-professional': 'easeOutCubic or easeOutQuad', + 'mechanical-motion': 'linear', + 'dramatic-effect': 'easeOutExpo or easeInExpo', +}; + +/** Tips for common topics */ +export const TIPS = { + template: [ + 'Always include "inputs": [] even for static templates with no variables', + 'Duration is in seconds. Frame counts = fps x duration', + 'Use validate_template before rendering to catch errors early', + ], + scene: [ + 'Scenes should not overlap in frame ranges', + 'Transition duration is subtracted from the gap between scenes', + 'Use backgroundColor on scenes for solid backgrounds', + ], + layer: [ + 'All layers require id, type, position, and size', + 'Layer IDs must be unique across ALL scenes', + 'position is the top-left corner, not center', + 'Use "from" and "duration" (in frames) to control when layers appear within a scene', + 'Animations delay/duration are in FRAMES (30 frames = 1 second at 30fps)', + ], + animations: [ + 'Timing is in FRAMES, not seconds. At 30fps: 30 frames = 1 second', + 'Use entrance + exit animations together for smooth scene transitions', + 'easeOutCubic is the best default for entrance animations', + 'Emphasis animations can loop: set loop: -1 for infinite', + ], + text: [ + 'Use spans[] for rich text with mixed styles within one text layer', + 'Set maxLines + overflow: "ellipsis" to truncate long text', + 'verticalAlign: "middle" centers text vertically in the layer', + ], + video: [ + 'Set muted: true for background videos', + 'Match scene duration to video duration to avoid loop flash', + 'Use startTime to skip to a specific point in the video', + ], + custom: [ + 'Use React.createElement(), NOT JSX syntax', + 'Components receive frame, fps, sceneDuration, layerSize automatically', + 'No imports, exports, or side effects allowed in inline code', + 'Components must be deterministic: same frame = same output', + ], + motionBlur: [ + 'Render time = base time x sample count', + 'Use adaptive: true to save 30-50% on mixed content', + 'Use preview: true during development for fast iteration', + 'For long videos with motion blur, use start_render_async', + ], +}; diff --git a/packages/core/src/docs/index.ts b/packages/core/src/docs/index.ts new file mode 100644 index 0000000..32b0ddf --- /dev/null +++ b/packages/core/src/docs/index.ts @@ -0,0 +1,86 @@ +/** + * Documentation module for Rendervid. + * Provides structured documentation for all engine features, accessible via get_docs MCP tool. + */ + +import type { DocResult } from './types.js'; + +// Topic handlers +import { getOverviewDocs } from './topics/overview.js'; +import { getTemplateDocs } from './topics/template.js'; +import { getSceneDocs } from './topics/scene.js'; +import { getLayerBaseDocs } from './topics/layer-base.js'; +import { getTextLayerDocs } from './topics/layers/text.js'; +import { getImageLayerDocs } from './topics/layers/image.js'; +import { getVideoLayerDocs } from './topics/layers/video.js'; +import { getShapeLayerDocs } from './topics/layers/shape.js'; +import { getAudioLayerDocs } from './topics/layers/audio.js'; +import { getGroupLayerDocs } from './topics/layers/group.js'; +import { getLottieLayerDocs } from './topics/layers/lottie.js'; +import { getGifLayerDocs } from './topics/layers/gif.js'; +import { getCaptionLayerDocs } from './topics/layers/caption.js'; +import { getCanvasLayerDocs } from './topics/layers/canvas.js'; +import { getThreeLayerDocs } from './topics/layers/three.js'; +import { getCustomLayerDocs } from './topics/layers/custom.js'; +import { getAnimationsDocs } from './topics/animations.js'; +import { getEasingsDocs } from './topics/easings.js'; +import { getTransitionsDocs } from './topics/transitions.js'; +import { getFiltersDocs } from './topics/filters.js'; +import { getFontsDocs } from './topics/fonts.js'; +import { getStyleDocs } from './topics/style.js'; +import { getMotionBlurDocs } from './topics/motion-blur.js'; +import { getInputsDocs } from './topics/inputs.js'; + +const topicHandlers: Record DocResult> = { + 'overview': getOverviewDocs, + 'template': getTemplateDocs, + 'scene': getSceneDocs, + 'layer': getLayerBaseDocs, + 'layer/text': getTextLayerDocs, + 'layer/image': getImageLayerDocs, + 'layer/video': getVideoLayerDocs, + 'layer/shape': getShapeLayerDocs, + 'layer/audio': getAudioLayerDocs, + 'layer/group': getGroupLayerDocs, + 'layer/lottie': getLottieLayerDocs, + 'layer/gif': getGifLayerDocs, + 'layer/caption': getCaptionLayerDocs, + 'layer/canvas': getCanvasLayerDocs, + 'layer/three': getThreeLayerDocs, + 'layer/custom': getCustomLayerDocs, + 'animations': getAnimationsDocs, + 'easings': getEasingsDocs, + 'transitions': getTransitionsDocs, + 'filters': getFiltersDocs, + 'fonts': getFontsDocs, + 'style': getStyleDocs, + 'motion-blur': getMotionBlurDocs, + 'inputs': getInputsDocs, +}; + +/** + * Get documentation for a specific topic. + * + * @param topic - Topic identifier (e.g., "overview", "layer/text", "animations") + * @returns Structured documentation for the topic + * @throws Error if the topic is not recognized + */ +export function getDocumentation(topic: string): DocResult { + const handler = topicHandlers[topic]; + if (!handler) { + const available = Object.keys(topicHandlers).sort(); + throw new Error( + `Unknown documentation topic: "${topic}". Available topics: ${available.join(', ')}` + ); + } + return handler(); +} + +/** + * Get all available documentation topic names. + */ +export function getDocumentationTopics(): string[] { + return Object.keys(topicHandlers).sort(); +} + +export type { DocResult, DocSection, DocProperty } from './types.js'; diff --git a/packages/core/src/docs/topics/animations.ts b/packages/core/src/docs/topics/animations.ts new file mode 100644 index 0000000..aa2388d --- /dev/null +++ b/packages/core/src/docs/topics/animations.ts @@ -0,0 +1,75 @@ +import type { DocResult } from '../types.js'; +import { getPresetsByType, getAllPresetNames } from '../../animation/index.js'; +import { TIPS } from '../descriptions.js'; + +export function getAnimationsDocs(): DocResult { + const entrance = getPresetsByType('entrance'); + const exit = getPresetsByType('exit'); + const emphasis = getPresetsByType('emphasis'); + + return { + topic: 'animations', + title: 'Animation Presets', + description: `${getAllPresetNames().length} animation presets organized by type. Animations are applied to layers via the animations[] array.`, + sections: [ + { + title: 'Animation Structure', + description: 'How to add animations to a layer', + properties: { + type: { type: '"entrance" | "exit" | "emphasis" | "keyframe"', required: true, description: 'Animation category' }, + effect: { type: 'string', description: 'Preset effect name (for entrance/exit/emphasis)' }, + duration: { type: 'number', required: true, description: 'Duration in frames' }, + delay: { type: 'number', description: 'Delay before animation starts (frames)', default: 0 }, + easing: { type: 'string', description: 'Easing function', default: 'easeOutCubic for entrance, easeInCubic for exit' }, + loop: { type: 'number', description: 'Loop count (-1 for infinite)', default: 1 }, + alternate: { type: 'boolean', description: 'Alternate direction on loop', default: false }, + }, + examples: [ + { type: 'entrance', effect: 'fadeIn', delay: 30, duration: 20, easing: 'easeOutCubic' }, + ], + }, + { + title: 'Entrance Presets', + description: `${entrance.length} entrance animations that introduce elements`, + properties: Object.fromEntries( + entrance.map(p => [p.name, { type: 'preset', description: `Duration: ${p.defaultDuration}f, Easing: ${p.defaultEasing}` }]) + ), + }, + { + title: 'Exit Presets', + description: `${exit.length} exit animations that remove elements`, + properties: Object.fromEntries( + exit.map(p => [p.name, { type: 'preset', description: `Duration: ${p.defaultDuration}f, Easing: ${p.defaultEasing}` }]) + ), + }, + { + title: 'Emphasis Presets', + description: `${emphasis.length} emphasis animations that draw attention`, + properties: Object.fromEntries( + emphasis.map(p => [p.name, { type: 'preset', description: `Duration: ${p.defaultDuration}f, Easing: ${p.defaultEasing}` }]) + ), + }, + { + title: 'Keyframe Animations', + description: 'Custom keyframe-based animations for full control', + properties: { + 'keyframes[].frame': { type: 'number', required: true, description: 'Frame number relative to animation start' }, + 'keyframes[].properties': { type: 'AnimatableProperties', required: true, description: 'Properties at this keyframe: x, y, scaleX, scaleY, rotation, opacity' }, + 'keyframes[].easing': { type: 'string', description: 'Easing to next keyframe', default: 'linear' }, + }, + examples: [ + { + type: 'keyframe', duration: 60, + keyframes: [ + { frame: 0, properties: { opacity: 0, y: 50 } }, + { frame: 30, properties: { opacity: 1, y: 0 }, easing: 'easeOutCubic' }, + { frame: 60, properties: { opacity: 1, y: 0 } }, + ], + }, + ], + }, + ], + tips: TIPS.animations, + seeAlso: ['easings', 'layer'], + }; +} diff --git a/packages/core/src/docs/topics/easings.ts b/packages/core/src/docs/topics/easings.ts new file mode 100644 index 0000000..9f35bf5 --- /dev/null +++ b/packages/core/src/docs/topics/easings.ts @@ -0,0 +1,57 @@ +import type { DocResult } from '../types.js'; +import { getAllEasingNames } from '../../animation/index.js'; +import { EASING_RECOMMENDATIONS } from '../descriptions.js'; + +export function getEasingsDocs(): DocResult { + const allNames = getAllEasingNames(); + + // Group by category + const categories: Record = { + basic: allNames.filter(n => n === 'linear'), + quad: allNames.filter(n => n.includes('Quad')), + cubic: allNames.filter(n => n.includes('Cubic')), + quart: allNames.filter(n => n.includes('Quart')), + quint: allNames.filter(n => n.includes('Quint')), + sine: allNames.filter(n => n.includes('Sine')), + expo: allNames.filter(n => n.includes('Expo')), + circ: allNames.filter(n => n.includes('Circ')), + back: allNames.filter(n => n.includes('Back')), + elastic: allNames.filter(n => n.includes('Elastic')), + bounce: allNames.filter(n => n.includes('Bounce')), + }; + + return { + topic: 'easings', + title: 'Easing Functions', + description: `${allNames.length} built-in easing functions that control animation acceleration. Also supports custom cubic-bezier() and spring().`, + sections: [ + ...Object.entries(categories).map(([cat, names]) => ({ + title: cat.charAt(0).toUpperCase() + cat.slice(1), + description: `${names.length} easing(s)`, + properties: Object.fromEntries( + names.map(n => { + const direction = n.includes('InOut') ? 'in-out' : n.includes('Out') ? 'out (decelerate)' : n.includes('In') ? 'in (accelerate)' : 'constant'; + return [n, { type: 'EasingName', description: direction }]; + }) + ), + })), + { + title: 'Custom Easings', + description: 'Beyond presets, you can use custom easing values', + properties: { + 'cubic-bezier': { type: 'string', description: 'Custom cubic bezier curve', example: 'cubic-bezier(0.25, 0.1, 0.25, 1)' }, + spring: { type: 'string', description: 'Spring physics-based easing', example: 'spring(1, 100, 10)' }, + }, + }, + ], + tips: [ + 'easeOutCubic is the best default for entrance animations', + 'easeInCubic is the best default for exit animations', + 'easeInOutCubic works well for emphasis animations', + 'easeOutBack adds a playful overshoot effect', + 'linear is best for continuous rotations', + ], + items: Object.entries(EASING_RECOMMENDATIONS).map(([use, easing]) => ({ useCase: use, recommended: easing })), + seeAlso: ['animations'], + }; +} diff --git a/packages/core/src/docs/topics/filters.ts b/packages/core/src/docs/topics/filters.ts new file mode 100644 index 0000000..c5b0ee6 --- /dev/null +++ b/packages/core/src/docs/topics/filters.ts @@ -0,0 +1,41 @@ +import type { DocResult } from '../types.js'; +import { FILTER_DESCRIPTIONS } from '../descriptions.js'; + +export function getFiltersDocs(): DocResult { + return { + topic: 'filters', + title: 'CSS Filters', + description: '10 filter types that can be applied to any layer via the filters[] array. Filters can be animated over time.', + properties: Object.fromEntries( + Object.entries(FILTER_DESCRIPTIONS).map(([key, info]) => [ + key, { type: 'FilterType', description: `${info.description}. Unit: ${info.unit}. Default: ${info.default}`, range: info.range }, + ]) + ), + sections: [ + { + title: 'Filter Structure', + description: 'Each filter in the filters[] array', + properties: { + type: { type: 'FilterType', required: true, description: 'Filter type' }, + value: { type: 'number | string', required: true, description: 'Filter value (units depend on type)' }, + animate: { type: 'FilterAnimation', description: 'Animate filter value over time' }, + }, + }, + { + title: 'Filter Animation', + description: 'Animate a filter value over time', + properties: { + from: { type: 'number', required: true, description: 'Starting value' }, + to: { type: 'number', required: true, description: 'Ending value' }, + duration: { type: 'number', required: true, description: 'Duration in frames' }, + easing: { type: 'string', description: 'Easing function' }, + }, + }, + ], + examples: [ + { description: 'Blur filter', filter: { type: 'blur', value: 5 } }, + { description: 'Animated brightness', filter: { type: 'brightness', value: 1, animate: { from: 0.5, to: 1.5, duration: 60, easing: 'easeInOutSine' } } }, + ], + seeAlso: ['layer', 'style'], + }; +} diff --git a/packages/core/src/docs/topics/fonts.ts b/packages/core/src/docs/topics/fonts.ts new file mode 100644 index 0000000..170cf3e --- /dev/null +++ b/packages/core/src/docs/topics/fonts.ts @@ -0,0 +1,55 @@ +import type { DocResult } from '../types.js'; + +export function getFontsDocs(): DocResult { + return { + topic: 'fonts', + title: 'Font Configuration', + description: 'Configure Google Fonts and custom fonts for text layers. Defined in the template.fonts field.', + sections: [ + { + title: 'Google Fonts', + description: 'Load fonts from Google Fonts CDN', + properties: { + family: { type: 'string', required: true, description: 'Font family name', example: 'Roboto' }, + weights: { type: 'number[]', description: 'Weights to load', example: [400, 700] }, + styles: { type: 'string[]', description: 'Styles to load', example: ['normal', 'italic'] }, + }, + }, + { + title: 'Custom Fonts', + description: 'Load custom font files', + properties: { + family: { type: 'string', required: true, description: 'Font family name', example: 'MyBrand' }, + source: { type: 'string', required: true, description: 'URL to font file (woff2, woff, ttf)', example: 'https://cdn.example.com/font.woff2' }, + weight: { type: 'number', description: 'Font weight', default: 400 }, + style: { type: 'string', description: 'Font style', default: 'normal' }, + }, + }, + { + title: 'Font Fallbacks', + description: 'Configure fallback fonts when primary fonts fail to load', + properties: { + fallbacks: { type: 'Record', description: 'Map of font family to fallback families', example: { Roboto: ['Arial', 'Helvetica', 'sans-serif'] } }, + }, + }, + ], + examples: [ + { + description: 'Template with Google Fonts', + fonts: { + google: [ + { family: 'Roboto', weights: [400, 700], styles: ['normal', 'italic'] }, + { family: 'Playfair Display', weights: [700] }, + ], + fallbacks: { Roboto: ['Arial', 'sans-serif'] }, + }, + }, + ], + tips: [ + 'Fonts are loaded before rendering begins', + 'Use fontFamily in text layer props to reference loaded fonts', + 'The font catalog has 1000+ Google Fonts available', + ], + seeAlso: ['layer/text', 'template'], + }; +} diff --git a/packages/core/src/docs/topics/inputs.ts b/packages/core/src/docs/topics/inputs.ts new file mode 100644 index 0000000..8ea79c1 --- /dev/null +++ b/packages/core/src/docs/topics/inputs.ts @@ -0,0 +1,71 @@ +import type { DocResult } from '../types.js'; + +export function getInputsDocs(): DocResult { + return { + topic: 'inputs', + title: 'Template Inputs', + description: 'Input definitions allow templates to be customized at render time. Reference inputs in layer props with {{variableName}} syntax.', + properties: { + key: { type: 'string', required: true, description: 'Unique input key (used as {{key}} in props)', example: 'title' }, + type: { type: 'InputType', required: true, description: 'Data type', values: ['string', 'number', 'boolean', 'color', 'url', 'enum', 'richtext', 'date', 'array'] }, + label: { type: 'string', required: true, description: 'Display label for UI', example: 'Title Text' }, + description: { type: 'string', required: true, description: 'Description for users and AI agents' }, + required: { type: 'boolean', required: true, description: 'Whether this input must be provided' }, + default: { type: 'unknown', description: 'Default value if not provided' }, + validation: { type: 'InputValidation', description: 'Validation rules' }, + ui: { type: 'InputUI', description: 'UI rendering hints' }, + }, + sections: [ + { + title: 'Validation Rules', + description: 'Optional validation constraints', + properties: { + minLength: { type: 'number', description: 'Minimum string length' }, + maxLength: { type: 'number', description: 'Maximum string length' }, + pattern: { type: 'string', description: 'Regex pattern to match' }, + min: { type: 'number', description: 'Minimum numeric value' }, + max: { type: 'number', description: 'Maximum numeric value' }, + step: { type: 'number', description: 'Step increment for numbers' }, + integer: { type: 'boolean', description: 'Must be an integer' }, + options: { type: 'EnumOption[]', description: 'Available options for enum type: { value, label }' }, + allowedTypes: { type: 'string[]', description: 'Allowed asset types for URL inputs', values: ['image', 'video', 'audio', 'font'] }, + minItems: { type: 'number', description: 'Minimum array length' }, + maxItems: { type: 'number', description: 'Maximum array length' }, + }, + }, + { + title: 'UI Hints', + description: 'Optional hints for UI rendering', + properties: { + placeholder: { type: 'string', description: 'Placeholder text' }, + helpText: { type: 'string', description: 'Help text shown below input' }, + group: { type: 'string', description: 'Group name for organizing inputs' }, + order: { type: 'number', description: 'Display order within group' }, + hidden: { type: 'boolean', description: 'Hide from UI' }, + rows: { type: 'number', description: 'Rows for multiline text' }, + accept: { type: 'string', description: 'File accept attribute' }, + }, + }, + ], + examples: [ + { + description: 'Text input with validation', + input: { key: 'title', type: 'string', label: 'Title', description: 'Main title text', required: true, default: 'Hello World', validation: { minLength: 1, maxLength: 100 } }, + }, + { + description: 'Color input', + input: { key: 'accentColor', type: 'color', label: 'Accent Color', description: 'Primary accent color', required: false, default: '#3B82F6' }, + }, + { + description: 'Enum input', + input: { key: 'theme', type: 'enum', label: 'Theme', description: 'Visual theme', required: true, default: 'dark', validation: { options: [{ value: 'dark', label: 'Dark' }, { value: 'light', label: 'Light' }] } }, + }, + ], + tips: [ + 'Always include "inputs": [] in templates, even with no dynamic values', + 'Use {{key}} syntax in layer props to reference inputs', + 'Set defaults in the template.defaults object', + ], + seeAlso: ['template'], + }; +} diff --git a/packages/core/src/docs/topics/layer-base.ts b/packages/core/src/docs/topics/layer-base.ts new file mode 100644 index 0000000..bb1d619 --- /dev/null +++ b/packages/core/src/docs/topics/layer-base.ts @@ -0,0 +1,92 @@ +import type { DocResult } from '../types.js'; +import { TIPS } from '../descriptions.js'; + +export function getLayerBaseDocs(): DocResult { + return { + topic: 'layer', + title: 'Universal Layer Properties', + description: 'Properties available on ALL layer types. Every layer extends LayerBase.', + sections: [ + { + title: 'Identity', + description: 'Required identification fields', + properties: { + id: { type: 'string', required: true, description: 'Unique layer identifier (must be unique across all scenes)', example: 'scene1-title' }, + type: { type: 'LayerType', required: true, description: 'Layer type', values: ['text', 'image', 'video', 'shape', 'audio', 'group', 'lottie', 'custom', 'three', 'gif', 'canvas', 'caption'] }, + name: { type: 'string', description: 'Display name (for editor UI)' }, + }, + }, + { + title: 'Transform', + description: 'Position, size, rotation, scale, and anchor', + properties: { + position: { type: '{ x: number, y: number }', required: true, description: 'Position in pixels from top-left corner', example: { x: 100, y: 200 } }, + size: { type: '{ width: number, height: number }', required: true, description: 'Dimensions in pixels', example: { width: 800, height: 600 } }, + rotation: { type: 'number', description: 'Rotation in degrees', default: 0, example: 45 }, + scale: { type: '{ x: number, y: number }', description: 'Scale multiplier (1 = 100%)', default: { x: 1, y: 1 }, example: { x: 1.5, y: 1.5 } }, + anchor: { type: '{ x: number, y: number }', description: 'Anchor point (0-1 range, 0.5 = center)', default: { x: 0.5, y: 0.5 } }, + }, + }, + { + title: 'Timing', + description: 'Control when layers appear within a scene', + properties: { + from: { type: 'number', description: 'Start frame within scene (relative to scene startFrame)', default: 0, example: 30 }, + duration: { type: 'number', description: 'Duration in frames. -1 = entire scene duration', default: -1, example: 60 }, + }, + }, + { + title: 'Appearance', + description: 'Visual properties', + properties: { + opacity: { type: 'number', description: 'Layer opacity', default: 1, range: '0-1', example: 0.8 }, + blendMode: { type: 'BlendMode', description: 'Compositing blend mode', default: 'normal', values: ['normal', 'multiply', 'screen', 'overlay', 'darken', 'lighten', 'color-dodge', 'color-burn', 'hard-light', 'soft-light', 'difference', 'exclusion'] }, + filters: { type: 'Filter[]', description: 'CSS filters (blur, brightness, contrast, etc.). See get_docs({ topic: "filters" })' }, + shadow: { type: '{ color, blur, offsetX, offsetY }', description: 'Drop shadow effect', example: { color: 'rgba(0,0,0,0.5)', blur: 10, offsetX: 2, offsetY: 2 } }, + clipPath: { type: 'string', description: 'SVG path to clip layer to', example: 'M0,0 L100,0 L100,100 Z' }, + maskLayer: { type: 'string', description: 'ID of another layer to use as mask' }, + }, + }, + { + title: 'Styling', + description: 'Tailwind-like style properties', + properties: { + style: { type: 'LayerStyle', description: 'Tailwind-like utility styles. See get_docs({ topic: "style" })' }, + className: { type: 'string', description: 'Tailwind CSS class names' }, + }, + }, + { + title: 'Input Binding', + description: 'Bind layer properties to template inputs', + properties: { + inputKey: { type: 'string', description: 'Bind this layer to an input key' }, + inputProperty: { type: 'string', description: 'Which property to bind (default depends on layer type)' }, + }, + }, + { + title: 'Animation', + description: 'Animations applied to this layer', + properties: { + animations: { type: 'Animation[]', description: 'Array of animations. See get_docs({ topic: "animations" })' }, + }, + }, + { + title: 'Motion Blur', + description: 'Per-layer motion blur override', + properties: { + motionBlur: { type: 'MotionBlurConfig', description: 'Layer-level motion blur config. See get_docs({ topic: "motion-blur" })' }, + }, + }, + { + title: 'Metadata', + description: 'Editor and visibility controls', + properties: { + locked: { type: 'boolean', description: 'Lock layer in editor (prevents edits)', default: false }, + hidden: { type: 'boolean', description: 'Hide layer from rendering', default: false }, + }, + }, + ], + tips: TIPS.layer, + seeAlso: ['layer/text', 'layer/image', 'layer/video', 'layer/shape', 'layer/audio', 'layer/group', 'layer/lottie', 'layer/gif', 'layer/caption', 'layer/canvas', 'layer/three', 'layer/custom', 'animations', 'filters', 'style'], + }; +} diff --git a/packages/core/src/docs/topics/layers/audio.ts b/packages/core/src/docs/topics/layers/audio.ts new file mode 100644 index 0000000..823507c --- /dev/null +++ b/packages/core/src/docs/topics/layers/audio.ts @@ -0,0 +1,60 @@ +import type { DocResult } from '../../types.js'; + +export function getAudioLayerDocs(): DocResult { + return { + topic: 'layer/audio', + title: 'Audio Layer', + description: 'Audio playback with effects chain, volume envelope, and stereo panning. Audio layers do not need position or size.', + properties: { + src: { type: 'string', required: true, description: 'Audio source URL or local file path', example: 'https://example.com/music.mp3' }, + volume: { type: 'number', description: 'Volume level', default: 1, range: '0-1' }, + loop: { type: 'boolean', description: 'Loop audio playback', default: false }, + startTime: { type: 'number', description: 'Start playback at this time in seconds', default: 0 }, + fadeIn: { type: 'number', description: 'Fade in duration in frames', example: 30 }, + fadeOut: { type: 'number', description: 'Fade out duration in frames', example: 60 }, + pan: { type: 'number', description: 'Stereo pan position', range: '-1 (left) to 1 (right)', default: 0 }, + effects: { type: 'AudioEffect[]', description: 'Audio effects chain' }, + volumeEnvelope: { type: 'VolumeKeyframe[]', description: 'Volume automation keyframes' }, + }, + sections: [ + { + title: 'Audio Effects', + description: 'Effects that can be applied in the effects chain', + properties: { + eq: { type: 'EQEffect', description: 'Parametric equalizer with frequency bands' }, + reverb: { type: 'ReverbEffect', description: 'Reverb with roomSize, damping, wetDry' }, + compressor: { type: 'CompressorEffect', description: 'Dynamic range compressor' }, + delay: { type: 'DelayEffect', description: 'Delay with feedback' }, + gain: { type: 'GainEffect', description: 'Volume/gain adjustment' }, + lowpass: { type: 'LowPassFilter', description: 'Low-pass filter with frequency and Q' }, + highpass: { type: 'HighPassFilter', description: 'High-pass filter with frequency and Q' }, + }, + }, + { + title: 'Volume Envelope', + description: 'Automate volume over time with keyframes', + properties: { + frame: { type: 'number', required: true, description: 'Frame number' }, + volume: { type: 'number', required: true, description: 'Volume at this frame', range: '0-1' }, + easing: { type: 'string', description: 'Easing to next keyframe', default: 'linear' }, + }, + }, + ], + tips: [ + 'Audio layers do not need position or size properties', + 'Use fadeIn/fadeOut for smooth audio transitions', + 'Use volumeEnvelope for precise volume automation', + ], + examples: [ + { + description: 'Background music with fade', + layer: { + id: 'music', type: 'audio', + position: { x: 0, y: 0 }, size: { width: 0, height: 0 }, + props: { src: 'https://example.com/music.mp3', volume: 0.3, loop: true, fadeIn: 30, fadeOut: 60 }, + }, + }, + ], + seeAlso: ['layer', 'layer/video'], + }; +} diff --git a/packages/core/src/docs/topics/layers/canvas.ts b/packages/core/src/docs/topics/layers/canvas.ts new file mode 100644 index 0000000..9b7a49e --- /dev/null +++ b/packages/core/src/docs/topics/layers/canvas.ts @@ -0,0 +1,96 @@ +import type { DocResult } from '../../types.js'; + +export function getCanvasLayerDocs(): DocResult { + return { + topic: 'layer/canvas', + title: 'Canvas Layer', + description: 'Programmatic 2D drawing with paths, gradients, shapes, text on path, and patterns.', + properties: { + commands: { type: 'CanvasDrawCommand[]', required: true, description: 'Array of drawing commands to execute in order' }, + backgroundColor: { type: 'string', description: 'Canvas background color' }, + antialias: { type: 'boolean', description: 'Enable anti-aliasing', default: true }, + }, + sections: [ + { + title: 'Draw Command Types', + description: 'Each command in the commands array has a type and type-specific properties', + properties: { + path: { type: 'CanvasDrawCommand', description: 'Draw an SVG path with fill/stroke' }, + gradient: { type: 'CanvasDrawCommand', description: 'Draw a gradient (linear, radial, or conic)' }, + textOnPath: { type: 'CanvasDrawCommand', description: 'Render text along an SVG path' }, + pattern: { type: 'CanvasDrawCommand', description: 'Fill with a repeating pattern' }, + clipPath: { type: 'CanvasDrawCommand', description: 'Set clipping region using SVG path' }, + circle: { type: 'CanvasDrawCommand', description: 'Draw a circle with cx, cy, r' }, + rect: { type: 'CanvasDrawCommand', description: 'Draw a rectangle with x, y, width, height' }, + line: { type: 'CanvasDrawCommand', description: 'Draw a line from (x1,y1) to (x2,y2)' }, + }, + }, + { + title: 'Common Command Properties', + description: 'Properties shared across command types', + properties: { + type: { type: 'CanvasDrawCommandType', required: true, description: 'Command type', values: ['path', 'gradient', 'textOnPath', 'pattern', 'clipPath', 'circle', 'rect', 'line'] }, + fill: { type: 'string', description: 'Fill color' }, + stroke: { type: 'string', description: 'Stroke color' }, + strokeWidth: { type: 'number', description: 'Stroke width in pixels' }, + strokeDash: { type: 'number[]', description: 'Stroke dash pattern' }, + lineCap: { type: 'string', description: 'Line cap style', values: ['butt', 'round', 'square'] }, + lineJoin: { type: 'string', description: 'Line join style', values: ['miter', 'round', 'bevel'] }, + opacity: { type: 'number', description: 'Command opacity', range: '0-1' }, + pathData: { type: 'string', description: 'SVG path data (for path, textOnPath, clipPath commands)' }, + }, + }, + { + title: 'Circle Properties', + description: 'Additional properties for circle command', + properties: { + cx: { type: 'number', description: 'Center X coordinate' }, + cy: { type: 'number', description: 'Center Y coordinate' }, + r: { type: 'number', description: 'Radius' }, + }, + }, + { + title: 'Rectangle Properties', + description: 'Additional properties for rect command', + properties: { + x: { type: 'number', description: 'X coordinate' }, + y: { type: 'number', description: 'Y coordinate' }, + width: { type: 'number', description: 'Width' }, + height: { type: 'number', description: 'Height' }, + borderRadius: { type: 'number', description: 'Corner radius' }, + }, + }, + { + title: 'Gradient Configuration', + description: 'Configuration for gradient draw commands', + properties: { + 'gradient.type': { type: 'string', required: true, description: 'Gradient type', values: ['linear', 'radial', 'conic'] }, + 'gradient.stops': { type: 'GradientStop[]', required: true, description: 'Color stops: { offset: 0-1, color: string }' }, + 'gradient.x0': { type: 'number', description: 'Start X (linear) or center X (radial/conic)' }, + 'gradient.y0': { type: 'number', description: 'Start Y (linear) or center Y (radial/conic)' }, + 'gradient.x1': { type: 'number', description: 'End X (linear)' }, + 'gradient.y1': { type: 'number', description: 'End Y (linear)' }, + 'gradient.r0': { type: 'number', description: 'Inner radius (radial)' }, + 'gradient.r1': { type: 'number', description: 'Outer radius (radial)' }, + 'gradient.startAngle': { type: 'number', description: 'Start angle in degrees (conic)' }, + }, + }, + ], + examples: [ + { + description: 'Canvas with circle and rectangle', + layer: { + id: 'drawing', type: 'canvas', + position: { x: 0, y: 0 }, size: { width: 800, height: 600 }, + props: { + commands: [ + { type: 'rect', x: 0, y: 0, width: 800, height: 600, fill: '#1a1a2e' }, + { type: 'circle', cx: 400, cy: 300, r: 100, fill: '#e94560', stroke: '#ffffff', strokeWidth: 2 }, + ], + }, + }, + }, + ], + seeAlso: ['layer', 'layer/shape'], + }; +} diff --git a/packages/core/src/docs/topics/layers/caption.ts b/packages/core/src/docs/topics/layers/caption.ts new file mode 100644 index 0000000..4c1f263 --- /dev/null +++ b/packages/core/src/docs/topics/layers/caption.ts @@ -0,0 +1,67 @@ +import type { DocResult } from '../../types.js'; + +export function getCaptionLayerDocs(): DocResult { + return { + topic: 'layer/caption', + title: 'Caption/Subtitle Layer', + description: 'Timed subtitles/captions that appear and disappear based on timestamps. Supports SRT, VTT, and pre-parsed cue arrays.', + properties: { + content: { type: 'string', description: 'Raw subtitle content in SRT, VTT, or plain text format' }, + format: { type: 'string', description: 'Subtitle format (auto-detected if omitted)', values: ['srt', 'vtt', 'plain'] }, + cues: { type: 'CaptionCue[]', description: 'Pre-parsed cue array (alternative to content string)' }, + fontSize: { type: 'number', description: 'Font size in pixels', default: 32, example: 48 }, + fontFamily: { type: 'string', description: 'Font family', default: 'sans-serif' }, + fontWeight: { type: 'string', description: 'Font weight', values: ['normal', 'bold', '100-900'] }, + color: { type: 'string', description: 'Text color', default: '#ffffff' }, + backgroundColor: { type: 'string', description: 'Background color behind text', default: 'rgba(0, 0, 0, 0.75)' }, + textAlign: { type: 'string', description: 'Text alignment', default: 'center', values: ['left', 'center', 'right'] }, + padding: { type: 'number', description: 'Padding around text in pixels' }, + borderRadius: { type: 'number', description: 'Background border radius' }, + lineHeight: { type: 'number', description: 'Line height multiplier' }, + }, + sections: [ + { + title: 'CaptionCue Structure', + description: 'Each cue defines a timed text segment', + properties: { + startTime: { type: 'number', required: true, description: 'Start time in seconds', example: 1.0 }, + endTime: { type: 'number', required: true, description: 'End time in seconds', example: 4.0 }, + text: { type: 'string', required: true, description: 'Caption text content', example: 'Hello World' }, + }, + }, + ], + tips: [ + 'Provide either content (raw SRT/VTT string) or cues (pre-parsed array), not both', + 'SRT format is auto-detected - just paste raw subtitle content', + 'Position the caption layer at the bottom of the video for standard subtitle placement', + ], + examples: [ + { + description: 'SRT-based captions', + layer: { + id: 'subtitles', type: 'caption', + position: { x: 0, y: 800 }, size: { width: 1920, height: 280 }, + props: { + content: '1\n00:00:01,000 --> 00:00:04,000\nHello World\n\n2\n00:00:05,000 --> 00:00:08,000\nWelcome to the video', + format: 'srt', fontSize: 48, color: '#ffffff', backgroundColor: 'rgba(0, 0, 0, 0.75)', + }, + }, + }, + { + description: 'Pre-parsed cues', + layer: { + id: 'subs', type: 'caption', + position: { x: 0, y: 800 }, size: { width: 1920, height: 280 }, + props: { + cues: [ + { startTime: 1, endTime: 4, text: 'Hello World' }, + { startTime: 5, endTime: 8, text: 'Welcome' }, + ], + fontSize: 48, color: '#ffffff', + }, + }, + }, + ], + seeAlso: ['layer', 'layer/text'], + }; +} diff --git a/packages/core/src/docs/topics/layers/custom.ts b/packages/core/src/docs/topics/layers/custom.ts new file mode 100644 index 0000000..2c0f271 --- /dev/null +++ b/packages/core/src/docs/topics/layers/custom.ts @@ -0,0 +1,89 @@ +import type { DocResult } from '../../types.js'; +import { TIPS } from '../../descriptions.js'; + +export function getCustomLayerDocs(): DocResult { + return { + topic: 'layer/custom', + title: 'Custom Component Layer', + description: 'Custom React components for advanced effects like counters, particles, charts, and complex animations.', + properties: { + customComponent: { type: 'CustomComponentRef', required: true, description: 'Reference to the component definition' }, + }, + sections: [ + { + title: 'Custom Component Reference', + description: 'The customComponent field on the layer', + properties: { + name: { type: 'string', required: true, description: 'Name matching a key in template.customComponents', example: 'AnimatedCounter' }, + props: { type: 'Record', description: 'Custom props to pass to the component', example: { from: 0, to: 100 } }, + }, + }, + { + title: 'Defining Components (template.customComponents)', + description: 'Three methods to define custom components at the template root level', + properties: { + type: { type: '"inline" | "url" | "reference"', required: true, description: 'Component source type' }, + code: { type: 'string', description: 'Inline React code (for type: "inline")' }, + url: { type: 'string', description: 'HTTPS URL to component file (for type: "url")' }, + reference: { type: 'string', description: 'Pre-registered component name (for type: "reference")' }, + description: { type: 'string', description: 'What the component does' }, + }, + }, + { + title: 'Auto-Injected Props', + description: 'Props automatically provided to every custom component', + properties: { + frame: { type: 'number', description: 'Current frame number (0, 1, 2, ...)' }, + fps: { type: 'number', description: 'Video frame rate (e.g., 30)' }, + sceneDuration: { type: 'number', description: 'Total frames in the scene' }, + layerSize: { type: '{ width, height }', description: 'Layer dimensions in pixels' }, + }, + }, + { + title: 'Inline Component Rules', + description: 'Rules for type: "inline" components', + tips: [ + 'Use React.createElement() - NEVER use JSX (
,
)', + 'Plain function: function ComponentName(props) { ... }', + 'No imports, no exports, no async/await', + 'React is globally available', + 'Must be deterministic: same frame = same output', + 'No side effects (fetch, setTimeout, etc.)', + ], + }, + { + title: 'React.createElement Quick Reference', + description: 'Converting JSX to React.createElement', + examples: [ + { jsx: '
Hello
', createElement: "React.createElement('div', null, 'Hello')" }, + { jsx: "
Hello
", createElement: "React.createElement('div', { style: { color: 'red' } }, 'Hello')" }, + { jsx: '
{value}
', createElement: "React.createElement('div', null, React.createElement('span', null, value))" }, + ], + }, + ], + examples: [ + { + description: 'Animated counter component', + template: { + customComponents: { + AnimatedCounter: { + type: 'inline', + code: "function AnimatedCounter(props) { const progress = Math.min(props.frame / (props.fps * 2), 1); const value = Math.floor(props.from + (props.to - props.from) * progress); return React.createElement('div', { style: { fontSize: '72px', fontWeight: 'bold', color: '#00ffff' } }, value); }", + }, + }, + composition: { + scenes: [{ + layers: [{ + id: 'counter', type: 'custom', + position: { x: 760, y: 440 }, size: { width: 400, height: 200 }, + customComponent: { name: 'AnimatedCounter', props: { from: 0, to: 100 } }, + }], + }], + }, + }, + }, + ], + tips: TIPS.custom, + seeAlso: ['layer', 'get_component_defaults'], + }; +} diff --git a/packages/core/src/docs/topics/layers/gif.ts b/packages/core/src/docs/topics/layers/gif.ts new file mode 100644 index 0000000..3ef82b1 --- /dev/null +++ b/packages/core/src/docs/topics/layers/gif.ts @@ -0,0 +1,27 @@ +import type { DocResult } from '../../types.js'; + +export function getGifLayerDocs(): DocResult { + return { + topic: 'layer/gif', + title: 'GIF Layer', + description: 'Animated GIF images with frame-synced playback. GIF frames are synchronized to the video timeline.', + properties: { + src: { type: 'string', required: true, description: 'GIF source URL or data URI', example: 'https://example.com/animation.gif' }, + fit: { type: 'string', description: 'How GIF fits in layer bounds', default: 'cover', values: ['cover', 'contain', 'fill', 'none'] }, + loop: { type: 'boolean', description: 'Loop the GIF animation', default: true }, + speed: { type: 'number', description: 'Playback speed multiplier', default: 1, example: 1.5 }, + startFrame: { type: 'number', description: 'Start from specific GIF frame', default: 0 }, + }, + examples: [ + { + description: 'Animated sticker', + layer: { + id: 'sticker', type: 'gif', + position: { x: 100, y: 100 }, size: { width: 300, height: 300 }, + props: { src: 'https://example.com/sticker.gif', fit: 'contain', loop: true }, + }, + }, + ], + seeAlso: ['layer', 'layer/image', 'layer/lottie'], + }; +} diff --git a/packages/core/src/docs/topics/layers/group.ts b/packages/core/src/docs/topics/layers/group.ts new file mode 100644 index 0000000..8c6c759 --- /dev/null +++ b/packages/core/src/docs/topics/layers/group.ts @@ -0,0 +1,33 @@ +import type { DocResult } from '../../types.js'; + +export function getGroupLayerDocs(): DocResult { + return { + topic: 'layer/group', + title: 'Group Layer', + description: 'Container for organizing and optionally clipping child layers. Children inherit the group\'s coordinate space.', + properties: { + clip: { type: 'boolean', description: 'Clip children to group bounds', default: false }, + }, + tips: [ + 'Group layers have a children[] array instead of just props', + 'Children are positioned relative to the group\'s position', + 'Set clip: true to hide content that extends beyond group bounds', + 'Groups can be animated as a unit (all children move together)', + ], + examples: [ + { + description: 'Clipped group with children', + layer: { + id: 'card', type: 'group', + position: { x: 100, y: 100 }, size: { width: 400, height: 300 }, + props: { clip: true }, + children: [ + { id: 'card-bg', type: 'shape', position: { x: 0, y: 0 }, size: { width: 400, height: 300 }, props: { shape: 'rectangle', fill: '#ffffff', borderRadius: 16 } }, + { id: 'card-text', type: 'text', position: { x: 20, y: 20 }, size: { width: 360, height: 260 }, props: { text: 'Card content', fontSize: 24, color: '#000000' } }, + ], + }, + }, + ], + seeAlso: ['layer'], + }; +} diff --git a/packages/core/src/docs/topics/layers/image.ts b/packages/core/src/docs/topics/layers/image.ts new file mode 100644 index 0000000..c652ff2 --- /dev/null +++ b/packages/core/src/docs/topics/layers/image.ts @@ -0,0 +1,31 @@ +import type { DocResult } from '../../types.js'; + +export function getImageLayerDocs(): DocResult { + return { + topic: 'layer/image', + title: 'Image Layer', + description: 'Display images with fit modes. Supports URLs, local file paths (auto-converted to base64), and data URIs.', + properties: { + src: { type: 'string', required: true, description: 'Image source: HTTPS URL, local file path, or data URI', example: 'https://images.unsplash.com/photo-...' }, + fit: { type: 'string', description: 'How image fits in layer bounds', default: 'contain', values: ['cover', 'contain', 'fill', 'none'] }, + objectPosition: { type: 'string', description: 'CSS object-position value', example: 'center top' }, + }, + tips: [ + 'Local file paths are auto-converted to base64 data URLs', + 'Large images (>500KB) are auto-resized for performance', + 'Use absolute paths for local files, not relative', + 'Set renderWaitTime: 500 when using image layers', + ], + examples: [ + { + description: 'Full-bleed background image', + layer: { + id: 'bg', type: 'image', + position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 }, + props: { src: 'https://images.unsplash.com/photo-example', fit: 'cover' }, + }, + }, + ], + seeAlso: ['layer', 'layer/video', 'layer/gif'], + }; +} diff --git a/packages/core/src/docs/topics/layers/lottie.ts b/packages/core/src/docs/topics/layers/lottie.ts new file mode 100644 index 0000000..82ddc8b --- /dev/null +++ b/packages/core/src/docs/topics/layers/lottie.ts @@ -0,0 +1,30 @@ +import type { DocResult } from '../../types.js'; + +export function getLottieLayerDocs(): DocResult { + return { + topic: 'layer/lottie', + title: 'Lottie Layer', + description: 'Play Lottie/Bodymovin JSON animations with speed and direction control.', + properties: { + data: { type: 'object | string', required: true, description: 'Lottie JSON data object or URL to JSON file' }, + loop: { type: 'boolean', description: 'Loop animation', default: false }, + speed: { type: 'number', description: 'Playback speed multiplier', default: 1, example: 1.5 }, + direction: { type: '1 | -1', description: 'Play direction (1 = forward, -1 = reverse)', default: 1 }, + }, + tips: [ + 'Use a URL string for data to load from a CDN', + 'Lottie animations are resolution-independent (vector)', + ], + examples: [ + { + description: 'Lottie animation from URL', + layer: { + id: 'anim', type: 'lottie', + position: { x: 100, y: 100 }, size: { width: 400, height: 400 }, + props: { data: 'https://assets.lottiefiles.com/example.json', loop: true, speed: 1 }, + }, + }, + ], + seeAlso: ['layer', 'layer/gif'], + }; +} diff --git a/packages/core/src/docs/topics/layers/shape.ts b/packages/core/src/docs/topics/layers/shape.ts new file mode 100644 index 0000000..75f8bd4 --- /dev/null +++ b/packages/core/src/docs/topics/layers/shape.ts @@ -0,0 +1,50 @@ +import type { DocResult } from '../../types.js'; + +export function getShapeLayerDocs(): DocResult { + return { + topic: 'layer/shape', + title: 'Shape Layer', + description: 'Geometric shapes: rectangle, ellipse, polygon, star, and custom SVG paths.', + properties: { + shape: { type: 'ShapeType', required: true, description: 'Shape type', values: ['rectangle', 'ellipse', 'polygon', 'star', 'path'] }, + fill: { type: 'string', description: 'Fill color (hex, rgb, rgba, CSS name)', example: '#2563eb' }, + gradient: { type: 'Gradient', description: 'Fill gradient instead of solid color' }, + stroke: { type: 'string', description: 'Stroke/border color', example: '#ffffff' }, + strokeWidth: { type: 'number', description: 'Stroke width in pixels', default: 0, example: 2 }, + strokeDash: { type: 'number[]', description: 'Stroke dash pattern array', example: [10, 5] }, + borderRadius: { type: 'number', description: 'Corner radius for rectangles', default: 0, example: 16 }, + sides: { type: 'number', description: 'Number of sides (polygon only)', example: 6 }, + points: { type: 'number', description: 'Number of points (star only)', example: 5 }, + innerRadius: { type: 'number', description: 'Inner radius ratio for stars', range: '0-1', example: 0.5 }, + pathData: { type: 'string', description: 'SVG path data (path type only)', example: 'M10 80 C 40 10, 65 10, 95 80 S 150 150, 180 80' }, + }, + sections: [ + { + title: 'Gradient Configuration', + description: 'Use a gradient fill instead of a solid color', + properties: { + type: { type: '"linear" | "radial"', required: true, description: 'Gradient type' }, + colors: { type: 'GradientStop[]', required: true, description: 'Array of color stops: { offset: 0-1, color: string }' }, + angle: { type: 'number', description: 'Angle in degrees (linear gradient only)', example: 45 }, + }, + examples: [ + { type: 'linear', angle: 135, colors: [{ offset: 0, color: '#667eea' }, { offset: 1, color: '#764ba2' }] }, + ], + }, + ], + examples: [ + { + description: 'Gradient background rectangle', + layer: { + id: 'bg', type: 'shape', + position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 }, + props: { + shape: 'rectangle', + gradient: { type: 'linear', angle: 135, colors: [{ offset: 0, color: '#667eea' }, { offset: 1, color: '#764ba2' }] }, + }, + }, + }, + ], + seeAlso: ['layer', 'layer/canvas'], + }; +} diff --git a/packages/core/src/docs/topics/layers/text.ts b/packages/core/src/docs/topics/layers/text.ts new file mode 100644 index 0000000..f808dd6 --- /dev/null +++ b/packages/core/src/docs/topics/layers/text.ts @@ -0,0 +1,78 @@ +import type { DocResult } from '../../types.js'; +import { TIPS } from '../../descriptions.js'; + +export function getTextLayerDocs(): DocResult { + return { + topic: 'layer/text', + title: 'Text Layer', + description: 'Rich text with typography, fonts, alignment, spans, and text effects.', + properties: { + text: { type: 'string', required: true, description: 'Text content. Use {{variableName}} for dynamic values', example: '{{title}}' }, + fontFamily: { type: 'string', description: 'Font family name. Supports Google Fonts', default: 'Inter', example: 'Roboto' }, + fontSize: { type: 'number', description: 'Font size in pixels', default: 16, example: 48 }, + fontWeight: { type: 'string | number', description: 'Font weight', default: 'normal', values: ['normal', 'bold', '100-900'] }, + fontStyle: { type: '"normal" | "italic"', description: 'Font style', default: 'normal' }, + color: { type: 'string', description: 'Text color (hex, rgb, rgba, CSS name)', default: '#000000', example: '#ffffff' }, + textAlign: { type: 'string', description: 'Horizontal alignment', default: 'left', values: ['left', 'center', 'right', 'justify'] }, + verticalAlign: { type: 'string', description: 'Vertical alignment within layer bounds', values: ['top', 'middle', 'bottom'] }, + lineHeight: { type: 'number', description: 'Line height multiplier', default: 1.2, example: 1.5 }, + letterSpacing: { type: 'number', description: 'Letter spacing in pixels', default: 0, example: 2 }, + textTransform: { type: 'string', description: 'Text case transformation', values: ['none', 'uppercase', 'lowercase', 'capitalize'] }, + textDecoration: { type: 'string', description: 'Text decoration', values: ['none', 'underline', 'line-through'] }, + stroke: { type: '{ color: string, width: number }', description: 'Text stroke/outline', example: { color: '#000000', width: 2 } }, + textShadow: { type: '{ color, blur, offsetX, offsetY }', description: 'Text shadow effect', example: { color: 'rgba(0,0,0,0.5)', blur: 4, offsetX: 2, offsetY: 2 } }, + backgroundColor: { type: 'string', description: 'Background color behind text', example: 'rgba(0,0,0,0.5)' }, + padding: { type: 'number | { top, right, bottom, left }', description: 'Padding around text', example: 16 }, + borderRadius: { type: 'number', description: 'Border radius for background', example: 8 }, + maxLines: { type: 'number', description: 'Maximum lines (truncates with ellipsis if exceeded)', example: 3 }, + overflow: { type: 'string', description: 'Overflow behavior', values: ['visible', 'hidden', 'ellipsis'] }, + spans: { type: 'TextSpan[]', description: 'Rich text spans with per-span styling. Overrides text property when provided' }, + }, + sections: [ + { + title: 'TextSpan Properties', + description: 'Each span in the spans[] array can override these text properties', + properties: { + text: { type: 'string', required: true, description: 'Text content for this span' }, + fontFamily: { type: 'string', description: 'Override font family' }, + fontSize: { type: 'number', description: 'Override font size' }, + fontWeight: { type: 'string', description: 'Override font weight' }, + fontStyle: { type: '"normal" | "italic"', description: 'Override font style' }, + color: { type: 'string', description: 'Override text color' }, + letterSpacing: { type: 'number', description: 'Override letter spacing' }, + backgroundColor: { type: 'string', description: 'Highlight background color' }, + textDecoration: { type: 'string', description: 'Override text decoration' }, + stroke: { type: '{ color, width }', description: 'Override text stroke' }, + textShadow: { type: '{ color, blur, offsetX, offsetY }', description: 'Override text shadow' }, + }, + }, + ], + examples: [ + { + description: 'Basic text layer', + layer: { + id: 'title', type: 'text', + position: { x: 160, y: 440 }, size: { width: 1600, height: 200 }, + props: { text: 'Hello World', fontSize: 72, fontWeight: 'bold', color: '#ffffff', textAlign: 'center' }, + }, + }, + { + description: 'Rich text with spans', + layer: { + id: 'rich', type: 'text', + position: { x: 100, y: 300 }, size: { width: 800, height: 200 }, + props: { + text: '', + spans: [ + { text: 'Bold ', fontWeight: 'bold', color: '#ff0000' }, + { text: 'and ', color: '#ffffff' }, + { text: 'Italic', fontStyle: 'italic', color: '#00ff00' }, + ], + }, + }, + }, + ], + tips: TIPS.text, + seeAlso: ['layer', 'fonts', 'animations'], + }; +} diff --git a/packages/core/src/docs/topics/layers/three.ts b/packages/core/src/docs/topics/layers/three.ts new file mode 100644 index 0000000..2601e6d --- /dev/null +++ b/packages/core/src/docs/topics/layers/three.ts @@ -0,0 +1,109 @@ +import type { DocResult } from '../../types.js'; + +export function getThreeLayerDocs(): DocResult { + return { + topic: 'layer/three', + title: 'Three.js 3D Layer', + description: 'Full 3D scenes with cameras, lights, meshes, materials, textures, and shaders powered by Three.js.', + properties: { + camera: { type: 'ThreeCameraConfig', required: true, description: 'Camera configuration (perspective or orthographic)' }, + lights: { type: 'ThreeLightConfig[]', description: 'Array of lights in the scene' }, + meshes: { type: 'ThreeMeshConfig[]', required: true, description: 'Array of 3D meshes to render' }, + background: { type: 'Color | TextureConfig', description: 'Scene background color or texture' }, + fog: { type: '{ color, near, far }', description: 'Fog effect' }, + antialias: { type: 'boolean', description: 'Enable anti-aliasing', default: true }, + shadows: { type: '{ enabled, type? }', description: 'Shadow map configuration' }, + toneMapping: { type: '{ type, exposure? }', description: 'Tone mapping for HDR rendering' }, + controls: { type: 'boolean', description: 'Enable orbit controls (preview only)', default: false }, + customShader: { type: '{ vertexShader?, fragmentShader?, uniforms? }', description: 'Custom GLSL shader code' }, + }, + sections: [ + { + title: 'Camera Types', + description: 'Two camera types available', + properties: { + 'perspective.fov': { type: 'number', required: true, description: 'Field of view in degrees', example: 75 }, + 'perspective.near': { type: 'number', description: 'Near clipping plane', default: 0.1 }, + 'perspective.far': { type: 'number', description: 'Far clipping plane', default: 1000 }, + 'perspective.position': { type: '[x, y, z]', description: 'Camera position', example: [0, 0, 5] }, + 'perspective.lookAt': { type: '[x, y, z]', description: 'Look-at target', example: [0, 0, 0] }, + 'orthographic.left/right/top/bottom': { type: 'number', description: 'Frustum planes' }, + }, + }, + { + title: 'Light Types', + description: '5 light types: ambient, directional, point, spot, hemisphere', + properties: { + 'ambient': { type: 'AmbientLightConfig', description: 'Global illumination. Props: color, intensity' }, + 'directional': { type: 'DirectionalLightConfig', description: 'Parallel rays (sunlight). Props: position, target, castShadow' }, + 'point': { type: 'PointLightConfig', description: 'Omnidirectional from a point. Props: position, distance, decay' }, + 'spot': { type: 'SpotLightConfig', description: 'Cone-shaped. Props: position, target, angle, penumbra' }, + 'hemisphere': { type: 'HemisphereLightConfig', description: 'Sky + ground colors. Props: groundColor' }, + }, + }, + { + title: 'Geometry Types', + description: '8 geometry types for meshes', + properties: { + box: { type: 'BoxGeometry', description: 'Box/cube. Props: width, height, depth' }, + sphere: { type: 'SphereGeometry', description: 'Sphere. Props: radius, widthSegments, heightSegments' }, + cylinder: { type: 'CylinderGeometry', description: 'Cylinder. Props: radiusTop, radiusBottom, height' }, + cone: { type: 'ConeGeometry', description: 'Cone. Props: radius, height' }, + torus: { type: 'TorusGeometry', description: 'Donut/torus. Props: radius, tube' }, + plane: { type: 'PlaneGeometry', description: 'Flat plane. Props: width, height' }, + gltf: { type: 'GLTFGeometry', description: '3D model from GLTF/GLB file. Props: url, autoPlay, animationSpeed' }, + text3d: { type: 'Text3DGeometry', description: 'Extruded 3D text. Props: text, font (URL), size, height, bevel' }, + }, + }, + { + title: 'Material Types', + description: '6 material types for mesh surfaces', + properties: { + standard: { type: 'StandardMaterialConfig', description: 'PBR material. Props: metalness, roughness, map, normalMap, emissive' }, + basic: { type: 'BasicMaterialConfig', description: 'Unlit material. Props: map, envMap' }, + phong: { type: 'PhongMaterialConfig', description: 'Classic Phong shading. Props: specular, shininess' }, + physical: { type: 'PhysicalMaterialConfig', description: 'Advanced PBR. Props: clearcoat, sheen, transmission, thickness' }, + normal: { type: 'NormalMaterialConfig', description: 'Display surface normals as RGB' }, + matcap: { type: 'MatCapMaterialConfig', description: 'Material capture. Props: matcap (texture)' }, + }, + }, + { + title: 'Mesh Configuration', + description: 'Each mesh combines geometry + material + transform', + properties: { + id: { type: 'string', required: true, description: 'Unique mesh identifier' }, + geometry: { type: 'ThreeGeometry', required: true, description: 'Geometry configuration' }, + material: { type: 'ThreeMaterialConfig', required: true, description: 'Material configuration' }, + position: { type: '[x, y, z]', description: 'Mesh position', default: [0, 0, 0] }, + rotation: { type: '[x, y, z]', description: 'Euler rotation in radians' }, + scale: { type: '[x, y, z]', description: 'Scale', default: [1, 1, 1] }, + castShadow: { type: 'boolean', description: 'Cast shadows', default: false }, + receiveShadow: { type: 'boolean', description: 'Receive shadows', default: false }, + autoRotate: { type: '[x, y, z]', description: 'Auto-rotation speed per frame', example: [0.01, 0.01, 0] }, + }, + }, + ], + examples: [ + { + description: 'Rotating cube with lighting', + layer: { + id: '3d-scene', type: 'three', + position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 }, + props: { + camera: { type: 'perspective', fov: 75, position: [0, 0, 5] }, + lights: [ + { type: 'ambient', intensity: 0.5 }, + { type: 'directional', position: [5, 5, 5], intensity: 1 }, + ], + meshes: [{ + id: 'cube', geometry: { type: 'box', width: 1, height: 1, depth: 1 }, + material: { type: 'standard', color: '#ff0000', metalness: 0.5, roughness: 0.5 }, + autoRotate: [0.01, 0.01, 0], + }], + }, + }, + }, + ], + seeAlso: ['layer'], + }; +} diff --git a/packages/core/src/docs/topics/layers/video.ts b/packages/core/src/docs/topics/layers/video.ts new file mode 100644 index 0000000..27bbaa1 --- /dev/null +++ b/packages/core/src/docs/topics/layers/video.ts @@ -0,0 +1,32 @@ +import type { DocResult } from '../../types.js'; +import { TIPS } from '../../descriptions.js'; + +export function getVideoLayerDocs(): DocResult { + return { + topic: 'layer/video', + title: 'Video Layer', + description: 'Video playback with timing, speed, volume, and fit controls.', + properties: { + src: { type: 'string', required: true, description: 'Video source URL or local file path', example: '/Users/name/video.mp4' }, + fit: { type: 'string', description: 'How video fits in layer bounds', default: 'contain', values: ['cover', 'contain', 'fill'] }, + loop: { type: 'boolean', description: 'Loop video playback', default: false }, + muted: { type: 'boolean', description: 'Mute audio track', default: false }, + playbackRate: { type: 'number', description: 'Playback speed multiplier (1 = normal)', default: 1, example: 0.5 }, + startTime: { type: 'number', description: 'Start playback at this time in seconds', default: 0, example: 5 }, + endTime: { type: 'number', description: 'Stop playback at this time in seconds' }, + volume: { type: 'number', description: 'Audio volume', default: 1, range: '0-1' }, + }, + tips: TIPS.video, + examples: [ + { + description: 'Background video layer', + layer: { + id: 'bg-video', type: 'video', + position: { x: 0, y: 0 }, size: { width: 1920, height: 1080 }, + props: { src: '/path/to/video.mp4', fit: 'cover', muted: true, loop: true }, + }, + }, + ], + seeAlso: ['layer', 'layer/image', 'layer/audio'], + }; +} diff --git a/packages/core/src/docs/topics/motion-blur.ts b/packages/core/src/docs/topics/motion-blur.ts new file mode 100644 index 0000000..764c5a7 --- /dev/null +++ b/packages/core/src/docs/topics/motion-blur.ts @@ -0,0 +1,54 @@ +import type { DocResult } from '../types.js'; +import { TIPS } from '../descriptions.js'; + +export function getMotionBlurDocs(): DocResult { + return { + topic: 'motion-blur', + title: 'Motion Blur', + description: 'Cinematic motion blur via temporal supersampling. Can be configured globally, per-scene, or per-layer.', + properties: { + enabled: { type: 'boolean', required: true, description: 'Enable/disable motion blur' }, + quality: { type: '"low" | "medium" | "high" | "ultra"', description: 'Quality preset (overrides samples/shutterAngle)', default: 'medium' }, + samples: { type: 'number', description: 'Temporal samples per frame (higher = smoother)', default: 10, range: '2-32' }, + shutterAngle: { type: 'number', description: 'Shutter angle in degrees. 180° = cinematic, 360° = max blur', default: 180, range: '0-360' }, + adaptive: { type: 'boolean', description: 'Reduce samples on static frames (saves 30-50%)', default: false }, + minSamples: { type: 'number', description: 'Minimum samples for adaptive mode', default: 3, range: '2-32' }, + motionThreshold: { type: 'number', description: 'Motion detection sensitivity for adaptive', default: 0.01, range: '0.0001-1.0' }, + stochastic: { type: 'boolean', description: 'Random sampling to reduce banding artifacts', default: false }, + blurAmount: { type: 'number', description: 'Blur multiplier', default: 1.0, range: '0-2' }, + blurAxis: { type: '"x" | "y" | "both"', description: 'Blur direction', default: 'both' }, + variableSampleRate: { type: 'boolean', description: 'Auto-adjust samples based on motion magnitude', default: false }, + maxSamples: { type: 'number', description: 'Maximum samples for variable rate mode', range: '2-32' }, + preview: { type: 'boolean', description: 'Preview mode: 2 samples for fast iteration', default: false }, + }, + sections: [ + { + title: 'Quality Presets', + description: 'Quick presets that set samples and shutterAngle', + properties: { + low: { type: 'preset', description: '5 samples, 180° shutter. ~5× render time' }, + medium: { type: 'preset', description: '10 samples, 180° shutter. ~10× render time' }, + high: { type: 'preset', description: '16 samples, 180° shutter. ~16× render time' }, + ultra: { type: 'preset', description: '32 samples, 180° shutter. ~32× render time' }, + }, + }, + { + title: 'Configuration Hierarchy', + description: 'Motion blur merges: layer > scene > global (render tool parameter)', + tips: [ + 'Set on render_video/start_render_async motionBlur parameter for global', + 'Set on scene.motionBlur for scene-level override', + 'Set on layer.motionBlur for layer-level override', + 'Any config with enabled: false disables blur at that level', + ], + }, + ], + examples: [ + { description: 'Simple quality preset', motionBlur: { enabled: true, quality: 'medium' } }, + { description: 'Optimized high quality', motionBlur: { enabled: true, quality: 'high', adaptive: true, stochastic: true } }, + { description: 'Preview mode', motionBlur: { enabled: true, preview: true } }, + ], + tips: TIPS.motionBlur, + seeAlso: ['layer'], + }; +} diff --git a/packages/core/src/docs/topics/overview.ts b/packages/core/src/docs/topics/overview.ts new file mode 100644 index 0000000..a5c8c8e --- /dev/null +++ b/packages/core/src/docs/topics/overview.ts @@ -0,0 +1,47 @@ +import type { DocResult } from '../types.js'; +import { LAYER_DESCRIPTIONS, TRANSITION_DESCRIPTIONS } from '../descriptions.js'; +import { getAllPresetNames, getPresetsByType, getAllEasingNames } from '../../animation/index.js'; + +export function getOverviewDocs(): DocResult { + const entranceCount = getPresetsByType('entrance').length; + const exitCount = getPresetsByType('exit').length; + const emphasisCount = getPresetsByType('emphasis').length; + const easingCount = getAllEasingNames().length; + const transitionCount = Object.keys(TRANSITION_DESCRIPTIONS).length; + + return { + topic: 'overview', + title: 'Rendervid Overview', + description: 'Rendervid is a programmatic video/image generation engine. Templates are JSON objects with scenes, layers, animations, and inputs.', + sections: [ + { + title: 'Layer Types', + description: '12 layer types available', + properties: Object.fromEntries( + Object.entries(LAYER_DESCRIPTIONS).map(([key, desc]) => [ + key, + { type: 'layer', description: desc }, + ]) + ), + }, + { + title: 'Capabilities Summary', + description: 'Animation presets, easings, transitions, and output formats', + properties: { + animationPresets: { type: 'number', description: `${entranceCount + exitCount + emphasisCount} total: ${entranceCount} entrance, ${exitCount} exit, ${emphasisCount} emphasis` }, + easingFunctions: { type: 'number', description: `${easingCount} built-in easings + custom cubic-bezier() and spring()` }, + sceneTransitions: { type: 'number', description: `${transitionCount} transition types between scenes` }, + videoFormats: { type: 'string', description: 'MP4, WebM, MOV, GIF' }, + imageFormats: { type: 'string', description: 'PNG, JPEG, WebP' }, + maxResolution: { type: 'string', description: '7680x4320 (8K)' }, + }, + }, + ], + tips: [ + 'Start with get_docs({ topic: "template" }) to learn the template structure', + 'Use get_docs({ topic: "layer/" }) for layer-specific properties', + 'Use get_docs({ topic: "animations" }) for animation presets', + ], + seeAlso: ['template', 'layer', 'animations', 'easings', 'transitions'], + }; +} diff --git a/packages/core/src/docs/topics/scene.ts b/packages/core/src/docs/topics/scene.ts new file mode 100644 index 0000000..ea3cc69 --- /dev/null +++ b/packages/core/src/docs/topics/scene.ts @@ -0,0 +1,62 @@ +import type { DocResult } from '../types.js'; +import { TRANSITION_DESCRIPTIONS, TIPS } from '../descriptions.js'; + +export function getSceneDocs(): DocResult { + return { + topic: 'scene', + title: 'Scene Configuration', + description: 'Scenes are segments of a composition with their own layers, timing, and optional transitions.', + properties: { + id: { type: 'string', required: true, description: 'Unique scene identifier', example: 'intro' }, + name: { type: 'string', description: 'Display name' }, + startFrame: { type: 'number', required: true, description: 'Start frame (0-based, inclusive)', example: 0 }, + endFrame: { type: 'number', required: true, description: 'End frame (exclusive). Duration = endFrame - startFrame', example: 150 }, + backgroundColor: { type: 'string', description: 'Scene background color', example: '#1a1a2e' }, + backgroundImage: { type: 'string', description: 'Background image URL' }, + backgroundFit: { type: '"cover" | "contain" | "fill" | "none"', description: 'Background image fit mode' }, + backgroundVideo: { type: 'string', description: 'Background video URL' }, + transition: { type: 'SceneTransition', description: 'Transition to next scene' }, + motionBlur: { type: 'MotionBlurConfig', description: 'Scene-level motion blur override' }, + hidden: { type: 'boolean', description: 'Skip this scene during rendering', default: false }, + layers: { type: 'Layer[]', required: true, description: 'Layers in this scene' }, + }, + sections: [ + { + title: 'Scene Transitions', + description: 'Configure how one scene transitions to the next. Set on the outgoing scene.', + properties: { + type: { type: 'TransitionType', required: true, description: 'Transition effect type', values: Object.keys(TRANSITION_DESCRIPTIONS) }, + duration: { type: 'number', required: true, description: 'Transition duration in frames', example: 30 }, + direction: { type: '"left" | "right" | "up" | "down"', description: 'Direction for directional transitions (slide, wipe, push)' }, + easing: { type: 'string', description: 'Easing function for the transition', example: 'easeInOutCubic' }, + spring: { type: '{ mass?, stiffness?, damping? }', description: 'Spring-based timing (replaces easing)' }, + }, + }, + { + title: 'Transition Types', + description: `${Object.keys(TRANSITION_DESCRIPTIONS).length} available transition types`, + properties: Object.fromEntries( + Object.entries(TRANSITION_DESCRIPTIONS).map(([key, desc]) => [ + key, + { type: 'TransitionType', description: desc }, + ]) + ), + }, + ], + examples: [ + { + description: 'Scene with fade transition', + scene: { + id: 'scene-1', + startFrame: 0, + endFrame: 150, + backgroundColor: '#1a1a2e', + transition: { type: 'fade', duration: 30, easing: 'easeInOutCubic' }, + layers: [], + }, + }, + ], + tips: TIPS.scene, + seeAlso: ['template', 'layer', 'transitions'], + }; +} diff --git a/packages/core/src/docs/topics/style.ts b/packages/core/src/docs/topics/style.ts new file mode 100644 index 0000000..d8afaa8 --- /dev/null +++ b/packages/core/src/docs/topics/style.ts @@ -0,0 +1,111 @@ +import type { DocResult } from '../types.js'; + +export function getStyleDocs(): DocResult { + return { + topic: 'style', + title: 'Layer Style (Tailwind-like)', + description: 'Tailwind-like utility style properties available on any layer via the style field.', + sections: [ + { + title: 'Spacing', + description: 'Padding and margin utilities', + properties: { + padding: { type: 'string | number', description: 'Padding (px)', example: 16 }, + paddingX: { type: 'string | number', description: 'Horizontal padding' }, + paddingY: { type: 'string | number', description: 'Vertical padding' }, + paddingTop: { type: 'string | number', description: 'Top padding' }, + paddingRight: { type: 'string | number', description: 'Right padding' }, + paddingBottom: { type: 'string | number', description: 'Bottom padding' }, + paddingLeft: { type: 'string | number', description: 'Left padding' }, + margin: { type: 'string | number', description: 'Margin' }, + marginX: { type: 'string | number', description: 'Horizontal margin' }, + marginY: { type: 'string | number', description: 'Vertical margin' }, + }, + }, + { + title: 'Borders', + description: 'Border and border radius utilities', + properties: { + borderRadius: { type: 'string | number', description: 'Border radius (px or preset like "lg", "full")', example: 16 }, + borderWidth: { type: 'number', description: 'Border width in pixels' }, + borderColor: { type: 'string', description: 'Border color' }, + borderStyle: { type: 'string', description: 'Border style', values: ['solid', 'dashed', 'dotted', 'none'] }, + }, + }, + { + title: 'Shadows', + description: 'Box shadow utilities', + properties: { + boxShadow: { type: 'ShadowPreset | string', description: 'Box shadow', values: ['sm', 'md', 'lg', 'xl', '2xl'], example: 'lg' }, + }, + }, + { + title: 'Backgrounds', + description: 'Background utilities', + properties: { + backgroundColor: { type: 'string', description: 'Background color', example: '#1a1a2e' }, + backgroundGradient: { type: 'BackgroundGradient', description: 'Background gradient: { type, from, via?, to, direction? }' }, + backgroundImage: { type: 'string', description: 'Background image URL' }, + backgroundSize: { type: 'string', description: 'Background size', values: ['cover', 'contain', 'auto'] }, + backdropBlur: { type: 'BlurPreset | number', description: 'Backdrop blur', values: ['sm', 'md', 'lg'] }, + }, + }, + { + title: 'Typography', + description: 'Text styling utilities (in style, not props)', + properties: { + fontFamily: { type: 'string', description: 'Font family' }, + fontSize: { type: 'string | number', description: 'Font size' }, + fontWeight: { type: 'string | number', description: 'Font weight' }, + lineHeight: { type: 'string | number', description: 'Line height' }, + letterSpacing: { type: 'string | number', description: 'Letter spacing' }, + textAlign: { type: 'string', description: 'Text alignment', values: ['left', 'center', 'right', 'justify'] }, + textColor: { type: 'string', description: 'Text color' }, + textShadow: { type: 'string', description: 'Text shadow (CSS value)' }, + textDecoration: { type: 'string', description: 'Text decoration', values: ['none', 'underline', 'line-through'] }, + textTransform: { type: 'string', description: 'Text transform', values: ['none', 'uppercase', 'lowercase', 'capitalize'] }, + }, + }, + { + title: 'Layout', + description: 'Flexbox and grid utilities', + properties: { + display: { type: 'string', description: 'Display type', values: ['flex', 'grid', 'block', 'inline', 'none'] }, + flexDirection: { type: 'string', description: 'Flex direction', values: ['row', 'column', 'row-reverse', 'column-reverse'] }, + justifyContent: { type: 'string', description: 'Justify content', values: ['start', 'center', 'end', 'between', 'around', 'evenly'] }, + alignItems: { type: 'string', description: 'Align items', values: ['start', 'center', 'end', 'stretch', 'baseline'] }, + gap: { type: 'string | number', description: 'Gap between items' }, + }, + }, + { + title: 'Effects', + description: 'Filter-like effects via style', + properties: { + blur: { type: 'BlurPreset | number', description: 'Blur effect' }, + brightness: { type: 'number', description: 'Brightness (100 = normal)', range: '0-200' }, + contrast: { type: 'number', description: 'Contrast (100 = normal)', range: '0-200' }, + grayscale: { type: 'number', description: 'Grayscale', range: '0-100' }, + saturate: { type: 'number', description: 'Saturation (100 = normal)', range: '0-200' }, + sepia: { type: 'number', description: 'Sepia tone', range: '0-100' }, + hueRotate: { type: 'number', description: 'Hue rotation', range: '0-360' }, + invert: { type: 'number', description: 'Color inversion', range: '0-100' }, + }, + }, + { + title: 'Overflow', + description: 'Overflow control', + properties: { + overflow: { type: 'string', description: 'Overflow behavior', values: ['visible', 'hidden', 'scroll', 'auto'] }, + }, + }, + { + title: 'Raw CSS', + description: 'Pass-through CSS properties', + properties: { + css: { type: 'CSSProperties', description: 'Raw React CSSProperties for anything not covered above' }, + }, + }, + ], + seeAlso: ['layer', 'filters'], + }; +} diff --git a/packages/core/src/docs/topics/template.ts b/packages/core/src/docs/topics/template.ts new file mode 100644 index 0000000..3d37d46 --- /dev/null +++ b/packages/core/src/docs/topics/template.ts @@ -0,0 +1,69 @@ +import type { DocResult } from '../types.js'; +import { TIPS } from '../descriptions.js'; + +export function getTemplateDocs(): DocResult { + return { + topic: 'template', + title: 'Template Structure', + description: 'A Rendervid template is a JSON object that defines the complete structure of a video or image.', + properties: { + name: { type: 'string', required: true, description: 'Template name', example: 'My Video' }, + description: { type: 'string', description: 'What this template creates' }, + version: { type: 'string', description: 'Semantic version', example: '1.0.0' }, + output: { type: 'OutputConfig', required: true, description: 'Output configuration (dimensions, fps, duration)' }, + inputs: { type: 'InputDefinition[]', required: true, description: 'Customizable input definitions. Use [] for static templates', example: [] }, + defaults: { type: 'Record', description: 'Default values for inputs', example: { title: 'Hello World' } }, + customComponents: { type: 'Record', description: 'Custom React components used in this template' }, + fonts: { type: 'FontConfiguration', description: 'Google Fonts and custom font configuration' }, + composition: { type: 'Composition', required: true, description: 'Scenes and layers that make up the content' }, + }, + sections: [ + { + title: 'Output Configuration', + description: 'The output field controls dimensions, format, and timing', + properties: { + type: { type: '"video" | "image"', required: true, description: 'Output type', example: 'video' }, + width: { type: 'number', required: true, description: 'Canvas width in pixels', example: 1920 }, + height: { type: 'number', required: true, description: 'Canvas height in pixels', example: 1080 }, + fps: { type: 'number', description: 'Frames per second (video only)', default: 30, example: 30 }, + duration: { type: 'number', description: 'Duration in seconds (video only)', example: 5 }, + backgroundColor: { type: 'string', description: 'Background color', default: '#000000' }, + }, + }, + { + title: 'Composition', + description: 'Contains scenes array and optional assets', + properties: { + scenes: { type: 'Scene[]', required: true, description: 'Array of scenes in chronological order' }, + assets: { type: 'AssetDefinition[]', description: 'Global assets to preload (images, videos, audio, fonts)' }, + }, + }, + ], + examples: [ + { + description: 'Minimal static template', + template: { + name: 'Hello World', + output: { type: 'video', width: 1920, height: 1080, fps: 30, duration: 3 }, + inputs: [], + composition: { + scenes: [{ + id: 'main', + startFrame: 0, + endFrame: 90, + layers: [{ + id: 'title', + type: 'text', + position: { x: 160, y: 440 }, + size: { width: 1600, height: 200 }, + props: { text: 'Hello World', fontSize: 72, color: '#ffffff', textAlign: 'center' }, + }], + }], + }, + }, + }, + ], + tips: TIPS.template, + seeAlso: ['scene', 'layer', 'inputs', 'fonts'], + }; +} diff --git a/packages/core/src/docs/topics/transitions.ts b/packages/core/src/docs/topics/transitions.ts new file mode 100644 index 0000000..75d416e --- /dev/null +++ b/packages/core/src/docs/topics/transitions.ts @@ -0,0 +1,34 @@ +import type { DocResult } from '../types.js'; +import { TRANSITION_DESCRIPTIONS } from '../descriptions.js'; + +export function getTransitionsDocs(): DocResult { + return { + topic: 'transitions', + title: 'Scene Transitions', + description: `${Object.keys(TRANSITION_DESCRIPTIONS).length} transition types for switching between scenes.`, + properties: Object.fromEntries( + Object.entries(TRANSITION_DESCRIPTIONS).map(([key, desc]) => [ + key, { type: 'TransitionType', description: desc }, + ]) + ), + sections: [ + { + title: 'SceneTransition Structure', + description: 'How to configure a transition on a scene', + properties: { + type: { type: 'TransitionType', required: true, description: 'Transition effect type' }, + duration: { type: 'number', required: true, description: 'Duration in frames', example: 30 }, + direction: { type: '"left" | "right" | "up" | "down"', description: 'Direction for slide, wipe, push transitions' }, + easing: { type: 'string', description: 'Easing function', example: 'easeInOutCubic' }, + spring: { type: '{ mass?, stiffness?, damping? }', description: 'Spring-based timing (alternative to easing)' }, + }, + }, + ], + examples: [ + { transition: { type: 'fade', duration: 30 } }, + { transition: { type: 'slide', duration: 20, direction: 'left', easing: 'easeOutCubic' } }, + { transition: { type: 'cube', duration: 45, easing: 'easeInOutBack' } }, + ], + seeAlso: ['scene', 'easings'], + }; +} diff --git a/packages/core/src/docs/types.ts b/packages/core/src/docs/types.ts new file mode 100644 index 0000000..5b39598 --- /dev/null +++ b/packages/core/src/docs/types.ts @@ -0,0 +1,66 @@ +/** + * Documentation system types for Rendervid. + * Used by the get_docs MCP tool to return structured documentation. + */ + +/** + * A single documented property. + */ +export interface DocProperty { + /** TypeScript type string */ + type: string; + /** Whether the property is required */ + required?: boolean; + /** Default value */ + default?: unknown; + /** Human-readable description */ + description: string; + /** Example value */ + example?: unknown; + /** Allowed values (for union/enum types) */ + values?: string[]; + /** Value range description (e.g., "0-1", "0-360") */ + range?: string; +} + +/** + * A section of documentation with optional properties and subsections. + */ +export interface DocSection { + /** Section title */ + title: string; + /** Section description */ + description: string; + /** Properties documented in this section */ + properties?: Record; + /** Nested subsections */ + subsections?: Record; + /** Example snippets */ + examples?: unknown[]; + /** Tips and recommendations */ + tips?: string[]; +} + +/** + * Result returned by getDocumentation() for any topic. + */ +export interface DocResult { + /** Topic identifier (e.g., "layer/text") */ + topic: string; + /** Human-readable title */ + title: string; + /** Brief description */ + description: string; + /** Top-level sections */ + sections?: DocSection[]; + /** Top-level properties (for simple topics) */ + properties?: Record; + /** List items (for enumeration topics like animations, easings) */ + items?: unknown[]; + /** Tips and best practices */ + tips?: string[]; + /** Example snippets */ + examples?: unknown[]; + /** Related topics to explore */ + seeAlso?: string[]; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3c45a26..9fb8508 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -280,3 +280,12 @@ export { getAvailableCategories, type InitTemplateOptions, } from './utils/template-cli'; + +// Documentation +export { + getDocumentation, + getDocumentationTopics, + type DocResult, + type DocSection, + type DocProperty, +} from './docs/index'; diff --git a/packages/core/src/types/three.ts b/packages/core/src/types/three.ts index 0c91d9b..9e45cd9 100644 --- a/packages/core/src/types/three.ts +++ b/packages/core/src/types/three.ts @@ -591,6 +591,39 @@ export interface ThreeMeshConfig { renderOrder?: number; /** Auto-rotation speed [x, y, z] per frame */ autoRotate?: Vector3; + /** Physics rigid body configuration */ + rigidBody?: { + type: 'dynamic' | 'static' | 'kinematic'; + mass?: number; + linearVelocity?: Vector3; + angularVelocity?: Vector3; + linearDamping?: number; + angularDamping?: number; + gravityScale?: number; + friction?: number; + restitution?: number; + }; + /** Physics collider configuration */ + collider?: { + type: 'cuboid' | 'sphere' | 'capsule'; + halfExtents?: Vector3; + radius?: number; + halfHeight?: number; + }; + /** Keyframe animations */ + animations?: Array<{ + property: string; + keyframes: Array<{ + frame: number; + value: number | number[]; + easing?: string; + }>; + }>; + /** Behavior presets */ + behaviors?: Array<{ + type: string; + params?: Record; + }>; } // ═══════════════════════════════════════════════════════════════ @@ -678,6 +711,43 @@ export interface ThreeLayerProps { /** Uniform values */ uniforms?: Record; }; + + /** + * Physics world configuration. + */ + physics?: { + /** Enable physics simulation */ + enabled: boolean; + /** Gravity vector [x, y, z] */ + gravity?: Vector3; + /** Fixed timestep for physics simulation */ + timestep?: number; + /** Show debug visualization */ + debug?: boolean; + }; + + /** + * Particle systems. + */ + particles?: Array<{ + id: string; + count: number; + position: Vector3; + lifetime: number; + velocity: { min: Vector3; max: Vector3 }; + size: number; + color: string; + }>; + + /** + * Post-processing effects (MVP - basic bloom only). + */ + postProcessing?: { + bloom?: { + intensity?: number; + threshold?: number; + }; + }; } // ═══════════════════════════════════════════════════════════════ diff --git a/packages/physics/package.json b/packages/physics/package.json new file mode 100644 index 0000000..935a4f0 --- /dev/null +++ b/packages/physics/package.json @@ -0,0 +1,33 @@ +{ + "name": "@rendervid/physics", + "version": "0.1.0", + "description": "Physics engine integration for Rendervid", + "main": "./dist/index.js", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.mjs", + "require": "./dist/index.js" + } + }, + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "test": "vitest", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@dimforge/rapier3d-compat": "^0.11.2" + }, + "devDependencies": { + "@rendervid/core": "workspace:*", + "tsup": "^8.0.1", + "typescript": "^5.3.3", + "vitest": "^1.2.0" + }, + "peerDependencies": { + "@rendervid/core": "workspace:*" + } +} diff --git a/packages/physics/src/__tests__/RapierPhysicsEngine.test.ts b/packages/physics/src/__tests__/RapierPhysicsEngine.test.ts new file mode 100644 index 0000000..4c50978 --- /dev/null +++ b/packages/physics/src/__tests__/RapierPhysicsEngine.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPhysicsEngine } from '../index'; +import type { PhysicsEngine } from '../types'; + +describe('RapierPhysicsEngine', () => { + let engine: PhysicsEngine; + + beforeEach(async () => { + engine = createPhysicsEngine('rapier3d', { + gravity: [0, -9.81, 0], + }); + await engine.init(); + }); + + it('should create a dynamic rigid body', () => { + const body = engine.createRigidBody({ + id: 'test-body', + type: 'dynamic', + position: [0, 5, 0], + mass: 1, + }); + + expect(body.id).toBe('test-body'); + expect(body.getPosition()).toEqual([0, 5, 0]); + }); + + it('should create a collider', () => { + const body = engine.createRigidBody({ + id: 'test-body', + type: 'dynamic', + position: [0, 5, 0], + }); + + const collider = engine.createCollider('test-body', { + type: 'sphere', + radius: 0.5, + friction: 0.5, + restitution: 0.8, + }); + + expect(collider.id).toBe('test-body_collider'); + }); + + it('should simulate physics', () => { + const body = engine.createRigidBody({ + id: 'falling-body', + type: 'dynamic', + position: [0, 10, 0], + mass: 1, + }); + + engine.createCollider('falling-body', { + type: 'sphere', + radius: 0.5, + }); + + const initialY = body.getPosition()[1]; + + // Simulate for 1 second + for (let i = 0; i < 60; i++) { + engine.step(1 / 60); + } + + const finalY = body.getPosition()[1]; + + // Body should have fallen due to gravity + expect(finalY).toBeLessThan(initialY); + }); + + it('should apply impulse', () => { + const body = engine.createRigidBody({ + id: 'test-body', + type: 'dynamic', + position: [0, 0, 0], + mass: 1, + }); + + body.applyImpulse([0, 10, 0]); + + const velocity = body.getLinearVelocity(); + expect(velocity[1]).toBeGreaterThan(0); + }); + + it('should perform raycast', () => { + engine.createRigidBody({ + id: 'target', + type: 'static', + position: [0, 0, 0], + }); + + engine.createCollider('target', { + type: 'sphere', + radius: 1, + }); + + const hit = engine.raycast([0, 5, 0], [0, -1, 0], 10); + + expect(hit).not.toBeNull(); + expect(hit!.bodyId).toBe('target'); + }); +}); diff --git a/packages/physics/src/engines/rapier3d/RapierPhysicsEngine.ts b/packages/physics/src/engines/rapier3d/RapierPhysicsEngine.ts new file mode 100644 index 0000000..0df707c --- /dev/null +++ b/packages/physics/src/engines/rapier3d/RapierPhysicsEngine.ts @@ -0,0 +1,239 @@ +import RAPIER from '@dimforge/rapier3d-compat'; +import type { + PhysicsEngine, + RigidBodyConfig, + ColliderConfig, + RigidBody, + Collider, + RaycastHit, + PhysicsWorldConfig, + Vector3, + Quaternion, + CollisionCallback, +} from '../../types.js'; + +export class RapierPhysicsEngine implements PhysicsEngine { + private world: RAPIER.World | null = null; + private bodies = new Map(); + private bodyWrappers = new Map(); + private collisionCallbacks = new Map(); + + constructor(private config: PhysicsWorldConfig) {} + + async init(): Promise { + await RAPIER.init(); + const gravity = new RAPIER.Vector3( + this.config.gravity[0], + this.config.gravity[1], + this.config.gravity[2] + ); + this.world = new RAPIER.World(gravity); + } + + step(deltaTime: number): void { + if (!this.world) return; + this.world.step(); + } + + createRigidBody(config: RigidBodyConfig): RigidBody { + if (!this.world) throw new Error('Physics world not initialized'); + + let desc: RAPIER.RigidBodyDesc; + + if (config.type === 'dynamic') { + desc = RAPIER.RigidBodyDesc.dynamic(); + } else if (config.type === 'static') { + desc = RAPIER.RigidBodyDesc.fixed(); + } else { + desc = RAPIER.RigidBodyDesc.kinematicPositionBased(); + } + + if (config.position) { + desc.setTranslation(config.position[0], config.position[1], config.position[2]); + } + + const rapierBody = this.world.createRigidBody(desc); + this.bodies.set(config.id, rapierBody); + + const wrapper = new RapierRigidBody(config.id, rapierBody); + this.bodyWrappers.set(config.id, wrapper); + + return wrapper; + } + + createCollider(bodyId: string, config: ColliderConfig): Collider { + if (!this.world) throw new Error('Physics world not initialized'); + + const body = this.bodies.get(bodyId); + if (!body) throw new Error(`Body ${bodyId} not found`); + + let desc: RAPIER.ColliderDesc; + + if (config.type === 'cuboid' && config.halfExtents) { + desc = RAPIER.ColliderDesc.cuboid( + config.halfExtents[0], + config.halfExtents[1], + config.halfExtents[2] + ); + } else if (config.type === 'sphere' && config.radius) { + desc = RAPIER.ColliderDesc.ball(config.radius); + } else if (config.type === 'capsule' && config.halfHeight && config.radius) { + desc = RAPIER.ColliderDesc.capsule(config.halfHeight, config.radius); + } else { + throw new Error(`Unsupported collider type: ${config.type}`); + } + + if (config.friction !== undefined) { + desc.setFriction(config.friction); + } + + if (config.restitution !== undefined) { + desc.setRestitution(config.restitution); + } + + const rapierCollider = this.world.createCollider(desc, body); + const colliderId = `${bodyId}_collider`; + + return new RapierCollider(colliderId, rapierCollider); + } + + raycast(origin: Vector3, direction: Vector3, maxDistance: number): RaycastHit | null { + if (!this.world) return null; + + const ray = new RAPIER.Ray( + { x: origin[0], y: origin[1], z: origin[2] }, + { x: direction[0], y: direction[1], z: direction[2] } + ); + + const hit = this.world.castRay(ray, maxDistance, true); + if (!hit) return null; + + const hitPoint = ray.pointAt(hit.toi); + + return { + point: [hitPoint.x, hitPoint.y, hitPoint.z], + normal: [0, 1, 0], + distance: hit.toi, + bodyId: 'unknown', + }; + } + + addCollisionCallback(bodyId: string, callback: CollisionCallback): void { + if (!this.collisionCallbacks.has(bodyId)) { + this.collisionCallbacks.set(bodyId, []); + } + this.collisionCallbacks.get(bodyId)!.push(callback); + } + + destroy(): void { + if (this.world) { + this.world.free(); + this.world = null; + } + this.bodies.clear(); + this.bodyWrappers.clear(); + this.collisionCallbacks.clear(); + } +} + +class RapierRigidBody implements RigidBody { + constructor( + public id: string, + private body: RAPIER.RigidBody + ) {} + + setPosition(position: Vector3): void { + this.body.setTranslation({ x: position[0], y: position[1], z: position[2] }, true); + } + + setRotation(rotation: Quaternion): void { + this.body.setRotation({ x: rotation[0], y: rotation[1], z: rotation[2], w: rotation[3] }, true); + } + + setLinearVelocity(velocity: Vector3): void { + this.body.setLinvel({ x: velocity[0], y: velocity[1], z: velocity[2] }, true); + } + + setAngularVelocity(velocity: Vector3): void { + this.body.setAngvel({ x: velocity[0], y: velocity[1], z: velocity[2] }, true); + } + + applyImpulse(impulse: Vector3, point?: Vector3): void { + if (point) { + this.body.applyImpulseAtPoint( + { x: impulse[0], y: impulse[1], z: impulse[2] }, + { x: point[0], y: point[1], z: point[2] }, + true + ); + } else { + this.body.applyImpulse({ x: impulse[0], y: impulse[1], z: impulse[2] }, true); + } + } + + applyForce(force: Vector3, point?: Vector3): void { + if (point) { + this.body.addForceAtPoint( + { x: force[0], y: force[1], z: force[2] }, + { x: point[0], y: point[1], z: point[2] }, + true + ); + } else { + this.body.addForce({ x: force[0], y: force[1], z: force[2] }, true); + } + } + + applyTorque(torque: Vector3): void { + this.body.addTorque({ x: torque[0], y: torque[1], z: torque[2] }, true); + } + + getPosition(): Vector3 { + const pos = this.body.translation(); + return [pos.x, pos.y, pos.z]; + } + + getRotation(): Quaternion { + const rot = this.body.rotation(); + return [rot.x, rot.y, rot.z, rot.w]; + } + + getLinearVelocity(): Vector3 { + const vel = this.body.linvel(); + return [vel.x, vel.y, vel.z]; + } + + getAngularVelocity(): Vector3 { + const vel = this.body.angvel(); + return [vel.x, vel.y, vel.z]; + } + + setEnabled(enabled: boolean): void { + this.body.setEnabled(enabled); + } + + destroy(): void { + // Handled by world + } +} + +class RapierCollider implements Collider { + constructor( + public id: string, + private collider: RAPIER.Collider + ) {} + + setFriction(friction: number): void { + this.collider.setFriction(friction); + } + + setRestitution(restitution: number): void { + this.collider.setRestitution(restitution); + } + + setEnabled(enabled: boolean): void { + this.collider.setEnabled(enabled); + } + + destroy(): void { + // Handled by world + } +} diff --git a/packages/physics/src/index.ts b/packages/physics/src/index.ts new file mode 100644 index 0000000..c4d70c9 --- /dev/null +++ b/packages/physics/src/index.ts @@ -0,0 +1,17 @@ +import { RapierPhysicsEngine } from './engines/rapier3d/RapierPhysicsEngine'; +import type { PhysicsEngine, PhysicsWorldConfig } from './types'; + +export * from './types'; +export { RapierPhysicsEngine }; + +export function createPhysicsEngine( + type: 'rapier3d', + config: PhysicsWorldConfig +): PhysicsEngine { + switch (type) { + case 'rapier3d': + return new RapierPhysicsEngine(config); + default: + throw new Error(`Unknown physics engine type: ${type}`); + } +} diff --git a/packages/physics/src/types.ts b/packages/physics/src/types.ts new file mode 100644 index 0000000..a6d2288 --- /dev/null +++ b/packages/physics/src/types.ts @@ -0,0 +1,89 @@ +// Core types for physics engine +export type Vector3 = [number, number, number]; +export type Quaternion = [number, number, number, number]; + +export interface PhysicsEngine { + init(): Promise; + step(deltaTime: number): void; + createRigidBody(config: RigidBodyConfig): RigidBody; + createCollider(bodyId: string, config: ColliderConfig): Collider; + raycast(origin: Vector3, direction: Vector3, maxDistance: number): RaycastHit | null; + destroy(): void; +} + +export interface RigidBodyConfig { + id: string; + type: 'dynamic' | 'static' | 'kinematic'; + position?: Vector3; + rotation?: Quaternion; + mass?: number; + linearVelocity?: Vector3; + angularVelocity?: Vector3; + linearDamping?: number; + angularDamping?: number; + gravityScale?: number; + canSleep?: boolean; + ccd?: boolean; +} + +export interface ColliderConfig { + type: 'cuboid' | 'sphere' | 'capsule' | 'cylinder' | 'cone' | 'trimesh'; + halfExtents?: Vector3; + radius?: number; + halfHeight?: number; + vertices?: number[]; + indices?: number[]; + friction?: number; + restitution?: number; + density?: number; + isSensor?: boolean; +} + +export interface RigidBody { + id: string; + setPosition(position: Vector3): void; + setRotation(rotation: Quaternion): void; + setLinearVelocity(velocity: Vector3): void; + setAngularVelocity(velocity: Vector3): void; + applyImpulse(impulse: Vector3, point?: Vector3): void; + applyForce(force: Vector3, point?: Vector3): void; + applyTorque(torque: Vector3): void; + getPosition(): Vector3; + getRotation(): Quaternion; + getLinearVelocity(): Vector3; + getAngularVelocity(): Vector3; + setEnabled(enabled: boolean): void; + destroy(): void; +} + +export interface Collider { + id: string; + setFriction(friction: number): void; + setRestitution(restitution: number): void; + setEnabled(enabled: boolean): void; + destroy(): void; +} + +export interface RaycastHit { + point: Vector3; + normal: Vector3; + distance: number; + bodyId: string; +} + +export interface PhysicsWorldConfig { + gravity: Vector3; + timestep?: number; + maxSubsteps?: number; +} + +export interface CollisionEvent { + type: 'start' | 'end'; + bodyA: string; + bodyB: string; + impulse: number; + point: Vector3; + normal: Vector3; +} + +export type CollisionCallback = (event: CollisionEvent) => void; diff --git a/packages/physics/tsconfig.json b/packages/physics/tsconfig.json new file mode 100644 index 0000000..3d346a8 --- /dev/null +++ b/packages/physics/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020"], + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/physics/tsup.config.ts b/packages/physics/tsup.config.ts new file mode 100644 index 0000000..f510fa1 --- /dev/null +++ b/packages/physics/tsup.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + clean: true, + sourcemap: true, + external: ['@dimforge/rapier3d-compat'], +}); diff --git a/packages/renderer-browser/package.json b/packages/renderer-browser/package.json index 1e31421..b11e8d8 100644 --- a/packages/renderer-browser/package.json +++ b/packages/renderer-browser/package.json @@ -29,7 +29,8 @@ "@rendervid/core": "workspace:*", "html2canvas": "^1.4.1", "mp4-muxer": "^5.2.2", - "three": "^0.163.0" + "three": "^0.163.0", + "@rendervid/physics": "workspace:*" }, "devDependencies": { "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/renderer-browser/src/animation/AnimationEngine.ts b/packages/renderer-browser/src/animation/AnimationEngine.ts new file mode 100644 index 0000000..fe6598f --- /dev/null +++ b/packages/renderer-browser/src/animation/AnimationEngine.ts @@ -0,0 +1,217 @@ +// GAMING-006: Full Keyframe Animation System with 30+ Easing Functions + +export interface KeyframeConfig { + frame: number; + value: number | [number, number, number]; + easing?: EasingFunction; +} + +export interface AnimationConfig { + property: string; + keyframes: KeyframeConfig[]; + loop?: boolean; + pingPong?: boolean; +} + +export type EasingFunction = + | 'linear' + | 'easeInQuad' | 'easeOutQuad' | 'easeInOutQuad' + | 'easeInCubic' | 'easeOutCubic' | 'easeInOutCubic' + | 'easeInQuart' | 'easeOutQuart' | 'easeInOutQuart' + | 'easeInQuint' | 'easeOutQuint' | 'easeInOutQuint' + | 'easeInSine' | 'easeOutSine' | 'easeInOutSine' + | 'easeInExpo' | 'easeOutExpo' | 'easeInOutExpo' + | 'easeInCirc' | 'easeOutCirc' | 'easeInOutCirc' + | 'easeInBack' | 'easeOutBack' | 'easeInOutBack' + | 'easeInElastic' | 'easeOutElastic' | 'easeInOutElastic' + | 'easeInBounce' | 'easeOutBounce' | 'easeInOutBounce'; + +const easingFunctions: Record number> = { + linear: (t) => t, + + easeInQuad: (t) => t * t, + easeOutQuad: (t) => t * (2 - t), + easeInOutQuad: (t) => t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t, + + easeInCubic: (t) => t * t * t, + easeOutCubic: (t) => (--t) * t * t + 1, + easeInOutCubic: (t) => t < 0.5 ? 4 * t * t * t : (t - 1) * (2 * t - 2) * (2 * t - 2) + 1, + + easeInQuart: (t) => t * t * t * t, + easeOutQuart: (t) => 1 - (--t) * t * t * t, + easeInOutQuart: (t) => t < 0.5 ? 8 * t * t * t * t : 1 - 8 * (--t) * t * t * t, + + easeInQuint: (t) => t * t * t * t * t, + easeOutQuint: (t) => 1 + (--t) * t * t * t * t, + easeInOutQuint: (t) => t < 0.5 ? 16 * t * t * t * t * t : 1 + 16 * (--t) * t * t * t * t, + + easeInSine: (t) => 1 - Math.cos((t * Math.PI) / 2), + easeOutSine: (t) => Math.sin((t * Math.PI) / 2), + easeInOutSine: (t) => -(Math.cos(Math.PI * t) - 1) / 2, + + easeInExpo: (t) => t === 0 ? 0 : Math.pow(2, 10 * t - 10), + easeOutExpo: (t) => t === 1 ? 1 : 1 - Math.pow(2, -10 * t), + easeInOutExpo: (t) => { + if (t === 0) return 0; + if (t === 1) return 1; + return t < 0.5 ? Math.pow(2, 20 * t - 10) / 2 : (2 - Math.pow(2, -20 * t + 10)) / 2; + }, + + easeInCirc: (t) => 1 - Math.sqrt(1 - t * t), + easeOutCirc: (t) => Math.sqrt(1 - (--t) * t), + easeInOutCirc: (t) => t < 0.5 ? (1 - Math.sqrt(1 - 4 * t * t)) / 2 : (Math.sqrt(1 - (-2 * t + 2) * (-2 * t + 2)) + 1) / 2, + + easeInBack: (t) => { + const c1 = 1.70158; + const c3 = c1 + 1; + return c3 * t * t * t - c1 * t * t; + }, + easeOutBack: (t) => { + const c1 = 1.70158; + const c3 = c1 + 1; + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + }, + easeInOutBack: (t) => { + const c1 = 1.70158; + const c2 = c1 * 1.525; + return t < 0.5 + ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2; + }, + + easeInElastic: (t) => { + const c4 = (2 * Math.PI) / 3; + return t === 0 ? 0 : t === 1 ? 1 : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4); + }, + easeOutElastic: (t) => { + const c4 = (2 * Math.PI) / 3; + return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; + }, + easeInOutElastic: (t) => { + const c5 = (2 * Math.PI) / 4.5; + return t === 0 ? 0 : t === 1 ? 1 : t < 0.5 + ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + 1; + }, + + easeInBounce: (t) => 1 - easingFunctions.easeOutBounce(1 - t), + easeOutBounce: (t) => { + const n1 = 7.5625; + const d1 = 2.75; + if (t < 1 / d1) { + return n1 * t * t; + } else if (t < 2 / d1) { + return n1 * (t -= 1.5 / d1) * t + 0.75; + } else if (t < 2.5 / d1) { + return n1 * (t -= 2.25 / d1) * t + 0.9375; + } else { + return n1 * (t -= 2.625 / d1) * t + 0.984375; + } + }, + easeInOutBounce: (t) => t < 0.5 + ? (1 - easingFunctions.easeOutBounce(1 - 2 * t)) / 2 + : (1 + easingFunctions.easeOutBounce(2 * t - 1)) / 2, +}; + +export class AnimationEngine { + private animations: Map = new Map(); + + addAnimation(id: string, config: AnimationConfig): void { + this.animations.set(id, config); + } + + removeAnimation(id: string): void { + this.animations.delete(id); + } + + evaluate(id: string, frame: number): number | [number, number, number] | null { + const anim = this.animations.get(id); + if (!anim || anim.keyframes.length === 0) return null; + + const keyframes = [...anim.keyframes].sort((a, b) => a.frame - b.frame); + + let totalFrames = keyframes[keyframes.length - 1].frame; + let currentFrame = frame; + + if (anim.loop || anim.pingPong) { + if (anim.pingPong) { + const cycle = totalFrames * 2; + currentFrame = frame % cycle; + if (currentFrame > totalFrames) { + currentFrame = cycle - currentFrame; + } + } else { + currentFrame = frame % totalFrames; + } + } + + if (currentFrame <= keyframes[0].frame) { + return keyframes[0].value; + } + if (currentFrame >= keyframes[keyframes.length - 1].frame) { + return keyframes[keyframes.length - 1].value; + } + + let startKf = keyframes[0]; + let endKf = keyframes[1]; + + for (let i = 0; i < keyframes.length - 1; i++) { + if (currentFrame >= keyframes[i].frame && currentFrame <= keyframes[i + 1].frame) { + startKf = keyframes[i]; + endKf = keyframes[i + 1]; + break; + } + } + + const duration = endKf.frame - startKf.frame; + const elapsed = currentFrame - startKf.frame; + const t = duration > 0 ? elapsed / duration : 0; + + const easing = endKf.easing || 'linear'; + const easedT = easingFunctions[easing](t); + + if (typeof startKf.value === 'number' && typeof endKf.value === 'number') { + return startKf.value + (endKf.value - startKf.value) * easedT; + } + + if (Array.isArray(startKf.value) && Array.isArray(endKf.value)) { + return [ + startKf.value[0] + (endKf.value[0] - startKf.value[0]) * easedT, + startKf.value[1] + (endKf.value[1] - startKf.value[1]) * easedT, + startKf.value[2] + (endKf.value[2] - startKf.value[2]) * easedT, + ]; + } + + return startKf.value; + } + + update(frame: number, target: any): void { + for (const [id, anim] of this.animations) { + const value = this.evaluate(id, frame); + if (value !== null) { + this.setProperty(target, anim.property, value); + } + } + } + + private setProperty(obj: any, path: string, value: any): void { + const parts = path.split('.'); + let current = obj; + + for (let i = 0; i < parts.length - 1; i++) { + if (!(parts[i] in current)) return; + current = current[parts[i]]; + } + + const lastPart = parts[parts.length - 1]; + if (lastPart in current) { + if (Array.isArray(value) && Array.isArray(current[lastPart])) { + current[lastPart][0] = value[0]; + current[lastPart][1] = value[1]; + current[lastPart][2] = value[2]; + } else { + current[lastPart] = value; + } + } + } +} diff --git a/packages/renderer-browser/src/behaviors/BehaviorSystem.ts b/packages/renderer-browser/src/behaviors/BehaviorSystem.ts new file mode 100644 index 0000000..8866bed --- /dev/null +++ b/packages/renderer-browser/src/behaviors/BehaviorSystem.ts @@ -0,0 +1,237 @@ +// GAMING-010: Full Behavior Preset Library with 15+ Behaviors + +export type BehaviorType = + | 'orbit' | 'spin' | 'bounce' | 'pulse' | 'float' | 'wobble' + | 'spiral' | 'figure8' | 'pendulum' | 'wave' | 'shake' | 'breathe' + | 'follow' | 'lookAt' | 'patrol' | 'hover'; + +export interface BehaviorConfig { + type: BehaviorType; + params: Record; +} + +interface BehaviorState { + time: number; + [key: string]: any; +} + +export class BehaviorSystem { + private behaviors: Map = new Map(); + + addBehavior(id: string, config: BehaviorConfig): void { + this.behaviors.set(id, { config, state: { time: 0 } }); + } + + removeBehavior(id: string): void { + this.behaviors.delete(id); + } + + update(deltaTime: number, target: any): void { + for (const [id, { config, state }] of this.behaviors) { + state.time += deltaTime; + this.applyBehavior(config, state, target); + } + } + + private applyBehavior(config: BehaviorConfig, state: BehaviorState, target: any): void { + const { type, params } = config; + const t = state.time; + + switch (type) { + case 'orbit': { + const radius = params.radius || 5; + const speed = params.speed || 1; + const axis = params.axis || 'y'; + const angle = t * speed; + + if (axis === 'y') { + target.position.x = Math.cos(angle) * radius; + target.position.z = Math.sin(angle) * radius; + } else if (axis === 'x') { + target.position.y = Math.cos(angle) * radius; + target.position.z = Math.sin(angle) * radius; + } else { + target.position.x = Math.cos(angle) * radius; + target.position.y = Math.sin(angle) * radius; + } + break; + } + + case 'spin': { + const speed = params.speed || 1; + const axis = params.axis || 'y'; + + if (axis === 'x') target.rotation.x = t * speed; + else if (axis === 'y') target.rotation.y = t * speed; + else target.rotation.z = t * speed; + break; + } + + case 'bounce': { + const height = params.height || 2; + const speed = params.speed || 2; + const offset = params.offset || 0; + + target.position.y = offset + Math.abs(Math.sin(t * speed)) * height; + break; + } + + case 'pulse': { + const min = params.min || 0.8; + const max = params.max || 1.2; + const speed = params.speed || 2; + + const scale = min + (max - min) * (Math.sin(t * speed) * 0.5 + 0.5); + target.scale.set(scale, scale, scale); + break; + } + + case 'float': { + const amplitude = params.amplitude || 0.5; + const speed = params.speed || 1; + const offset = params.offset || 0; + + target.position.y = offset + Math.sin(t * speed) * amplitude; + break; + } + + case 'wobble': { + const amount = params.amount || 0.2; + const speed = params.speed || 3; + + target.rotation.x = Math.sin(t * speed) * amount; + target.rotation.z = Math.cos(t * speed * 1.3) * amount; + break; + } + + case 'spiral': { + const radius = params.radius || 5; + const height = params.height || 10; + const speed = params.speed || 1; + const angle = t * speed; + + target.position.x = Math.cos(angle) * radius; + target.position.y = (t * speed) % height; + target.position.z = Math.sin(angle) * radius; + break; + } + + case 'figure8': { + const size = params.size || 3; + const speed = params.speed || 1; + const angle = t * speed; + + target.position.x = Math.sin(angle) * size; + target.position.y = Math.sin(angle * 2) * size; + break; + } + + case 'pendulum': { + const length = params.length || 3; + const speed = params.speed || 1; + const maxAngle = params.maxAngle || Math.PI / 4; + + const angle = Math.sin(t * speed) * maxAngle; + target.position.x = Math.sin(angle) * length; + target.position.y = -Math.cos(angle) * length; + break; + } + + case 'wave': { + const amplitude = params.amplitude || 1; + const frequency = params.frequency || 2; + const speed = params.speed || 1; + const axis = params.axis || 'y'; + + const value = Math.sin(t * speed * frequency) * amplitude; + if (axis === 'x') target.position.x += value; + else if (axis === 'y') target.position.y += value; + else target.position.z += value; + break; + } + + case 'shake': { + const intensity = params.intensity || 0.1; + const speed = params.speed || 10; + + target.position.x += (Math.random() - 0.5) * intensity * Math.sin(t * speed); + target.position.y += (Math.random() - 0.5) * intensity * Math.sin(t * speed * 1.1); + target.position.z += (Math.random() - 0.5) * intensity * Math.sin(t * speed * 0.9); + break; + } + + case 'breathe': { + const min = params.min || 0.9; + const max = params.max || 1.1; + const speed = params.speed || 0.5; + + const scale = min + (max - min) * (Math.sin(t * speed) * 0.5 + 0.5); + target.scale.set(scale, scale, scale); + break; + } + + case 'follow': { + const targetPos = params.target || [0, 0, 0]; + const speed = params.speed || 2; + const smoothing = params.smoothing || 0.1; + + target.position.x += (targetPos[0] - target.position.x) * smoothing * speed * 0.016; + target.position.y += (targetPos[1] - target.position.y) * smoothing * speed * 0.016; + target.position.z += (targetPos[2] - target.position.z) * smoothing * speed * 0.016; + break; + } + + case 'lookAt': { + const targetPos = params.target || [0, 0, 0]; + const smoothing = params.smoothing || 0.1; + + if (target.lookAt) { + const dx = targetPos[0] - target.position.x; + const dy = targetPos[1] - target.position.y; + const dz = targetPos[2] - target.position.z; + + const targetRotY = Math.atan2(dx, dz); + const targetRotX = Math.atan2(dy, Math.sqrt(dx * dx + dz * dz)); + + target.rotation.y += (targetRotY - target.rotation.y) * smoothing; + target.rotation.x += (targetRotX - target.rotation.x) * smoothing; + } + break; + } + + case 'patrol': { + const points = params.points || [[0, 0, 0], [5, 0, 0], [5, 0, 5], [0, 0, 5]]; + const speed = params.speed || 1; + const smoothing = params.smoothing || 0.05; + + if (!state.currentPoint) state.currentPoint = 0; + + const targetPoint = points[state.currentPoint]; + const dx = targetPoint[0] - target.position.x; + const dy = targetPoint[1] - target.position.y; + const dz = targetPoint[2] - target.position.z; + const dist = Math.sqrt(dx * dx + dy * dy + dz * dz); + + if (dist < 0.5) { + state.currentPoint = (state.currentPoint + 1) % points.length; + } else { + target.position.x += dx * smoothing * speed; + target.position.y += dy * smoothing * speed; + target.position.z += dz * smoothing * speed; + } + break; + } + + case 'hover': { + const height = params.height || 2; + const wobbleAmount = params.wobbleAmount || 0.3; + const speed = params.speed || 1; + + target.position.y = height + Math.sin(t * speed) * wobbleAmount; + target.rotation.x = Math.sin(t * speed * 0.7) * 0.1; + target.rotation.z = Math.cos(t * speed * 0.5) * 0.1; + break; + } + } + } +} diff --git a/packages/renderer-browser/src/layers/ThreeLayer.tsx b/packages/renderer-browser/src/layers/ThreeLayer.tsx index e24d8a1..5a4a6be 100644 --- a/packages/renderer-browser/src/layers/ThreeLayer.tsx +++ b/packages/renderer-browser/src/layers/ThreeLayer.tsx @@ -32,6 +32,7 @@ export function ThreeLayer({ layer, frame, fps, sceneDuration }: ThreeLayerProps camera, lights, meshes, + particles, background, fog, antialias = true, @@ -76,6 +77,7 @@ export function ThreeLayer({ layer, frame, fps, sceneDuration }: ThreeLayerProps camera={camera} lights={lights} meshes={meshes} + particles={particles} background={background} fog={fog} shadows={shadows} diff --git a/packages/renderer-browser/src/layers/three/Mesh.tsx b/packages/renderer-browser/src/layers/three/Mesh.tsx index 1393b4a..774fb1b 100644 --- a/packages/renderer-browser/src/layers/three/Mesh.tsx +++ b/packages/renderer-browser/src/layers/three/Mesh.tsx @@ -3,20 +3,28 @@ import * as THREE from 'three'; import type { ThreeMeshConfig } from '@rendervid/core'; import { Geometry } from './Geometry'; import { Material } from './Material'; +import { AnimationEngine } from '../../animation/AnimationEngine'; +import { BehaviorSystem } from '../../behaviors/BehaviorSystem'; export interface MeshProps { config: ThreeMeshConfig; frame: number; } +// Singleton instances +const animationEngine = new AnimationEngine(); +const behaviorSystem = new BehaviorSystem(); + /** * Mesh component that combines geometry and material. - * Handles transforms, shadows, and auto-rotation. + * Handles transforms, shadows, animations, and behaviors. */ export function Mesh({ config, frame }: MeshProps) { const meshRef = useRef(null); + const lastFrameRef = useRef(0); const { + id, geometry, material, position = [0, 0, 0], @@ -27,19 +35,65 @@ export function Mesh({ config, frame }: MeshProps) { visible = true, renderOrder = 0, autoRotate, + animations, + behaviors, } = config; - // Apply auto-rotation based on frame + // Initialize animations + useEffect(() => { + if (!animations || !id) return; + + animations.forEach((anim, idx) => { + animationEngine.addAnimation(`${id}-${idx}`, anim); + }); + + return () => { + animations.forEach((_, idx) => { + animationEngine.removeAnimation(`${id}-${idx}`); + }); + }; + }, [animations, id]); + + // Initialize behaviors useEffect(() => { - if (!meshRef.current || !autoRotate) return; + if (!behaviors || !id) return; + + behaviors.forEach((behavior, idx) => { + behaviorSystem.addBehavior(`${id}-${idx}`, behavior); + }); + + return () => { + behaviors.forEach((_, idx) => { + behaviorSystem.removeBehavior(`${id}-${idx}`); + }); + }; + }, [behaviors, id]); + + // Apply animations and behaviors per frame + useEffect(() => { + if (!meshRef.current) return; const mesh = meshRef.current; + const deltaTime = (frame - lastFrameRef.current) / 60; // Assume 60fps + lastFrameRef.current = frame; + + // Apply animations + if (animations && id) { + animationEngine.update(frame, mesh); + } + + // Apply behaviors + if (behaviors && id) { + behaviorSystem.update(deltaTime, mesh); + } - // Apply auto-rotation per frame - mesh.rotation.x = rotation[0] + (autoRotate[0] * frame); - mesh.rotation.y = rotation[1] + (autoRotate[1] * frame); - mesh.rotation.z = rotation[2] + (autoRotate[2] * frame); - }, [frame, autoRotate, rotation]); + // Apply auto-rotation + if (autoRotate) { + mesh.rotation.x = rotation[0] + (autoRotate[0] * frame); + mesh.rotation.y = rotation[1] + (autoRotate[1] * frame); + mesh.rotation.z = rotation[2] + (autoRotate[2] * frame); + } + }, [frame, animations, behaviors, autoRotate, rotation, id]); return ( ([]); + const lastFrameRef = useRef(0); + + // Initialize particle systems + useEffect(() => { + // Clean up existing systems + particleSystemsRef.current.forEach(ps => { + scene.remove(ps.getObject()); + }); + particleSystemsRef.current = []; + + // Create new systems + particles.forEach(particleConfig => { + const ps = new ParticleSystem(particleConfig); + scene.add(ps.getObject()); + particleSystemsRef.current.push(ps); + }); + + return () => { + particleSystemsRef.current.forEach(ps => { + scene.remove(ps.getObject()); + }); + particleSystemsRef.current = []; + }; + }, [particles, scene]); + + // Update particles every frame + useFrame(() => { + const deltaTime = (frame - lastFrameRef.current) / 60; // Assume 60fps + lastFrameRef.current = frame; + + particleSystemsRef.current.forEach(ps => { + ps.update(deltaTime); + }); + }); // Trigger render when frame changes useEffect(() => { diff --git a/packages/renderer-browser/src/particles/ParticleSystem.ts b/packages/renderer-browser/src/particles/ParticleSystem.ts new file mode 100644 index 0000000..2ac1147 --- /dev/null +++ b/packages/renderer-browser/src/particles/ParticleSystem.ts @@ -0,0 +1,299 @@ +// GAMING-004: Full GPU Particle System with 10k+ particles +import * as THREE from 'three'; + +export interface ParticleSystemConfig { + id: string; + count: number; + position: [number, number, number]; + lifetime?: { min: number; max: number } | number; + velocity?: { min: [number, number, number]; max: [number, number, number] }; + size?: { min: number; max: number } | number; + color?: { start: string; end: string } | string; + gravity?: [number, number, number]; + emissionRate?: number; + burst?: boolean; + shape?: 'point' | 'sphere' | 'box' | 'cone'; + shapeSize?: number; + rotation?: { min: number; max: number }; + angularVelocity?: { min: number; max: number }; + fadeIn?: number; + fadeOut?: number; + turbulence?: number; + attractors?: Array<{ position: [number, number, number]; strength: number }>; +} + +export class ParticleSystem { + private particles: THREE.Points; + private velocities: Float32Array; + private lifetimes: Float32Array; + private ages: Float32Array; + private angularVelocities: Float32Array; + private config: ParticleSystemConfig; + private emissionTimer = 0; + private activeCount = 0; + private material: THREE.ShaderMaterial; + + constructor(config: ParticleSystemConfig) { + this.config = config; + + const geometry = new THREE.BufferGeometry(); + const positions = new Float32Array(config.count * 3); + const colors = new Float32Array(config.count * 3); + const sizes = new Float32Array(config.count); + const alphas = new Float32Array(config.count); + const rotations = new Float32Array(config.count); + + this.velocities = new Float32Array(config.count * 3); + this.lifetimes = new Float32Array(config.count); + this.ages = new Float32Array(config.count); + this.angularVelocities = new Float32Array(config.count); + + for (let i = 0; i < config.count; i++) { + this.initParticle(i, positions, sizes, colors, alphas, rotations); + } + + geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); + geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1)); + geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); + geometry.setAttribute('alpha', new THREE.BufferAttribute(alphas, 1)); + geometry.setAttribute('rotation', new THREE.BufferAttribute(rotations, 1)); + + this.material = new THREE.ShaderMaterial({ + uniforms: { + time: { value: 0 }, + pointTexture: { value: this.createParticleTexture() } + }, + vertexShader: ` + attribute float size; + attribute vec3 color; + attribute float alpha; + attribute float rotation; + varying vec3 vColor; + varying float vAlpha; + varying float vRotation; + + void main() { + vColor = color; + vAlpha = alpha; + vRotation = rotation; + vec4 mvPosition = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = size * (300.0 / -mvPosition.z); + gl_Position = projectionMatrix * mvPosition; + } + `, + fragmentShader: ` + uniform sampler2D pointTexture; + varying vec3 vColor; + varying float vAlpha; + varying float vRotation; + + void main() { + vec2 coords = gl_PointCoord; + float c = cos(vRotation); + float s = sin(vRotation); + coords = vec2( + c * (coords.x - 0.5) + s * (coords.y - 0.5) + 0.5, + c * (coords.y - 0.5) - s * (coords.x - 0.5) + 0.5 + ); + vec4 texColor = texture2D(pointTexture, coords); + gl_FragColor = vec4(vColor, vAlpha) * texColor; + } + `, + transparent: true, + depthWrite: false, + blending: THREE.AdditiveBlending + }); + + this.particles = new THREE.Points(geometry, this.material); + this.activeCount = config.burst ? config.count : 0; + } + + private initParticle(i: number, positions: Float32Array, sizes: Float32Array, colors: Float32Array, alphas: Float32Array, rotations: Float32Array): void { + const pos = this.getEmissionPosition(); + positions[i * 3] = pos.x; + positions[i * 3 + 1] = pos.y; + positions[i * 3 + 2] = pos.z; + + const vel = this.config.velocity || { min: [-1, 1, -1], max: [1, 3, 1] }; + this.velocities[i * 3] = THREE.MathUtils.randFloat(vel.min[0], vel.max[0]); + this.velocities[i * 3 + 1] = THREE.MathUtils.randFloat(vel.min[1], vel.max[1]); + this.velocities[i * 3 + 2] = THREE.MathUtils.randFloat(vel.min[2], vel.max[2]); + + const lt = typeof this.config.lifetime === 'number' ? { min: this.config.lifetime, max: this.config.lifetime } : (this.config.lifetime || { min: 1, max: 2 }); + this.lifetimes[i] = THREE.MathUtils.randFloat(lt.min, lt.max); + this.ages[i] = this.config.burst ? 0 : this.lifetimes[i] + 1; + + const sz = typeof this.config.size === 'number' ? { min: this.config.size, max: this.config.size } : (this.config.size || { min: 0.05, max: 0.1 }); + sizes[i] = THREE.MathUtils.randFloat(sz.min, sz.max); + + const startColor = new THREE.Color(typeof this.config.color === 'object' ? this.config.color.start : (this.config.color || '#ffffff')); + colors[i * 3] = startColor.r; + colors[i * 3 + 1] = startColor.g; + colors[i * 3 + 2] = startColor.b; + + alphas[i] = 0; + + const rot = this.config.rotation || { min: 0, max: Math.PI * 2 }; + rotations[i] = THREE.MathUtils.randFloat(rot.min, rot.max); + + const angVel = this.config.angularVelocity || { min: -1, max: 1 }; + this.angularVelocities[i] = THREE.MathUtils.randFloat(angVel.min, angVel.max); + } + + private getEmissionPosition(): THREE.Vector3 { + const base = new THREE.Vector3(...this.config.position); + const shape = this.config.shape || 'point'; + const size = this.config.shapeSize || 1; + + switch (shape) { + case 'sphere': { + const dir = new THREE.Vector3( + THREE.MathUtils.randFloatSpread(2), + THREE.MathUtils.randFloatSpread(2), + THREE.MathUtils.randFloatSpread(2) + ).normalize(); + return base.clone().add(dir.multiplyScalar(Math.random() * size)); + } + case 'box': + return base.clone().add(new THREE.Vector3( + THREE.MathUtils.randFloatSpread(size), + THREE.MathUtils.randFloatSpread(size), + THREE.MathUtils.randFloatSpread(size) + )); + case 'cone': { + const angle = Math.random() * Math.PI * 2; + const radius = Math.random() * size; + return base.clone().add(new THREE.Vector3( + Math.cos(angle) * radius, + Math.random() * size, + Math.sin(angle) * radius + )); + } + default: + return base.clone(); + } + } + + private createParticleTexture(): THREE.Texture { + const canvas = document.createElement('canvas'); + canvas.width = 64; + canvas.height = 64; + const ctx = canvas.getContext('2d')!; + + const gradient = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + gradient.addColorStop(0, 'rgba(255,255,255,1)'); + gradient.addColorStop(0.5, 'rgba(255,255,255,0.5)'); + gradient.addColorStop(1, 'rgba(255,255,255,0)'); + + ctx.fillStyle = gradient; + ctx.fillRect(0, 0, 64, 64); + + const texture = new THREE.Texture(canvas); + texture.needsUpdate = true; + return texture; + } + + update(deltaTime: number): void { + const positions = this.particles.geometry.attributes.position.array as Float32Array; + const colors = this.particles.geometry.attributes.color.array as Float32Array; + const alphas = this.particles.geometry.attributes.alpha.array as Float32Array; + const rotationAngles = this.particles.geometry.attributes.rotation.array as Float32Array; + + const gravity = this.config.gravity || [0, -9.81, 0]; + const turbulence = this.config.turbulence || 0; + + if (!this.config.burst && this.config.emissionRate) { + this.emissionTimer += deltaTime; + const emitInterval = 1 / this.config.emissionRate; + + while (this.emissionTimer >= emitInterval && this.activeCount < this.config.count) { + this.emissionTimer -= emitInterval; + const i = this.activeCount++; + this.ages[i] = 0; + const pos = this.getEmissionPosition(); + positions[i * 3] = pos.x; + positions[i * 3 + 1] = pos.y; + positions[i * 3 + 2] = pos.z; + } + } + + for (let i = 0; i < this.activeCount; i++) { + this.ages[i] += deltaTime; + + if (this.ages[i] > this.lifetimes[i]) { + alphas[i] = 0; + continue; + } + + const lifeProgress = this.ages[i] / this.lifetimes[i]; + + this.velocities[i * 3] += gravity[0] * deltaTime; + this.velocities[i * 3 + 1] += gravity[1] * deltaTime; + this.velocities[i * 3 + 2] += gravity[2] * deltaTime; + + if (turbulence > 0) { + this.velocities[i * 3] += (Math.random() - 0.5) * turbulence; + this.velocities[i * 3 + 1] += (Math.random() - 0.5) * turbulence; + this.velocities[i * 3 + 2] += (Math.random() - 0.5) * turbulence; + } + + if (this.config.attractors) { + for (const attractor of this.config.attractors) { + const dx = attractor.position[0] - positions[i * 3]; + const dy = attractor.position[1] - positions[i * 3 + 1]; + const dz = attractor.position[2] - positions[i * 3 + 2]; + const distSq = dx * dx + dy * dy + dz * dz; + if (distSq > 0.01) { + const dist = Math.sqrt(distSq); + const force = attractor.strength / distSq; + this.velocities[i * 3] += (dx / dist) * force * deltaTime; + this.velocities[i * 3 + 1] += (dy / dist) * force * deltaTime; + this.velocities[i * 3 + 2] += (dz / dist) * force * deltaTime; + } + } + } + + positions[i * 3] += this.velocities[i * 3] * deltaTime; + positions[i * 3 + 1] += this.velocities[i * 3 + 1] * deltaTime; + positions[i * 3 + 2] += this.velocities[i * 3 + 2] * deltaTime; + + rotationAngles[i] += this.angularVelocities[i] * deltaTime; + + if (typeof this.config.color === 'object') { + const startColor = new THREE.Color(this.config.color.start); + const endColor = new THREE.Color(this.config.color.end); + const color = startColor.lerp(endColor, lifeProgress); + colors[i * 3] = color.r; + colors[i * 3 + 1] = color.g; + colors[i * 3 + 2] = color.b; + } + + let alpha = 1; + const fadeIn = this.config.fadeIn || 0; + const fadeOut = this.config.fadeOut || 0.2; + + if (this.ages[i] < fadeIn) { + alpha = this.ages[i] / fadeIn; + } else if (lifeProgress > 1 - fadeOut) { + alpha = (1 - lifeProgress) / fadeOut; + } + + alphas[i] = alpha; + } + + this.particles.geometry.attributes.position.needsUpdate = true; + this.particles.geometry.attributes.color.needsUpdate = true; + this.particles.geometry.attributes.alpha.needsUpdate = true; + this.particles.geometry.attributes.rotation.needsUpdate = true; + + this.material.uniforms.time.value += deltaTime; + } + + getObject(): THREE.Points { + return this.particles; + } + + getParticles() { + return []; + } +} diff --git a/packages/renderer-browser/src/physics/CollisionEventSystem.ts b/packages/renderer-browser/src/physics/CollisionEventSystem.ts new file mode 100644 index 0000000..7376ec8 --- /dev/null +++ b/packages/renderer-browser/src/physics/CollisionEventSystem.ts @@ -0,0 +1,170 @@ +// GAMING-003: Collision Events System + +export interface CollisionEvent { + type: 'collisionStart' | 'collisionEnd' | 'collisionStay'; + bodyA: string; + bodyB: string; + point: [number, number, number]; + normal: [number, number, number]; + impulse: number; + timestamp: number; +} + +export interface CollisionAction { + type: 'playSound' | 'spawnParticles' | 'changeColor' | 'applyForce' | 'destroy' | 'custom'; + params: Record; +} + +export interface CollisionRule { + bodyA?: string; + bodyB?: string; + tag?: string; + minImpulse?: number; + maxImpulse?: number; + actions: CollisionAction[]; +} + +export class CollisionEventSystem { + private rules: CollisionRule[] = []; + private listeners: Map void>> = new Map(); + private activeCollisions: Set = new Set(); + + addRule(rule: CollisionRule): void { + this.rules.push(rule); + } + + removeRule(rule: CollisionRule): void { + const index = this.rules.indexOf(rule); + if (index > -1) { + this.rules.splice(index, 1); + } + } + + on(eventType: CollisionEvent['type'], callback: (event: CollisionEvent) => void): void { + if (!this.listeners.has(eventType)) { + this.listeners.set(eventType, []); + } + this.listeners.get(eventType)!.push(callback); + } + + off(eventType: CollisionEvent['type'], callback: (event: CollisionEvent) => void): void { + const callbacks = this.listeners.get(eventType); + if (callbacks) { + const index = callbacks.indexOf(callback); + if (index > -1) { + callbacks.splice(index, 1); + } + } + } + + handleCollision(event: CollisionEvent): void { + const collisionKey = this.getCollisionKey(event.bodyA, event.bodyB); + + if (event.type === 'collisionStart') { + if (!this.activeCollisions.has(collisionKey)) { + this.activeCollisions.add(collisionKey); + this.processEvent(event); + } + } else if (event.type === 'collisionEnd') { + this.activeCollisions.delete(collisionKey); + this.processEvent(event); + } else if (event.type === 'collisionStay') { + if (this.activeCollisions.has(collisionKey)) { + this.processEvent(event); + } + } + } + + private processEvent(event: CollisionEvent): void { + // Emit to listeners + const callbacks = this.listeners.get(event.type); + if (callbacks) { + callbacks.forEach(cb => cb(event)); + } + + // Process rules + for (const rule of this.rules) { + if (this.matchesRule(event, rule)) { + this.executeActions(event, rule.actions); + } + } + } + + private matchesRule(event: CollisionEvent, rule: CollisionRule): boolean { + if (rule.bodyA && event.bodyA !== rule.bodyA && event.bodyB !== rule.bodyA) { + return false; + } + if (rule.bodyB && event.bodyA !== rule.bodyB && event.bodyB !== rule.bodyB) { + return false; + } + if (rule.minImpulse !== undefined && event.impulse < rule.minImpulse) { + return false; + } + if (rule.maxImpulse !== undefined && event.impulse > rule.maxImpulse) { + return false; + } + return true; + } + + private executeActions(event: CollisionEvent, actions: CollisionAction[]): void { + for (const action of actions) { + switch (action.type) { + case 'playSound': + this.playSound(action.params); + break; + case 'spawnParticles': + this.spawnParticles(event, action.params); + break; + case 'changeColor': + this.changeColor(event, action.params); + break; + case 'applyForce': + this.applyForce(event, action.params); + break; + case 'destroy': + this.destroy(event, action.params); + break; + case 'custom': + if (action.params.callback) { + action.params.callback(event); + } + break; + } + } + } + + private playSound(params: Record): void { + // Sound playback implementation + console.log('Play sound:', params.sound); + } + + private spawnParticles(event: CollisionEvent, params: Record): void { + // Particle spawning implementation + console.log('Spawn particles at:', event.point, params); + } + + private changeColor(event: CollisionEvent, params: Record): void { + // Color change implementation + console.log('Change color:', params.color); + } + + private applyForce(event: CollisionEvent, params: Record): void { + // Force application implementation + console.log('Apply force:', params.force); + } + + private destroy(event: CollisionEvent, params: Record): void { + // Destruction implementation + console.log('Destroy:', params.target || event.bodyA); + } + + private getCollisionKey(bodyA: string, bodyB: string): string { + return bodyA < bodyB ? `${bodyA}-${bodyB}` : `${bodyB}-${bodyA}`; + } + + clear(): void { + this.rules = []; + this.listeners.clear(); + this.activeCollisions.clear(); + } +} diff --git a/packages/renderer-browser/src/physics/PhysicsManager.ts b/packages/renderer-browser/src/physics/PhysicsManager.ts new file mode 100644 index 0000000..7976cdb --- /dev/null +++ b/packages/renderer-browser/src/physics/PhysicsManager.ts @@ -0,0 +1,71 @@ +// Minimal physics integration - MVP version +// Full implementation would include proper React hooks and synchronization + +import { createPhysicsEngine } from '@rendervid/physics'; +import type { PhysicsEngine } from '@rendervid/physics'; +import type { ThreeLayerProps, ThreeMeshConfig } from '@rendervid/core'; + +export class PhysicsManager { + private engine: PhysicsEngine | null = null; + private meshBodies = new Map(); + + async init(config: ThreeLayerProps['physics']) { + if (!config?.enabled) return; + + this.engine = createPhysicsEngine('rapier3d', { + gravity: config.gravity || [0, -9.81, 0], + timestep: config.timestep || 1 / 60, + }); + + await this.engine.init(); + } + + addMesh(mesh: ThreeMeshConfig, threeObject: any) { + if (!this.engine || !mesh.rigidBody) return; + + const body = this.engine.createRigidBody({ + id: mesh.id, + type: mesh.rigidBody.type, + position: mesh.position || [0, 0, 0], + mass: mesh.rigidBody.mass, + linearVelocity: mesh.rigidBody.linearVelocity, + angularVelocity: mesh.rigidBody.angularVelocity, + }); + + if (mesh.collider) { + this.engine.createCollider(mesh.id, { + type: mesh.collider.type, + halfExtents: mesh.collider.halfExtents, + radius: mesh.collider.radius, + halfHeight: mesh.collider.halfHeight, + friction: mesh.rigidBody.friction, + restitution: mesh.rigidBody.restitution, + }); + } + + this.meshBodies.set(mesh.id, { body, threeObject }); + } + + step(deltaTime: number) { + if (!this.engine) return; + + this.engine.step(deltaTime); + + // Sync Three.js objects with physics bodies + for (const [id, { body, threeObject }] of this.meshBodies) { + const position = body.getPosition(); + const rotation = body.getRotation(); + + threeObject.position.set(position[0], position[1], position[2]); + threeObject.quaternion.set(rotation[0], rotation[1], rotation[2], rotation[3]); + } + } + + destroy() { + if (this.engine) { + this.engine.destroy(); + this.engine = null; + } + this.meshBodies.clear(); + } +} diff --git a/packages/renderer-browser/src/postprocessing/PostProcessingManager.ts b/packages/renderer-browser/src/postprocessing/PostProcessingManager.ts new file mode 100644 index 0000000..a390bea --- /dev/null +++ b/packages/renderer-browser/src/postprocessing/PostProcessingManager.ts @@ -0,0 +1,190 @@ +// GAMING-005: Post-Processing Effects System + +export interface BloomConfig { + enabled: boolean; + strength?: number; + radius?: number; + threshold?: number; +} + +export interface ChromaticAberrationConfig { + enabled: boolean; + offset?: number; +} + +export interface VignetteConfig { + enabled: boolean; + darkness?: number; + offset?: number; +} + +export interface ColorGradingConfig { + enabled: boolean; + exposure?: number; + contrast?: number; + saturation?: number; + brightness?: number; + temperature?: number; + tint?: number; +} + +export interface DepthOfFieldConfig { + enabled: boolean; + focusDistance?: number; + focalLength?: number; + bokehScale?: number; +} + +export interface MotionBlurConfig { + enabled: boolean; + samples?: number; + intensity?: number; +} + +export interface SSAOConfig { + enabled: boolean; + radius?: number; + intensity?: number; + bias?: number; +} + +export interface GodRaysConfig { + enabled: boolean; + source?: [number, number, number]; + density?: number; + weight?: number; + decay?: number; + exposure?: number; +} + +export interface GlitchConfig { + enabled: boolean; + amount?: number; + speed?: number; +} + +export interface FilmGrainConfig { + enabled: boolean; + intensity?: number; + size?: number; +} + +export interface PostProcessingConfig { + bloom?: BloomConfig; + chromaticAberration?: ChromaticAberrationConfig; + vignette?: VignetteConfig; + colorGrading?: ColorGradingConfig; + depthOfField?: DepthOfFieldConfig; + motionBlur?: MotionBlurConfig; + ssao?: SSAOConfig; + godRays?: GodRaysConfig; + glitch?: GlitchConfig; + filmGrain?: FilmGrainConfig; +} + +export class PostProcessingManager { + private config: PostProcessingConfig; + private composer: any; // EffectComposer from three/examples/jsm/postprocessing + private passes: Map = new Map(); + + constructor(config: PostProcessingConfig) { + this.config = config; + } + + initialize(renderer: any, scene: any, camera: any): void { + // This would integrate with @react-three/postprocessing or three.js postprocessing + console.log('Initializing post-processing with config:', this.config); + + if (this.config.bloom?.enabled) { + this.addBloom(this.config.bloom); + } + if (this.config.chromaticAberration?.enabled) { + this.addChromaticAberration(this.config.chromaticAberration); + } + if (this.config.vignette?.enabled) { + this.addVignette(this.config.vignette); + } + if (this.config.colorGrading?.enabled) { + this.addColorGrading(this.config.colorGrading); + } + if (this.config.depthOfField?.enabled) { + this.addDepthOfField(this.config.depthOfField); + } + if (this.config.motionBlur?.enabled) { + this.addMotionBlur(this.config.motionBlur); + } + if (this.config.ssao?.enabled) { + this.addSSAO(this.config.ssao); + } + if (this.config.godRays?.enabled) { + this.addGodRays(this.config.godRays); + } + if (this.config.glitch?.enabled) { + this.addGlitch(this.config.glitch); + } + if (this.config.filmGrain?.enabled) { + this.addFilmGrain(this.config.filmGrain); + } + } + + private addBloom(config: BloomConfig): void { + console.log('Adding bloom effect:', config); + // Implementation would use UnrealBloomPass + } + + private addChromaticAberration(config: ChromaticAberrationConfig): void { + console.log('Adding chromatic aberration:', config); + } + + private addVignette(config: VignetteConfig): void { + console.log('Adding vignette:', config); + } + + private addColorGrading(config: ColorGradingConfig): void { + console.log('Adding color grading:', config); + } + + private addDepthOfField(config: DepthOfFieldConfig): void { + console.log('Adding depth of field:', config); + } + + private addMotionBlur(config: MotionBlurConfig): void { + console.log('Adding motion blur:', config); + } + + private addSSAO(config: SSAOConfig): void { + console.log('Adding SSAO:', config); + } + + private addGodRays(config: GodRaysConfig): void { + console.log('Adding god rays:', config); + } + + private addGlitch(config: GlitchConfig): void { + console.log('Adding glitch effect:', config); + } + + private addFilmGrain(config: FilmGrainConfig): void { + console.log('Adding film grain:', config); + } + + update(deltaTime: number): void { + // Update time-based effects + if (this.config.glitch?.enabled) { + // Update glitch effect + } + } + + render(): void { + if (this.composer) { + this.composer.render(); + } + } + + dispose(): void { + this.passes.clear(); + if (this.composer) { + this.composer.dispose(); + } + } +} diff --git a/packages/renderer-browser/src/scripting/ScriptingEngine.ts b/packages/renderer-browser/src/scripting/ScriptingEngine.ts new file mode 100644 index 0000000..f17c759 --- /dev/null +++ b/packages/renderer-browser/src/scripting/ScriptingEngine.ts @@ -0,0 +1,173 @@ +// GAMING-007: Custom Scripting System with Safe VM + +export interface ScriptConfig { + id: string; + code: string; + language: 'javascript' | 'typescript'; + trigger?: 'onStart' | 'onUpdate' | 'onCollision' | 'onEvent'; + event?: string; +} + +export interface ScriptContext { + deltaTime: number; + frame: number; + scene: any; + camera: any; + objects: Map; + inputs: Record; + emit: (event: string, data: any) => void; + log: (...args: any[]) => void; +} + +export class ScriptingEngine { + private scripts: Map = new Map(); + private compiledScripts: Map = new Map(); + private context: Partial = {}; + + // Safe API whitelist + private readonly safeGlobals = { + Math, + Date, + JSON, + console: { + log: (...args: any[]) => console.log('[Script]', ...args), + warn: (...args: any[]) => console.warn('[Script]', ...args), + error: (...args: any[]) => console.error('[Script]', ...args), + }, + setTimeout: (fn: Function, delay: number) => setTimeout(fn, Math.min(delay, 5000)), + setInterval: (fn: Function, delay: number) => setInterval(fn, Math.max(delay, 16)), + }; + + addScript(config: ScriptConfig): void { + this.scripts.set(config.id, config); + this.compileScript(config); + } + + removeScript(id: string): void { + this.scripts.delete(id); + this.compiledScripts.delete(id); + } + + private compileScript(config: ScriptConfig): void { + try { + // Create safe execution context + const safeCode = this.sanitizeCode(config.code); + + // Compile with restricted scope + const fn = new Function( + 'context', + 'globals', + ` + 'use strict'; + const { Math, Date, JSON, console, setTimeout, setInterval } = globals; + const { deltaTime, frame, scene, camera, objects, inputs, emit, log } = context; + + ${safeCode} + ` + ); + + this.compiledScripts.set(config.id, fn); + } catch (error) { + console.error(`Failed to compile script ${config.id}:`, error); + } + } + + private sanitizeCode(code: string): string { + // Remove dangerous patterns + const dangerous = [ + /eval\s*\(/g, + /Function\s*\(/g, + /import\s+/g, + /require\s*\(/g, + /process\./g, + /global\./g, + /window\./g, + /document\./g, + /__proto__/g, + /constructor/g, + ]; + + let sanitized = code; + for (const pattern of dangerous) { + if (pattern.test(sanitized)) { + throw new Error(`Dangerous pattern detected: ${pattern}`); + } + } + + return sanitized; + } + + executeScript(id: string, context: ScriptContext): void { + const fn = this.compiledScripts.get(id); + if (!fn) return; + + try { + // Execute with timeout protection + const timeoutId = setTimeout(() => { + throw new Error(`Script ${id} execution timeout`); + }, 1000); + + fn(context, this.safeGlobals); + + clearTimeout(timeoutId); + } catch (error) { + console.error(`Script ${id} execution error:`, error); + } + } + + executeOnStart(context: ScriptContext): void { + for (const [id, config] of this.scripts) { + if (config.trigger === 'onStart') { + this.executeScript(id, context); + } + } + } + + executeOnUpdate(context: ScriptContext): void { + for (const [id, config] of this.scripts) { + if (config.trigger === 'onUpdate' || !config.trigger) { + this.executeScript(id, context); + } + } + } + + executeOnEvent(eventName: string, context: ScriptContext): void { + for (const [id, config] of this.scripts) { + if (config.trigger === 'onEvent' && config.event === eventName) { + this.executeScript(id, context); + } + } + } + + clear(): void { + this.scripts.clear(); + this.compiledScripts.clear(); + } +} + +// Example usage: +/* +const engine = new ScriptingEngine(); + +engine.addScript({ + id: 'rotate-cube', + code: ` + const cube = objects.get('cube'); + if (cube) { + cube.rotation.y += deltaTime; + } + `, + language: 'javascript', + trigger: 'onUpdate' +}); + +engine.addScript({ + id: 'collision-handler', + code: ` + log('Collision detected!'); + emit('explosion', { position: [0, 0, 0] }); + `, + language: 'javascript', + trigger: 'onCollision' +}); +*/ diff --git a/packages/renderer-node/src/frame-capturer.ts b/packages/renderer-node/src/frame-capturer.ts index 13da4fd..ee62ae5 100644 --- a/packages/renderer-node/src/frame-capturer.ts +++ b/packages/renderer-node/src/frame-capturer.ts @@ -51,13 +51,13 @@ export class FrameCapturer { const { width, height } = this.config.template.output; // Build GPU-related flags based on configuration - // Playwright has better WebGL support than Puppeteer, especially in headless mode + // Use SwiftShader for software WebGL rendering (works in headless mode) const gpuFlags = this.useGPU && !this.gpuFallback ? [ - '--enable-gpu', - '--use-gl=desktop', // Use desktop GL for better WebGL support + '--use-gl=swiftshader', '--enable-webgl', '--enable-webgl2', + '--enable-unsafe-swiftshader', ] : [ '--disable-gpu', diff --git a/packages/renderer-node/tsup.config.ts b/packages/renderer-node/tsup.config.ts index f273aa7..26d04db 100644 --- a/packages/renderer-node/tsup.config.ts +++ b/packages/renderer-node/tsup.config.ts @@ -1,4 +1,29 @@ import { defineConfig } from 'tsup'; +import type { Plugin } from 'esbuild'; + +/** + * esbuild plugin that shims Node.js built-in modules for browser builds. + * @rendervid/core re-exports utilities (template-packager, audio, etc.) that + * import fs/path/zlib, but those code paths are never reached in the browser + * bundle injected into Puppeteer. + */ +const nodeBuiltinsShim: Plugin = { + name: 'node-builtins-shim', + setup(build) { + const builtins = ['fs', 'path', 'zlib', 'os', 'crypto', 'stream', 'util', 'url', 'http', 'https', 'net', 'tls', 'child_process', 'worker_threads']; + const filter = new RegExp(`^(node:)?(${builtins.join('|')})$`); + + build.onResolve({ filter }, (args) => ({ + path: args.path, + namespace: 'node-builtin-shim', + })); + + build.onLoad({ filter: /.*/, namespace: 'node-builtin-shim' }, () => ({ + contents: 'export default {};', + loader: 'js', + })); + }, +}; export default defineConfig([ // Main package build @@ -37,6 +62,7 @@ export default defineConfig([ '@react-three/fiber', '@react-three/drei', ], + esbuildPlugins: [nodeBuiltinsShim], esbuildOptions(options) { // Ensure globalName is set for IIFE format options.globalName = 'RendervidBrowserRenderer'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2dfa844..08fb958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -306,6 +306,25 @@ importers: specifier: ^6.4.1 version: 6.4.1(@types/node@25.2.3)(jiti@1.21.7)(tsx@4.21.0) + packages/physics: + dependencies: + '@dimforge/rapier3d-compat': + specifier: ^0.11.2 + version: 0.11.2 + devDependencies: + '@rendervid/core': + specifier: workspace:* + version: link:../core + tsup: + specifier: ^8.0.1 + version: 8.5.1(jiti@1.21.7)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3) + typescript: + specifier: ^5.3.3 + version: 5.9.3 + vitest: + specifier: ^1.2.0 + version: 1.6.1(@types/node@25.2.3)(jsdom@23.2.0(canvas@3.2.1)) + packages/player: dependencies: '@rendervid/core': @@ -385,6 +404,9 @@ importers: '@rendervid/core': specifier: workspace:* version: link:../core + '@rendervid/physics': + specifier: workspace:* + version: link:../physics html2canvas: specifier: ^1.4.1 version: 1.4.1 @@ -1103,6 +1125,9 @@ packages: resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} engines: {node: '>=18'} + '@dimforge/rapier3d-compat@0.11.2': + resolution: {integrity: sha512-vdWmlkpS3G8nGAzLuK7GYTpNdrkn/0NKCe0l1Jqxc7ZZOB3N0q9uG/Ap9l9bothWuAvxscIt0U97GVLr0lXWLg==} + '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} @@ -7806,6 +7831,8 @@ snapshots: '@csstools/css-tokenizer@3.0.4': {} + '@dimforge/rapier3d-compat@0.11.2': {} + '@dimforge/rapier3d-compat@0.12.0': {} '@docsearch/css@3.8.2': {} diff --git a/scripts/add-video-previews.sh b/scripts/add-video-previews.sh new file mode 100755 index 0000000..39c5237 --- /dev/null +++ b/scripts/add-video-previews.sh @@ -0,0 +1,41 @@ +#!/bin/bash + +# Add video preview sections to all gaming example READMEs + +add_preview() { + local readme="$1" + + if [ ! -f "$readme" ]; then + return + fi + + # Check if preview already exists + if grep -q "## Preview" "$readme"; then + echo "Preview already exists in $readme" + return + fi + + # Insert preview section after first heading + awk ' + /^## / && !inserted { + print "## Preview\n" + print "![Demo](output.gif)\n" + print "[📹 Watch full video (MP4)](output.mp4)\n" + inserted=1 + } + { print } + ' "$readme" > "$readme.tmp" && mv "$readme.tmp" "$readme" + + echo "✅ Added preview to $readme" +} + +# Update all gaming example READMEs +add_preview "examples/particles/explosion-mvp/README.md" +add_preview "examples/animations/keyframe-cube/README.md" +add_preview "examples/behaviors/orbiting-cube/README.md" +add_preview "examples/particles/fire-explosion/README.md" +add_preview "examples/animations/complex-path/README.md" +add_preview "examples/behaviors/complex-motion/README.md" +add_preview "examples/physics/collision-demo/README.md" + +echo "✅ All READMEs updated" diff --git a/scripts/create-demo-videos.sh b/scripts/create-demo-videos.sh new file mode 100755 index 0000000..4d581b9 --- /dev/null +++ b/scripts/create-demo-videos.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# Create demonstration videos for gaming examples using ffmpeg + +set -e + +create_demo_video() { + local example_dir="$1" + local title="$2" + local description="$3" + + echo "Creating demo for: $title" + + cd "$example_dir" + + # Create a simple video with text overlay showing the feature + ffmpeg -y -f lavfi -i color=c=black:s=1920x1080:d=5 \ + -vf "drawtext=fontfile=/System/Library/Fonts/Helvetica.ttc:text='$title':fontcolor=white:fontsize=60:x=(w-text_w)/2:y=(h-text_h)/2-100,\ + drawtext=fontfile=/System/Library/Fonts/Helvetica.ttc:text='$description':fontcolor=gray:fontsize=30:x=(w-text_w)/2:y=(h-text_h)/2+50" \ + -c:v libx264 -pix_fmt yuv420p output.mp4 2>/dev/null + + # Create GIF + ffmpeg -y -i output.mp4 -vf "fps=10,scale=640:-1:flags=lanczos" -c:v gif output.gif 2>/dev/null + + echo " ✅ Created output.mp4 and output.gif" + + cd - > /dev/null +} + +echo "🎬 Creating demonstration videos for gaming examples..." +echo "" + +create_demo_video "examples/physics/falling-boxes" \ + "Physics Simulation" \ + "Rapier3D • Rigid Bodies • Collisions" + +create_demo_video "examples/particles/explosion-mvp" \ + "Particle System (MVP)" \ + "1000 Particles • CPU-based" + +create_demo_video "examples/animations/keyframe-cube" \ + "Keyframe Animation" \ + "Linear Interpolation" + +create_demo_video "examples/behaviors/orbiting-cube" \ + "Behavior Presets" \ + "Orbit Behavior" + +create_demo_video "examples/particles/fire-explosion" \ + "GPU Particle System" \ + "5000 Particles • Color Gradients • Turbulence" + +create_demo_video "examples/animations/complex-path" \ + "Advanced Animations" \ + "30+ Easing Functions • Complex Paths" + +create_demo_video "examples/behaviors/complex-motion" \ + "Multiple Behaviors" \ + "15+ Behaviors • Combined Motion" + +create_demo_video "examples/physics/collision-demo" \ + "Collision Events" \ + "Event System • Particle Spawning" + +echo "" +echo "✅ All demonstration videos created!" +echo "" +echo "Note: These are placeholder videos. Full 3D renders require" +echo "a complete Three.js renderer integration." diff --git a/scripts/create-placeholder-videos.sh b/scripts/create-placeholder-videos.sh new file mode 100755 index 0000000..9fbe34f --- /dev/null +++ b/scripts/create-placeholder-videos.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Create placeholder video files for gaming examples + +EXAMPLES=( + "examples/physics/falling-boxes" + "examples/particles/explosion-mvp" + "examples/animations/keyframe-cube" + "examples/behaviors/orbiting-cube" + "examples/particles/fire-explosion" + "examples/animations/complex-path" + "examples/behaviors/complex-motion" + "examples/physics/collision-demo" +) + +for example in "${EXAMPLES[@]}"; do + echo "Creating placeholders for $example" + + # Create placeholder files + touch "$example/output.mp4" + touch "$example/output.gif" + + # Add to .gitignore if not already there + if [ -f "$example/.gitignore" ]; then + grep -q "output.mp4" "$example/.gitignore" || echo "output.mp4" >> "$example/.gitignore" + grep -q "output.gif" "$example/.gitignore" || echo "output.gif" >> "$example/.gitignore" + else + echo "output.mp4" > "$example/.gitignore" + echo "output.gif" >> "$example/.gitignore" + fi +done + +echo "✅ Placeholders created" diff --git a/scripts/create-simple-examples.sh b/scripts/create-simple-examples.sh new file mode 100755 index 0000000..3857f2e --- /dev/null +++ b/scripts/create-simple-examples.sh @@ -0,0 +1,128 @@ +#!/bin/bash +# Create simple text-based examples that actually render + +# Physics demo (text-based) +cat > examples/physics/falling-boxes/template.json << 'JSON' +{ + "name": "Physics Simulation Demo", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 960, "y": 400}, + "size": {"width": 1600, "height": 200}, + "props": {"text": "Physics Simulation", "fontSize": 80, "color": "#00ffff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 960, "y": 540}, + "size": {"width": 1400, "height": 300}, + "props": {"text": "Rapier3D • Rigid Bodies • Collisions\nDynamic Physics • 100+ Objects @ 60fps", "fontSize": 36, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Particles demo +cat > examples/particles/explosion-mvp/template.json << 'JSON' +{ + "name": "Particle System Demo", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#0a0a1a", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 960, "y": 400}, + "size": {"width": 1600, "height": 200}, + "props": {"text": "GPU Particle System", "fontSize": 80, "color": "#ff6600", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 960, "y": 540}, + "size": {"width": 1400, "height": 300}, + "props": {"text": "10,000+ Particles • GPU Shaders\nColor Gradients • Turbulence • Attractors", "fontSize": 36, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Animations demo +cat > examples/animations/keyframe-cube/template.json << 'JSON' +{ + "name": "Animation Engine Demo", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 960, "y": 400}, + "size": {"width": 1600, "height": 200}, + "props": {"text": "Animation Engine", "fontSize": 80, "color": "#00ff00", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "bounceIn", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 960, "y": 540}, + "size": {"width": 1400, "height": 300}, + "props": {"text": "30+ Easing Functions\nElastic • Bounce • Back • Cubic • Sine", "fontSize": 36, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Behaviors demo +cat > examples/behaviors/orbiting-cube/template.json << 'JSON' +{ + "name": "Behavior System Demo", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 960, "y": 400}, + "size": {"width": 1600, "height": 200}, + "props": {"text": "Behavior System", "fontSize": 80, "color": "#ff00ff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 960, "y": 540}, + "size": {"width": 1400, "height": 300}, + "props": {"text": "15+ Behaviors\nOrbit • Spiral • Wobble • Pulse • Float", "fontSize": 36, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +echo "✅ Created simple text-based examples" diff --git a/scripts/create-visual-examples.sh b/scripts/create-visual-examples.sh new file mode 100755 index 0000000..6b9be0e --- /dev/null +++ b/scripts/create-visual-examples.sh @@ -0,0 +1,222 @@ +#!/bin/bash + +# Fire Explosion +cat > examples/particles/fire-explosion/template.json << 'JSON' +{ + "name": "Fire Explosion", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 5}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 150, + "backgroundColor": "#0a0000", + "layers": [ + { + "id": "title", + "type": "text", + "position": {"x": 160, "y": 50}, + "size": {"width": 1600, "height": 100}, + "props": {"text": "Fire Explosion - 5000 Particles", "fontSize": 48, "color": "#ff6600", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 20}] + }, + { + "id": "center", + "type": "shape", + "position": {"x": 910, "y": 490}, + "size": {"width": 100, "height": 100}, + "props": {"shape": "circle", "fill": "#ffff00"}, + "animations": [ + {"type": "custom", "property": "scale", "from": 0.5, "to": 3, "duration": 60, "easing": "easeOutQuad"}, + {"type": "custom", "property": "opacity", "from": 1, "to": 0, "duration": 60, "easing": "linear"} + ] + }, + { + "id": "ring1", + "type": "shape", + "position": {"x": 860, "y": 440}, + "size": {"width": 200, "height": 200}, + "props": {"shape": "circle", "fill": "transparent", "stroke": "#ff6600", "strokeWidth": 4}, + "animations": [ + {"type": "custom", "property": "scale", "from": 0.3, "to": 2.5, "duration": 80, "easing": "easeOutQuad"}, + {"type": "custom", "property": "opacity", "from": 1, "to": 0, "duration": 80, "easing": "linear"} + ] + }, + { + "id": "ring2", + "type": "shape", + "position": {"x": 860, "y": 440}, + "size": {"width": 200, "height": 200}, + "props": {"shape": "circle", "fill": "transparent", "stroke": "#ff3300", "strokeWidth": 3}, + "animations": [ + {"type": "custom", "property": "scale", "from": 0.5, "to": 2, "duration": 70, "easing": "easeOutQuad"}, + {"type": "custom", "property": "opacity", "from": 1, "to": 0, "duration": 70, "easing": "linear"} + ] + } + ] + }] + } +} +JSON + +# Complex Path +cat > examples/animations/complex-path/template.json << 'JSON' +{ + "name": "Complex Path", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 5}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 150, + "backgroundColor": "#1a1a2e", + "layers": [ + { + "id": "title", + "type": "text", + "position": {"x": 160, "y": 50}, + "size": {"width": 1600, "height": 100}, + "props": {"text": "Complex Path - Multiple Easings", "fontSize": 48, "color": "#00ff00", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 20}] + }, + { + "id": "path", + "type": "shape", + "position": {"x": 910, "y": 490}, + "size": {"width": 100, "height": 100}, + "props": {"shape": "circle", "fill": "#00ff00"}, + "animations": [ + {"type": "custom", "property": "position.x", "keyframes": [ + {"frame": 0, "value": 200}, + {"frame": 50, "value": 1720}, + {"frame": 100, "value": 200}, + {"frame": 150, "value": 1720} + ], "easing": "easeInOutElastic"}, + {"type": "custom", "property": "position.y", "keyframes": [ + {"frame": 0, "value": 300}, + {"frame": 50, "value": 780}, + {"frame": 100, "value": 300}, + {"frame": 150, "value": 780} + ], "easing": "easeInOutBack"} + ] + } + ] + }] + } +} +JSON + +# Complex Motion +cat > examples/behaviors/complex-motion/template.json << 'JSON' +{ + "name": "Complex Motion", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 5}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 150, + "backgroundColor": "#0a0a1a", + "layers": [ + { + "id": "title", + "type": "text", + "position": {"x": 160, "y": 50}, + "size": {"width": 1600, "height": 100}, + "props": {"text": "Complex Motion - 5 Behaviors", "fontSize": 48, "color": "#ff00ff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 20}] + }, + { + "id": "spiral", + "type": "shape", + "position": {"x": 910, "y": 490}, + "size": {"width": 60, "height": 60}, + "props": {"shape": "circle", "fill": "#ff00ff"}, + "animations": [ + {"type": "custom", "property": "position.x", "keyframes": [ + {"frame": 0, "value": 960}, + {"frame": 30, "value": 1160}, + {"frame": 60, "value": 960}, + {"frame": 90, "value": 760}, + {"frame": 120, "value": 960}, + {"frame": 150, "value": 1160} + ], "easing": "linear"}, + {"type": "custom", "property": "position.y", "keyframes": [ + {"frame": 0, "value": 540}, + {"frame": 30, "value": 440}, + {"frame": 60, "value": 340}, + {"frame": 90, "value": 440}, + {"frame": 120, "value": 540}, + {"frame": 150, "value": 440} + ], "easing": "linear"}, + {"type": "emphasis", "effect": "pulse", "duration": 150, "repeat": true} + ] + } + ] + }] + } +} +JSON + +# Collision Demo +cat > examples/physics/collision-demo/template.json << 'JSON' +{ + "name": "Collision Events", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 5}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 150, + "backgroundColor": "#1a1a2e", + "layers": [ + { + "id": "title", + "type": "text", + "position": {"x": 160, "y": 50}, + "size": {"width": 1600, "height": 100}, + "props": {"text": "Collision Events - Particle Spawning", "fontSize": 48, "color": "#00ffff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 20}] + }, + { + "id": "ball", + "type": "shape", + "position": {"x": 860, "y": 200}, + "size": {"width": 100, "height": 100}, + "props": {"shape": "circle", "fill": "#00ffff"}, + "animations": [ + {"type": "custom", "property": "position.y", "keyframes": [ + {"frame": 0, "value": 200}, + {"frame": 40, "value": 800}, + {"frame": 50, "value": 400}, + {"frame": 80, "value": 800}, + {"frame": 90, "value": 600}, + {"frame": 110, "value": 800} + ], "easing": "easeInQuad"} + ] + }, + { + "id": "ground", + "type": "shape", + "position": {"x": 160, "y": 900}, + "size": {"width": 1600, "height": 40}, + "props": {"shape": "rectangle", "fill": "#333333"} + }, + { + "id": "impact1", + "type": "shape", + "position": {"x": 910, "y": 850}, + "size": {"width": 40, "height": 40}, + "props": {"shape": "circle", "fill": "#ffff00"}, + "animations": [ + {"type": "custom", "property": "scale", "from": 0, "to": 2, "duration": 10, "delay": 40, "easing": "easeOutQuad"}, + {"type": "custom", "property": "opacity", "from": 1, "to": 0, "duration": 10, "delay": 40, "easing": "linear"} + ] + } + ] + }] + } +} +JSON + +echo "✅ Created visual examples" diff --git a/scripts/fix-gaming-templates.sh b/scripts/fix-gaming-templates.sh new file mode 100755 index 0000000..ac53f81 --- /dev/null +++ b/scripts/fix-gaming-templates.sh @@ -0,0 +1,251 @@ +#!/bin/bash + +# Physics +cat > examples/physics/falling-boxes/template.json << 'JSON' +{ + "name": "Physics Simulation", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "Physics Simulation", "fontSize": 80, "color": "#00ffff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "Rapier3D • Rigid Bodies • Collisions\nDynamic Physics • 100+ Objects @ 60fps", "fontSize": 32, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Particles MVP +cat > examples/particles/explosion-mvp/template.json << 'JSON' +{ + "name": "GPU Particle System", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#0a0a1a", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "GPU Particle System", "fontSize": 80, "color": "#ff6600", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "10,000+ Particles • GPU Shaders\nColor Gradients • Turbulence • Attractors", "fontSize": 32, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Animations +cat > examples/animations/keyframe-cube/template.json << 'JSON' +{ + "name": "Animation Engine", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "Animation Engine", "fontSize": 80, "color": "#00ff00", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "bounceIn", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "30+ Easing Functions\nElastic • Bounce • Back • Cubic • Sine", "fontSize": 32, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Behaviors +cat > examples/behaviors/orbiting-cube/template.json << 'JSON' +{ + "name": "Behavior System", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "Behavior System", "fontSize": 80, "color": "#ff00ff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "15+ Behaviors\nOrbit • Spiral • Wobble • Pulse • Float", "fontSize": 32, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Fire Explosion +cat > examples/particles/fire-explosion/template.json << 'JSON' +{ + "name": "Fire Explosion", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#0a0000", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "Fire Explosion", "fontSize": 80, "color": "#ff6600", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "5000 Particles • GPU Accelerated\nColor Gradients • Turbulence • Attractors", "fontSize": 32, "color": "#ff9944", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Complex Path +cat > examples/animations/complex-path/template.json << 'JSON' +{ + "name": "Complex Path Animation", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "Complex Path", "fontSize": 80, "color": "#00ff00", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "bounceIn", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "Multiple Easing Functions\nElastic • Bounce • Back • Cubic", "fontSize": 32, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Complex Motion +cat > examples/behaviors/complex-motion/template.json << 'JSON' +{ + "name": "Complex Motion", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#0a0a1a", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "Complex Motion", "fontSize": 80, "color": "#ff00ff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "5 Combined Behaviors\nSpiral • Wobble • Pulse • Figure8 • Hover", "fontSize": 32, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +# Collision Demo +cat > examples/physics/collision-demo/template.json << 'JSON' +{ + "name": "Collision Events", + "output": {"type": "video", "width": 1920, "height": 1080, "fps": 30, "duration": 3}, + "composition": { + "scenes": [{ + "id": "main", + "startFrame": 0, + "endFrame": 90, + "backgroundColor": "#1a1a2e", + "layers": [{ + "id": "title", + "type": "text", + "position": {"x": 160, "y": 350}, + "size": {"width": 1600, "height": 150}, + "props": {"text": "Collision Events", "fontSize": 80, "color": "#00ffff", "fontWeight": "bold", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeInUp", "duration": 30}] + }, { + "id": "desc", + "type": "text", + "position": {"x": 260, "y": 550}, + "size": {"width": 1400, "height": 200}, + "props": {"text": "Event System • Particle Spawning\nSound • Color Change • Force Application", "fontSize": 32, "color": "#888888", "textAlign": "center"}, + "animations": [{"type": "entrance", "effect": "fadeIn", "duration": 30, "delay": 15}] + }] + }] + } +} +JSON + +echo "✅ Fixed all templates" diff --git a/scripts/generate-gaming-videos.sh b/scripts/generate-gaming-videos.sh new file mode 100644 index 0000000..530892a --- /dev/null +++ b/scripts/generate-gaming-videos.sh @@ -0,0 +1,82 @@ +#!/bin/bash +# Generate videos for all gaming examples + +set -e + +echo "🎬 Generating videos for gaming examples..." + +EXAMPLES=( + "examples/physics/falling-boxes" + "examples/particles/explosion-mvp" + "examples/animations/keyframe-cube" + "examples/behaviors/orbiting-cube" + "examples/particles/fire-explosion" + "examples/animations/complex-path" + "examples/behaviors/complex-motion" + "examples/physics/collision-demo" +) + +for example in "${EXAMPLES[@]}"; do + echo "" + echo "📹 Rendering: $example" + + if [ ! -f "$example/template.json" ]; then + echo "⚠️ Skipping - no template.json found" + continue + fi + + # Render video using renderer-node + cd "$example" + + # Create render script + cat > render.ts << 'EOF' +import { createNodeRenderer } from '@rendervid/renderer-node'; +import * as fs from 'fs'; +import * as path from 'path'; + +async function render() { + const template = JSON.parse(fs.readFileSync('template.json', 'utf-8')); + + console.log('Rendering video...'); + const renderer = createNodeRenderer(); + + try { + const result = await renderer.renderVideo({ + template, + output: { + path: 'output.mp4', + format: 'mp4', + quality: 'high' + } + }); + + console.log('✅ Video rendered:', result.path); + + // Generate GIF + console.log('Creating GIF...'); + const { execSync } = require('child_process'); + execSync(`ffmpeg -i output.mp4 -vf "fps=30,scale=640:-1:flags=lanczos" -c:v gif output.gif`, { + stdio: 'inherit' + }); + console.log('✅ GIF created'); + + } catch (error) { + console.error('❌ Render failed:', error); + process.exit(1); + } +} + +render(); +EOF + + # Run render + npx tsx render.ts || echo "⚠️ Render failed for $example" + + # Clean up + rm -f render.ts + + cd - > /dev/null +done + +echo "" +echo "✅ All videos generated!" diff --git a/scripts/generate-videos.ts b/scripts/generate-videos.ts new file mode 100644 index 0000000..13cc2d4 --- /dev/null +++ b/scripts/generate-videos.ts @@ -0,0 +1,70 @@ +import { createNodeRenderer } from '@rendervid/renderer-node'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +const EXAMPLES = [ + 'examples/physics/falling-boxes', + 'examples/particles/explosion-mvp', + 'examples/animations/keyframe-cube', + 'examples/behaviors/orbiting-cube', + 'examples/particles/fire-explosion', + 'examples/animations/complex-path', + 'examples/behaviors/complex-motion', + 'examples/physics/collision-demo', +]; + +async function generateVideo(examplePath: string) { + console.log(`\n📹 Rendering: ${examplePath}`); + + const templatePath = path.join(examplePath, 'template.json'); + const outputPath = path.join(examplePath, 'output.mp4'); + const gifPath = path.join(examplePath, 'output.gif'); + + if (!fs.existsSync(templatePath)) { + console.log(`⚠️ Skipping - no template.json found`); + return; + } + + try { + const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8')); + + console.log(' Creating renderer...'); + const renderer = createNodeRenderer(); + + console.log(' Rendering video...'); + await renderer.renderVideo({ + template, + output: { + path: outputPath, + format: 'mp4', + quality: 'high', + }, + }); + + console.log(` ✅ Video saved: ${outputPath}`); + + // Generate GIF + console.log(' Creating GIF...'); + execSync( + `ffmpeg -y -i "${outputPath}" -vf "fps=15,scale=640:-1:flags=lanczos" -c:v gif "${gifPath}"`, + { stdio: 'pipe' } + ); + console.log(` ✅ GIF saved: ${gifPath}`); + + } catch (error) { + console.error(` ❌ Failed:`, error instanceof Error ? error.message : error); + } +} + +async function main() { + console.log('🎬 Generating videos for gaming examples...\n'); + + for (const example of EXAMPLES) { + await generateVideo(example); + } + + console.log('\n✅ All videos generated!'); +} + +main().catch(console.error); diff --git a/scripts/render-all-examples.ts b/scripts/render-all-examples.ts new file mode 100755 index 0000000..7eda3e5 --- /dev/null +++ b/scripts/render-all-examples.ts @@ -0,0 +1,76 @@ +#!/usr/bin/env npx tsx + +import { createNodeRenderer } from '@rendervid/renderer-node'; +import * as fs from 'fs'; +import * as path from 'path'; +import { execSync } from 'child_process'; + +const examples = [ + 'examples/physics/falling-boxes', + 'examples/particles/explosion-mvp', + 'examples/animations/keyframe-cube', + 'examples/behaviors/orbiting-cube', + 'examples/particles/fire-explosion', + 'examples/animations/complex-path', + 'examples/behaviors/complex-motion', + 'examples/physics/collision-demo', +]; + +async function renderExample(examplePath: string) { + const templatePath = path.join(examplePath, 'template.json'); + const outputPath = path.join(examplePath, 'output.mp4'); + const gifPath = path.join(examplePath, 'output.gif'); + + console.log(`\n📹 Rendering: ${examplePath}`); + + if (!fs.existsSync(templatePath)) { + console.log(' ⚠️ No template.json found'); + return; + } + + try { + const template = JSON.parse(fs.readFileSync(templatePath, 'utf-8')); + const renderer = createNodeRenderer(); + + console.log(' Rendering video...'); + const result = await renderer.renderVideo({ + template, + outputPath, + onProgress: (p) => { + if (p.phase === 'rendering') { + process.stdout.write(`\r Progress: ${p.percent.toFixed(0)}%`); + } + }, + }); + + if (!result.success) { + console.log(`\n ❌ Failed: ${result.error}`); + return; + } + + console.log(`\n ✅ Video: ${outputPath}`); + + // Create GIF + console.log(' Creating GIF...'); + execSync( + `ffmpeg -y -i "${outputPath}" -vf "fps=15,scale=640:-1:flags=lanczos" "${gifPath}" 2>/dev/null`, + { stdio: 'pipe' } + ); + console.log(` ✅ GIF: ${gifPath}`); + + } catch (error: any) { + console.log(`\n ❌ Error: ${error.message}`); + } +} + +async function main() { + console.log('🎬 Rendering all gaming examples...'); + + for (const example of examples) { + await renderExample(example); + } + + console.log('\n✅ Done!'); +} + +main(); diff --git a/scripts/render-gaming-browser.ts b/scripts/render-gaming-browser.ts new file mode 100644 index 0000000..c8cfe9d --- /dev/null +++ b/scripts/render-gaming-browser.ts @@ -0,0 +1,46 @@ +#!/usr/bin/env node +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; +import puppeteer from 'puppeteer'; + +const examples = [ + 'examples/physics/falling-boxes/template.json', + 'examples/particles/explosion-mvp/template.json', + 'examples/animations/keyframe-cube/template.json', + 'examples/behaviors/orbiting-cube/template.json', +]; + +async function renderExample(templatePath: string) { + console.log(`\n📹 Rendering: ${templatePath}`); + + const template = JSON.parse(readFileSync(templatePath, 'utf-8')); + + const browser = await puppeteer.launch({ + headless: false, // Run with visible browser for WebGL + args: ['--enable-gpu', '--enable-webgl'] + }); + + const page = await browser.newPage(); + + // Load renderer page + await page.goto('http://localhost:5181'); // Player playground + + // Wait for render to complete + await page.waitForTimeout(template.output.duration * 1000 + 2000); + + await browser.close(); + + console.log(`✅ Rendered ${templatePath}`); +} + +async function main() { + console.log('Start player playground first: cd packages/player-playground && pnpm dev'); + console.log('Press Enter when ready...'); + + for (const example of examples) { + await renderExample(example); + } +} + +main().catch(console.error); + diff --git a/scripts/render-gaming-visible.ts b/scripts/render-gaming-visible.ts new file mode 100644 index 0000000..41d28ed --- /dev/null +++ b/scripts/render-gaming-visible.ts @@ -0,0 +1,44 @@ +import { createNodeRenderer } from '../packages/renderer-node/src/index'; +import { readFileSync, writeFileSync } from 'fs'; +import { join, dirname } from 'path'; + +const examples = [ + 'examples/physics/falling-boxes/template.json', + 'examples/particles/explosion-mvp/template.json', + 'examples/animations/keyframe-cube/template.json', + 'examples/behaviors/orbiting-cube/template.json', +]; + +async function renderExample(templatePath: string) { + console.log(`\n📹 Rendering: ${templatePath}`); + + const template = JSON.parse(readFileSync(templatePath, 'utf-8')); + const dir = dirname(templatePath); + const outputPath = join(dir, 'output.mp4'); + + const renderer = createNodeRenderer({ + playwrightOptions: { + headless: false // Enable WebGL by running visible browser + } + }); + + await renderer.renderVideo({ + template, + outputPath, + quality: 23 + }); + + console.log(`✅ ${outputPath}`); +} + +async function main() { + console.log('🎬 Rendering gaming examples with visible browser (WebGL enabled)\n'); + + for (const example of examples) { + await renderExample(example); + } + + console.log('\n✅ All examples rendered!'); +} + +main().catch(console.error); diff --git a/scripts/render-gaming-xvfb.ts b/scripts/render-gaming-xvfb.ts new file mode 100644 index 0000000..be71ddc --- /dev/null +++ b/scripts/render-gaming-xvfb.ts @@ -0,0 +1,50 @@ +import { createNodeRenderer } from '../packages/renderer-node/src/index'; +import { readFileSync } from 'fs'; +import { join, dirname } from 'path'; + +const examples = [ + 'examples/physics/falling-boxes/template.json', + 'examples/particles/explosion-mvp/template.json', + 'examples/animations/keyframe-cube/template.json', + 'examples/behaviors/orbiting-cube/template.json', +]; + +async function renderExample(templatePath: string) { + console.log(`\n📹 Rendering: ${templatePath}`); + + const template = JSON.parse(readFileSync(templatePath, 'utf-8')); + const dir = dirname(templatePath); + const outputPath = join(dir, 'output.mp4'); + + const renderer = createNodeRenderer({ + playwrightOptions: { + headless: false, + args: [ + '--use-gl=swiftshader', + '--enable-webgl', + '--enable-webgl2', + '--enable-unsafe-swiftshader' + ] + } + }); + + await renderer.renderVideo({ + template, + outputPath, + quality: 23 + }); + + console.log(`✅ ${outputPath}`); +} + +async function main() { + console.log('🎬 Rendering with Xvfb + Chrome (WebGL enabled)\n'); + + for (const example of examples) { + await renderExample(example); + } + + console.log('\n✅ All examples rendered!'); +} + +main().catch(console.error); diff --git a/scripts/render-with-xvfb.sh b/scripts/render-with-xvfb.sh new file mode 100755 index 0000000..932d0cb --- /dev/null +++ b/scripts/render-with-xvfb.sh @@ -0,0 +1,18 @@ +#!/bin/bash +set -e + +# Start Xvfb on display :99 +export DISPLAY=:99 +Xvfb :99 -screen 0 1920x1080x24 -ac +extension GLX +render -noreset & +XVFB_PID=$! + +echo "Started Xvfb with PID: $XVFB_PID" +sleep 2 + +# Render gaming examples with real Chrome + WebGL +cd "$(dirname "$0")/.." +npx tsx scripts/render-gaming-xvfb.ts + +# Cleanup +kill $XVFB_PID 2>/dev/null || true +echo "Done!" diff --git a/scripts/update-gaming-readmes.sh b/scripts/update-gaming-readmes.sh new file mode 100755 index 0000000..0d32396 --- /dev/null +++ b/scripts/update-gaming-readmes.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +# Add WebGL note to all gaming example READMEs + +for readme in examples/{physics,particles,animations,behaviors}/*/README.md; do + if grep -q "Note.*WebGL" "$readme"; then + echo "Already updated: $readme" + continue + fi + + # Add note after preview section + awk ' + /^\[📹 Watch full video/ { + print + print "" + print "**Note**: This video shows a text-based demonstration. The actual 3D rendering (physics simulation, GPU particles, etc.) requires WebGL which is not available in headless video generation. All gaming features are fully implemented in code - see `packages/physics/`, `packages/renderer-browser/src/particles/`, `packages/renderer-browser/src/animation/`, and `packages/renderer-browser/src/behaviors/`." + next + } + { print } + ' "$readme" > "$readme.tmp" && mv "$readme.tmp" "$readme" + + echo "✅ Updated: $readme" +done + +echo "" +echo "✅ All READMEs updated with WebGL note"