@vibecuting/video-project-core 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibecuting/video-project-core",
3
- "version": "0.1.16",
3
+ "version": "0.1.18",
4
4
  "type": "module",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "main": "src/index.ts",
@@ -14,12 +14,14 @@
14
14
  },
15
15
  "scripts": {
16
16
  "lint": "node ../../../scripts/run-eslint.mjs .",
17
- "typecheck": "node ../../../node_modules/.pnpm/typescript@5.9.3/node_modules/typescript/bin/tsc -p tsconfig.json --noEmit",
18
- "test": "node ../../../node_modules/vitest/vitest.mjs run"
17
+ "typecheck": "pnpm exec tsc -p tsconfig.json --noEmit",
18
+ "test": "pnpm exec vitest run"
19
19
  },
20
20
  "dependencies": {
21
- "@vibecuting/component-project-helper": "0.1.16",
22
- "react": "^19.0.0"
21
+ "@vibecuting/component-project-helper": "0.1.18",
22
+ "@remotion/transitions": "4.0.473",
23
+ "react": "^19.0.0",
24
+ "zod": "4.1.12"
23
25
  },
24
26
  "peerDependencies": {
25
27
  "remotion": "^4.0.0"
@@ -27,6 +29,10 @@
27
29
  "devDependencies": {
28
30
  "@types/react": "18.2.20",
29
31
  "@types/react-dom": "18.2.7",
30
- "remotion": "4.0.473"
32
+ "remotion": "4.0.473",
33
+ "typescript": "5.9.3",
34
+ "vitest": "^4.1.0",
35
+ "@testing-library/jest-dom": "^6.8.0",
36
+ "@types/node": "20.12.2"
31
37
  }
32
38
  }
@@ -0,0 +1,2 @@
1
+ export * from './resolve-video-plugin-timeline'
2
+ export * from './video-plugin-composer'
@@ -0,0 +1,103 @@
1
+ import type { SceneThemeRegistry } from '../themes'
2
+ import type { SceneTheme } from '../themes'
3
+ import type { DefaultSceneProps } from '../scenes/default-scene-props'
4
+ import type { ScenePluginRegistryEntry } from '../plugins/scene-component'
5
+ import type { TransitionPlugin } from '../transitions/transition-plugin'
6
+
7
+ export type VideoSceneSelection = {
8
+ id: string
9
+ chapterId: string
10
+ pluginKey: string
11
+ durationInFrames: number
12
+ themePreset?: string
13
+ props: unknown
14
+ }
15
+
16
+ export type VideoBoundarySelection = {
17
+ afterSceneId: string
18
+ pluginKey: string
19
+ props: unknown
20
+ }
21
+
22
+ export type ResolvedVideoTimeline = {
23
+ durationInFrames: number
24
+ scenes: Array<{
25
+ id: string
26
+ chapterId: string
27
+ durationInFrames: number
28
+ pluginKey: string
29
+ props: unknown
30
+ theme?: SceneTheme
31
+ plugin: ScenePluginRegistryEntry<DefaultSceneProps>
32
+ }>
33
+ boundaries: Array<{
34
+ afterSceneId: string
35
+ pluginKey: string
36
+ props: unknown
37
+ plugin: TransitionPlugin<unknown>
38
+ }>
39
+ }
40
+
41
+ export type ResolveVideoPluginTimelineInput = {
42
+ fps: number
43
+ scenes: VideoSceneSelection[]
44
+ boundaries: VideoBoundarySelection[]
45
+ scenePluginRegistry: { get(key: string): ScenePluginRegistryEntry<DefaultSceneProps> }
46
+ transitionPluginRegistry: { get(key: string): TransitionPlugin<unknown> }
47
+ sceneThemeRegistry: SceneThemeRegistry
48
+ }
49
+
50
+ export function resolveVideoPluginTimeline(
51
+ input: ResolveVideoPluginTimelineInput,
52
+ ): ResolvedVideoTimeline {
53
+ const scenes = input.scenes.map((scene) => ({
54
+ ...scene,
55
+ plugin: input.scenePluginRegistry.get(scene.pluginKey),
56
+ theme: scene.themePreset ? input.sceneThemeRegistry.get(scene.themePreset) : undefined,
57
+ }))
58
+
59
+ const boundaries = input.boundaries.map((boundary) => ({
60
+ ...boundary,
61
+ plugin: input.transitionPluginRegistry.get(boundary.pluginKey),
62
+ }))
63
+
64
+ const boundaryBySceneId = new Map<string, typeof boundaries[number]>()
65
+ for (const boundary of boundaries) {
66
+ if (boundaryBySceneId.has(boundary.afterSceneId)) {
67
+ throw new Error(`duplicate boundary after scene: ${boundary.afterSceneId}`)
68
+ }
69
+ boundaryBySceneId.set(boundary.afterSceneId, boundary)
70
+ }
71
+
72
+ let durationInFrames = 0
73
+ scenes.forEach((scene, index) => {
74
+ const boundary = boundaryBySceneId.get(scene.id)
75
+ durationInFrames += scene.durationInFrames
76
+
77
+ if (!boundary) {
78
+ return
79
+ }
80
+
81
+ const boundaryDuration = boundary.plugin.getBoundaryDurationInFrames(boundary.props, input.fps)
82
+ const overlap = boundary.plugin.getTimelineOverlapInFrames(boundary.props, input.fps)
83
+
84
+ if (boundary.plugin.kind === 'transition') {
85
+ durationInFrames -= overlap
86
+ }
87
+
88
+ if (index === scenes.length - 1) {
89
+ throw new Error(`boundary cannot follow last scene: ${scene.id}`)
90
+ }
91
+
92
+ const nextScene = scenes[index + 1]
93
+ if (boundaryDuration > scene.durationInFrames || boundaryDuration > nextScene.durationInFrames) {
94
+ throw new Error(`transition duration exceeds adjacent scenes: ${boundary.afterSceneId}`)
95
+ }
96
+ })
97
+
98
+ return {
99
+ durationInFrames,
100
+ scenes,
101
+ boundaries,
102
+ }
103
+ }
@@ -0,0 +1,54 @@
1
+ import type { ReactNode } from 'react'
2
+ import { createElement, Fragment } from 'react'
3
+
4
+ import type { ResolvedVideoTimeline } from './resolve-video-plugin-timeline'
5
+
6
+ type TransitionSeriesProps = {
7
+ children: ReactNode
8
+ }
9
+
10
+ type TransitionSeriesSequenceProps = {
11
+ durationInFrames: number
12
+ premountFor?: number
13
+ children: ReactNode
14
+ }
15
+
16
+ type TransitionSeriesBoundaryProps = {
17
+ children?: ReactNode
18
+ }
19
+
20
+ function TransitionSeries({ children }: TransitionSeriesProps) {
21
+ return createElement(Fragment, null, children)
22
+ }
23
+
24
+ TransitionSeries.Sequence = function TransitionSeriesSequence({
25
+ children,
26
+ }: TransitionSeriesSequenceProps) {
27
+ return createElement(Fragment, null, children)
28
+ }
29
+
30
+ TransitionSeries.Transition = function TransitionSeriesBoundary({
31
+ children,
32
+ }: TransitionSeriesBoundaryProps) {
33
+ return createElement(Fragment, null, children)
34
+ }
35
+
36
+ export function VideoPluginComposer({
37
+ timeline,
38
+ }: {
39
+ timeline: ResolvedVideoTimeline
40
+ }) {
41
+ return (
42
+ <TransitionSeries>
43
+ {timeline.scenes.map((scene) => (
44
+ <TransitionSeries.Sequence
45
+ key={scene.id}
46
+ durationInFrames={scene.durationInFrames}
47
+ premountFor={Math.min(30, scene.durationInFrames)}
48
+ >
49
+ {scene.plugin.render(scene.props)}
50
+ </TransitionSeries.Sequence>
51
+ ))}
52
+ </TransitionSeries>
53
+ )
54
+ }
@@ -1,52 +1,76 @@
1
- import {
2
- BaseRemotionScene,
3
- type BaseRemotionSceneProps,
4
- useRemotionSceneRuntime,
5
- } from '../../../base'
6
- import {
7
- VideoComponent,
8
- defineComponentProjectComponentMetadata,
9
- } from '@vibecuting/component-project-helper'
1
+ import { AbsoluteFill } from 'remotion'
2
+ import { z } from 'zod'
10
3
 
11
- export interface Default2DSceneProps extends BaseRemotionSceneProps {
4
+ import { VideoComponent, defineScenePluginMetadata } from '@vibecuting/component-project-helper'
5
+
6
+ import { defineSceneComponent } from '../../../plugins/scene-component'
7
+ import { useRemotionSceneRuntime } from '../../../base'
8
+ import { useSceneRootProps } from '../../../runtime/use-scene-root-props'
9
+
10
+ export interface Default2DSceneProps {
11
+ sceneId?: string
12
+ sceneName?: string
12
13
  title: string
13
14
  subtitle?: string
14
15
  }
15
16
 
16
- export const componentMetadata = defineComponentProjectComponentMetadata({
17
+ export const componentMetadata = defineScenePluginMetadata({
18
+ resourceKind: 'scene',
17
19
  name: 'Default2DScene',
18
20
  description: 'Default remotion 2D scene',
19
21
  sourceFile: 'src/core/scene/2dscene/default-2d-scene.tsx',
22
+ pluginKey: 'core.canvas-2d',
23
+ tags: ['scene', '2d'],
20
24
  aspectRatio: '16:9',
21
25
  sceneType: 'scene',
22
- tags: ['scene', '2d'],
26
+ sceneFamily: 'canvas-2d',
27
+ rootLayout: 'absolute-fill',
23
28
  propsTypeName: 'Default2DSceneProps',
24
29
  })
25
30
 
26
- export function Default2DScene({ id, name, title, subtitle }: Default2DSceneProps) {
27
- const runtime = useRemotionSceneRuntime()
28
- const titleFontSize = Math.round(runtime.height * 0.062)
29
- const subtitleFontSize = Math.round(runtime.height * 0.024)
30
-
31
- return (
32
- <BaseRemotionScene
33
- id={id}
34
- name={name}
35
- style={{
36
- backgroundColor: '#111827',
37
- color: '#f8fafc',
38
- display: 'flex',
39
- alignItems: 'center',
40
- justifyContent: 'flex-start',
41
- padding: Math.round(runtime.height * 0.06),
42
- }}
43
- >
44
- <div style={{ width: '100%', maxWidth: Math.round(runtime.width * 0.72) }}>
45
- <h1 style={{ margin: 0, fontSize: titleFontSize, lineHeight: 1.05 }}>{title}</h1>
46
- {subtitle ? <p style={{ margin: '16px 0 0', fontSize: subtitleFontSize }}>{subtitle}</p> : null}
47
- </div>
48
- </BaseRemotionScene>
49
- )
50
- }
31
+ export const Default2DScene = VideoComponent(componentMetadata)(
32
+ defineSceneComponent({
33
+ family: 'canvas-2d',
34
+ propsSchema: z.object({
35
+ sceneId: z.string().optional(),
36
+ sceneName: z.string().optional(),
37
+ title: z.string().min(1),
38
+ subtitle: z.string().optional(),
39
+ }),
40
+ component: function Default2DScene({
41
+ sceneId,
42
+ sceneName,
43
+ title,
44
+ subtitle,
45
+ }: Default2DSceneProps) {
46
+ const runtime = useRemotionSceneRuntime()
47
+ const rootProps = useSceneRootProps({
48
+ pluginKey: componentMetadata.pluginKey,
49
+ sceneId,
50
+ sceneName: sceneName ?? title,
51
+ })
52
+ const titleFontSize = Math.round(runtime.height * 0.062)
53
+ const subtitleFontSize = Math.round(runtime.height * 0.024)
51
54
 
52
- VideoComponent(componentMetadata)(Default2DScene)
55
+ return (
56
+ <AbsoluteFill
57
+ {...rootProps}
58
+ style={{
59
+ ...rootProps.style,
60
+ backgroundColor: '#111827',
61
+ color: '#f8fafc',
62
+ display: 'flex',
63
+ alignItems: 'center',
64
+ justifyContent: 'flex-start',
65
+ padding: Math.round(runtime.height * 0.06),
66
+ }}
67
+ >
68
+ <div style={{ width: '100%', maxWidth: Math.round(runtime.width * 0.72) }}>
69
+ <h1 style={{ margin: 0, fontSize: titleFontSize, lineHeight: 1.05 }}>{title}</h1>
70
+ {subtitle ? <p style={{ margin: '16px 0 0', fontSize: subtitleFontSize }}>{subtitle}</p> : null}
71
+ </div>
72
+ </AbsoluteFill>
73
+ )
74
+ },
75
+ }),
76
+ )
@@ -1,54 +1,78 @@
1
- import {
2
- BaseRemotionScene,
3
- type BaseRemotionSceneProps,
4
- useRemotionSceneRuntime,
5
- } from '../../../base'
6
- import {
7
- VideoComponent,
8
- defineComponentProjectComponentMetadata,
9
- } from '@vibecuting/component-project-helper'
1
+ import { AbsoluteFill } from 'remotion'
2
+ import { z } from 'zod'
10
3
 
11
- export interface Default3DSceneProps extends BaseRemotionSceneProps {
4
+ import { VideoComponent, defineScenePluginMetadata } from '@vibecuting/component-project-helper'
5
+
6
+ import { defineSceneComponent } from '../../../plugins/scene-component'
7
+ import { useRemotionSceneRuntime } from '../../../base'
8
+ import { useSceneRootProps } from '../../../runtime/use-scene-root-props'
9
+
10
+ export interface Default3DSceneProps {
11
+ sceneId?: string
12
+ sceneName?: string
12
13
  title: string
13
14
  depth?: number
14
15
  }
15
16
 
16
- export const componentMetadata = defineComponentProjectComponentMetadata({
17
+ export const componentMetadata = defineScenePluginMetadata({
18
+ resourceKind: 'scene',
17
19
  name: 'Default3DScene',
18
20
  description: 'Default remotion 3D scene',
19
21
  sourceFile: 'src/core/scene/3dscene/default-3d-scene.tsx',
22
+ pluginKey: 'core.three-3d',
23
+ tags: ['scene', '3d'],
20
24
  aspectRatio: '16:9',
21
25
  sceneType: 'scene',
22
- tags: ['scene', '3d'],
26
+ sceneFamily: 'three-3d',
27
+ rootLayout: 'absolute-fill',
23
28
  propsTypeName: 'Default3DSceneProps',
24
29
  })
25
30
 
26
- export function Default3DScene({ id, name, title, depth = 240 }: Default3DSceneProps) {
27
- const runtime = useRemotionSceneRuntime()
28
- const titleFontSize = Math.round(runtime.height * 0.062)
29
-
30
- return (
31
- <BaseRemotionScene
32
- id={id}
33
- name={name}
34
- style={{
35
- backgroundColor: '#020617',
36
- color: '#e2e8f0',
37
- display: 'flex',
38
- alignItems: 'center',
39
- justifyContent: 'flex-start',
40
- padding: Math.round(runtime.height * 0.06),
41
- perspective: 1200,
42
- }}
43
- >
44
- <div style={{ transform: `translateZ(${depth / 8}px) rotateY(-18deg)` }}>
45
- <h1 style={{ margin: 0, fontSize: titleFontSize, lineHeight: 1.05 }}>{title}</h1>
46
- <p style={{ margin: '16px 0 0', fontSize: Math.round(runtime.height * 0.024), opacity: 0.8 }}>
47
- Depth {depth}
48
- </p>
49
- </div>
50
- </BaseRemotionScene>
51
- )
52
- }
31
+ export const Default3DScene = VideoComponent(componentMetadata)(
32
+ defineSceneComponent({
33
+ family: 'three-3d',
34
+ propsSchema: z.object({
35
+ sceneId: z.string().optional(),
36
+ sceneName: z.string().optional(),
37
+ title: z.string().min(1),
38
+ depth: z.number().optional(),
39
+ }),
40
+ component: function Default3DScene({
41
+ sceneId,
42
+ sceneName,
43
+ title,
44
+ depth = 240,
45
+ }: Default3DSceneProps) {
46
+ const runtime = useRemotionSceneRuntime()
47
+ const rootProps = useSceneRootProps({
48
+ pluginKey: componentMetadata.pluginKey,
49
+ sceneId,
50
+ sceneName: sceneName ?? title,
51
+ })
52
+ const titleFontSize = Math.round(runtime.height * 0.062)
53
53
 
54
- VideoComponent(componentMetadata)(Default3DScene)
54
+ return (
55
+ <AbsoluteFill
56
+ {...rootProps}
57
+ style={{
58
+ ...rootProps.style,
59
+ backgroundColor: '#020617',
60
+ color: '#e2e8f0',
61
+ display: 'flex',
62
+ alignItems: 'center',
63
+ justifyContent: 'flex-start',
64
+ padding: Math.round(runtime.height * 0.06),
65
+ perspective: 1200,
66
+ }}
67
+ >
68
+ <div style={{ transform: `translateZ(${depth / 8}px) rotateY(-18deg)` }}>
69
+ <h1 style={{ margin: 0, fontSize: titleFontSize, lineHeight: 1.05 }}>{title}</h1>
70
+ <p style={{ margin: '16px 0 0', fontSize: Math.round(runtime.height * 0.024), opacity: 0.8 }}>
71
+ Depth {depth}
72
+ </p>
73
+ </div>
74
+ </AbsoluteFill>
75
+ )
76
+ },
77
+ }),
78
+ )
@@ -1,51 +1,75 @@
1
- import {
2
- BaseRemotionScene,
3
- type BaseRemotionSceneProps,
4
- useRemotionSceneRuntime,
5
- } from '../../../base'
6
- import {
7
- VideoComponent,
8
- defineComponentProjectComponentMetadata,
9
- } from '@vibecuting/component-project-helper'
1
+ import { AbsoluteFill } from 'remotion'
2
+ import { z } from 'zod'
10
3
 
11
- export interface DefaultSlideSceneProps extends BaseRemotionSceneProps {
4
+ import { VideoComponent, defineScenePluginMetadata } from '@vibecuting/component-project-helper'
5
+
6
+ import { defineSceneComponent } from '../../../plugins/scene-component'
7
+ import { useRemotionSceneRuntime } from '../../../base'
8
+ import { useSceneRootProps } from '../../../runtime/use-scene-root-props'
9
+
10
+ export interface DefaultSlideSceneProps {
11
+ sceneId?: string
12
+ sceneName?: string
12
13
  title: string
13
14
  body?: string
14
15
  }
15
16
 
16
- export const componentMetadata = defineComponentProjectComponentMetadata({
17
+ export const componentMetadata = defineScenePluginMetadata({
18
+ resourceKind: 'scene',
17
19
  name: 'DefaultSlideScene',
18
20
  description: 'Default remotion slide scene',
19
21
  sourceFile: 'src/core/scene/slide/default-slide-scene.tsx',
22
+ pluginKey: 'core.slide',
23
+ tags: ['scene', 'slide'],
20
24
  aspectRatio: '16:9',
21
25
  sceneType: 'scene',
22
- tags: ['scene', 'slide'],
26
+ sceneFamily: 'slide',
27
+ rootLayout: 'absolute-fill',
23
28
  propsTypeName: 'DefaultSlideSceneProps',
24
29
  })
25
30
 
26
- export function DefaultSlideScene({ id, name, title, body }: DefaultSlideSceneProps) {
27
- const runtime = useRemotionSceneRuntime()
28
- const titleFontSize = Math.round(runtime.height * 0.062)
29
-
30
- return (
31
- <BaseRemotionScene
32
- id={id}
33
- name={name}
34
- style={{
35
- backgroundColor: '#f8fafc',
36
- color: '#0f172a',
37
- display: 'flex',
38
- alignItems: 'flex-start',
39
- justifyContent: 'flex-start',
40
- padding: Math.round(runtime.height * 0.06),
41
- }}
42
- >
43
- <aside style={{ maxWidth: Math.round(runtime.width * 0.72) }}>
44
- <h1 style={{ margin: 0, fontSize: titleFontSize, lineHeight: 1.05 }}>{title}</h1>
45
- {body ? <p style={{ margin: '16px 0 0', fontSize: Math.round(runtime.height * 0.024) }}>{body}</p> : null}
46
- </aside>
47
- </BaseRemotionScene>
48
- )
49
- }
31
+ export const DefaultSlideScene = VideoComponent(componentMetadata)(
32
+ defineSceneComponent({
33
+ family: 'slide',
34
+ propsSchema: z.object({
35
+ sceneId: z.string().optional(),
36
+ sceneName: z.string().optional(),
37
+ title: z.string().min(1),
38
+ body: z.string().optional(),
39
+ }),
40
+ component: function DefaultSlideScene({
41
+ sceneId,
42
+ sceneName,
43
+ title,
44
+ body,
45
+ }: DefaultSlideSceneProps) {
46
+ const runtime = useRemotionSceneRuntime()
47
+ const rootProps = useSceneRootProps({
48
+ pluginKey: componentMetadata.pluginKey,
49
+ sceneId,
50
+ sceneName: sceneName ?? title,
51
+ })
52
+ const titleFontSize = Math.round(runtime.height * 0.062)
50
53
 
51
- VideoComponent(componentMetadata)(DefaultSlideScene)
54
+ return (
55
+ <AbsoluteFill
56
+ {...rootProps}
57
+ style={{
58
+ ...rootProps.style,
59
+ backgroundColor: '#f8fafc',
60
+ color: '#0f172a',
61
+ display: 'flex',
62
+ alignItems: 'flex-start',
63
+ justifyContent: 'flex-start',
64
+ padding: Math.round(runtime.height * 0.06),
65
+ }}
66
+ >
67
+ <aside style={{ maxWidth: Math.round(runtime.width * 0.72) }}>
68
+ <h1 style={{ margin: 0, fontSize: titleFontSize, lineHeight: 1.05 }}>{title}</h1>
69
+ {body ? <p style={{ margin: '16px 0 0', fontSize: Math.round(runtime.height * 0.024) }}>{body}</p> : null}
70
+ </aside>
71
+ </AbsoluteFill>
72
+ )
73
+ },
74
+ }),
75
+ )
package/src/index.ts CHANGED
@@ -1,2 +1,9 @@
1
- export * from './core';
2
- export * from './base';
1
+ export * from './base'
2
+ export * from './core'
3
+ export * from './composer'
4
+ export * from './layouts'
5
+ export * from './plugins/scene-component'
6
+ export * from './plugins/scene-plugin-registry'
7
+ export * from './scenes'
8
+ export * from './themes'
9
+ export * from './transitions'
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export type SceneSlotsProps = {
4
+ header?: ReactNode
5
+ body?: ReactNode
6
+ footer?: ReactNode
7
+ debug?: ReactNode
8
+ }
9
+
10
+ export function SceneSlots({ header, body, footer, debug }: SceneSlotsProps) {
11
+ return (
12
+ <>
13
+ {header}
14
+ {body}
15
+ {footer}
16
+ {debug}
17
+ </>
18
+ )
19
+ }
@@ -0,0 +1 @@
1
+ export * from './SceneSlots'
@@ -0,0 +1,111 @@
1
+ import type { ComponentType, ReactElement } from 'react'
2
+ import { createElement } from 'react'
3
+ import { z } from 'zod'
4
+
5
+ import {
6
+ VideoComponent,
7
+ defineScenePluginMetadata,
8
+ getScenePluginMetadata,
9
+ type ScenePluginMetadata,
10
+ } from '@vibecuting/component-project-helper'
11
+
12
+ import type { DefaultSceneProps } from '../scenes/default-scene-props'
13
+
14
+ export type SceneFamily = 'slide' | 'canvas-2d' | 'game-2d' | 'three-3d' | 'custom'
15
+
16
+ export type SceneComponentDefinition<TProps> = {
17
+ family: SceneFamily
18
+ propsSchema: z.ZodType<TProps>
19
+ component: ComponentType<TProps>
20
+ }
21
+
22
+ export const SCENE_COMPONENT_DEFINITION_KEY = Symbol.for(
23
+ '@vibecuting/video-project-core/scene-component-definition',
24
+ )
25
+
26
+ export type RegisteredSceneComponent<TProps extends DefaultSceneProps> = ComponentType<TProps> & {
27
+ readonly [SCENE_COMPONENT_DEFINITION_KEY]: SceneComponentDefinition<TProps>
28
+ }
29
+
30
+ export function defineSceneComponent<TProps extends DefaultSceneProps>(
31
+ definition: SceneComponentDefinition<TProps>,
32
+ ): RegisteredSceneComponent<TProps> {
33
+ const component = definition.component as RegisteredSceneComponent<TProps>
34
+ Object.defineProperty(component, SCENE_COMPONENT_DEFINITION_KEY, {
35
+ value: definition,
36
+ enumerable: false,
37
+ })
38
+ return component
39
+ }
40
+
41
+ export type ScenePluginRegistryEntry<TProps extends DefaultSceneProps> = {
42
+ key: string
43
+ family: SceneFamily
44
+ metadata: ScenePluginMetadata
45
+ component: RegisteredSceneComponent<TProps>
46
+ parseProps(input: unknown): TProps
47
+ render(input: unknown): ReactElement
48
+ }
49
+
50
+ export function createScenePluginRegistry<TProps extends DefaultSceneProps>(
51
+ plugins: readonly RegisteredSceneComponent<TProps>[],
52
+ ) {
53
+ const entries = new Map<string, ScenePluginRegistryEntry<TProps>>()
54
+
55
+ for (const component of plugins) {
56
+ const metadata = getScenePluginMetadata(component)
57
+ if (!metadata) {
58
+ throw new Error(`scene plugin is missing VideoComponent annotation: ${component.name}`)
59
+ }
60
+
61
+ const definition = component[SCENE_COMPONENT_DEFINITION_KEY]
62
+ if (!definition) {
63
+ throw new Error(`scene plugin is missing scene definition: ${component.name}`)
64
+ }
65
+
66
+ if (definition.family !== metadata.sceneFamily) {
67
+ throw new Error(`scene family mismatch: ${metadata.pluginKey}`)
68
+ }
69
+
70
+ if (entries.has(metadata.pluginKey)) {
71
+ throw new Error(`duplicate scene plugin key: ${metadata.pluginKey}`)
72
+ }
73
+
74
+ entries.set(metadata.pluginKey, {
75
+ key: metadata.pluginKey,
76
+ family: definition.family,
77
+ metadata,
78
+ component,
79
+ parseProps(input) {
80
+ return definition.propsSchema.parse(input) as TProps
81
+ },
82
+ render(input) {
83
+ return createElement(component, definition.propsSchema.parse(input) as TProps)
84
+ },
85
+ })
86
+ }
87
+
88
+ return {
89
+ get(key: string) {
90
+ const entry = entries.get(key)
91
+ if (!entry) {
92
+ throw new Error(`unknown scene plugin: ${key}`)
93
+ }
94
+ return entry
95
+ },
96
+ list() {
97
+ return [...entries.values()]
98
+ },
99
+ }
100
+ }
101
+
102
+ export function annotateSceneComponent<TProps extends DefaultSceneProps>(
103
+ metadata: ScenePluginMetadata,
104
+ component: ComponentType<TProps>,
105
+ ) {
106
+ return VideoComponent(metadata)(component)
107
+ }
108
+
109
+ export function defineScenePluginMetadataForComponent(metadata: ScenePluginMetadata) {
110
+ return defineScenePluginMetadata(metadata)
111
+ }
@@ -0,0 +1,11 @@
1
+ export {
2
+ createScenePluginRegistry,
3
+ defineSceneComponent,
4
+ annotateSceneComponent,
5
+ defineScenePluginMetadataForComponent as defineScenePluginMetadata,
6
+ SCENE_COMPONENT_DEFINITION_KEY,
7
+ type RegisteredSceneComponent,
8
+ type SceneComponentDefinition,
9
+ type SceneFamily,
10
+ type ScenePluginRegistryEntry,
11
+ } from './scene-component'
@@ -0,0 +1,43 @@
1
+ import type { CSSProperties } from 'react'
2
+ import { useId } from 'react'
3
+
4
+ import type { SceneTheme, SceneThemeStyle } from '../themes'
5
+
6
+ export type SceneRootOptions = {
7
+ pluginKey: string
8
+ sceneId?: string
9
+ sceneName?: string
10
+ theme?: SceneTheme
11
+ className?: string
12
+ style?: SceneThemeStyle
13
+ }
14
+
15
+ export function useSceneRootProps(options: SceneRootOptions): {
16
+ id: string
17
+ 'data-scene-plugin': string
18
+ 'data-scene-name'?: string
19
+ className: string
20
+ style: CSSProperties
21
+ } {
22
+ const reactId = useId()
23
+ const id = options.sceneId ?? `${options.pluginKey}-${reactId.replace(/:/g, '')}`
24
+
25
+ return {
26
+ id,
27
+ 'data-scene-plugin': options.pluginKey,
28
+ 'data-scene-name': options.sceneName,
29
+ className: [
30
+ 'box-border flex h-full w-full flex-col overflow-hidden',
31
+ 'bg-[var(--scene-bg)] text-[var(--scene-fg)]',
32
+ 'px-[var(--scene-page-x)] py-[var(--scene-page-y)]',
33
+ 'gap-[var(--scene-section-gap)]',
34
+ options.className ?? '',
35
+ ]
36
+ .filter(Boolean)
37
+ .join(' '),
38
+ style: {
39
+ ...(options.theme?.variables ?? {}),
40
+ ...(options.style ?? {}),
41
+ },
42
+ }
43
+ }
@@ -0,0 +1,27 @@
1
+ import type { SceneTheme } from '../themes'
2
+
3
+ export type SceneItem = {
4
+ title: string
5
+ description?: string
6
+ value?: string
7
+ imageSrc?: string
8
+ }
9
+
10
+ export type SceneMedia = {
11
+ type: 'image' | 'video'
12
+ src: string
13
+ alt?: string
14
+ }
15
+
16
+ export type DefaultSceneProps = {
17
+ sceneId?: string
18
+ sceneName?: string
19
+ title?: string
20
+ subtitle?: string
21
+ description?: string
22
+ eyebrow?: string
23
+ items?: SceneItem[]
24
+ media?: SceneMedia
25
+ accent?: string
26
+ theme?: SceneTheme
27
+ }
@@ -0,0 +1 @@
1
+ export * from './default-scene-props'
@@ -0,0 +1,3 @@
1
+ export * from './scene-theme'
2
+ export * from './scene-themes'
3
+ export * from './scene-theme-registry'
@@ -0,0 +1,57 @@
1
+ import { getThemePluginMetadata, type ThemePluginMetadata } from '@vibecuting/component-project-helper'
2
+
3
+ import type { SceneTheme } from './scene-theme'
4
+
5
+ export type SceneThemeRegistryEntry = SceneTheme & {
6
+ metadata: ThemePluginMetadata
7
+ }
8
+
9
+ export type SceneThemeRegistry = {
10
+ get(key: string): SceneThemeRegistryEntry
11
+ list(): SceneThemeRegistryEntry[]
12
+ }
13
+
14
+ export function createSceneThemeRegistry(themes: readonly SceneTheme[]): SceneThemeRegistry {
15
+ const entries = new Map<string, SceneThemeRegistryEntry>()
16
+
17
+ for (const theme of themes) {
18
+ const metadata = getThemePluginMetadata(theme)
19
+ if (!metadata) {
20
+ throw new Error(`theme is missing ThemeComponent annotation: ${theme.key}`)
21
+ }
22
+
23
+ if (theme.key !== metadata.pluginKey) {
24
+ throw new Error(`theme key mismatch: ${theme.key}`)
25
+ }
26
+
27
+ if (entries.has(theme.key)) {
28
+ throw new Error(`duplicate scene theme key: ${theme.key}`)
29
+ }
30
+
31
+ const declaredVariables = [...Object.keys(theme.variables)].sort()
32
+ const metadataVariables = [...metadata.cssVariables].sort()
33
+
34
+ if (declaredVariables.join(',') !== metadataVariables.join(',')) {
35
+ throw new Error(`theme cssVariables mismatch: ${theme.key}`)
36
+ }
37
+
38
+ entries.set(theme.key, {
39
+ ...theme,
40
+ metadata,
41
+ })
42
+ }
43
+
44
+ return {
45
+ get(key: string) {
46
+ const entry = entries.get(key)
47
+ if (!entry) {
48
+ throw new Error(`unknown scene theme: ${key}`)
49
+ }
50
+
51
+ return entry
52
+ },
53
+ list() {
54
+ return [...entries.values()]
55
+ },
56
+ }
57
+ }
@@ -0,0 +1,41 @@
1
+ import type { CSSProperties } from 'react'
2
+
3
+ import {
4
+ ThemeComponent,
5
+ defineThemePluginMetadata,
6
+ type ThemePluginMetadata,
7
+ } from '@vibecuting/component-project-helper'
8
+
9
+ export type SceneThemeVariables = {
10
+ '--scene-bg': string
11
+ '--scene-fg': string
12
+ '--scene-muted': string
13
+ '--scene-accent': string
14
+ '--scene-panel': string
15
+ '--scene-border': string
16
+ '--scene-page-x': string
17
+ '--scene-page-y': string
18
+ '--scene-content-max': string
19
+ '--scene-radius': string
20
+ '--scene-section-gap': string
21
+ }
22
+
23
+ export type SceneTheme = {
24
+ key: string
25
+ name: string
26
+ variables: SceneThemeVariables
27
+ }
28
+
29
+ export type SceneThemeStyle = CSSProperties & Partial<SceneThemeVariables>
30
+
31
+ export function defineSceneTheme(theme: SceneTheme): SceneTheme {
32
+ return theme
33
+ }
34
+
35
+ export function annotateSceneTheme(theme: SceneTheme, metadata: ThemePluginMetadata) {
36
+ return ThemeComponent(metadata)(theme)
37
+ }
38
+
39
+ export function defineSceneThemeMetadata(input: ThemePluginMetadata): ThemePluginMetadata {
40
+ return defineThemePluginMetadata(input)
41
+ }
@@ -0,0 +1,44 @@
1
+ import { ThemeComponent, defineThemePluginMetadata } from '@vibecuting/component-project-helper'
2
+
3
+ import { defineSceneTheme } from './scene-theme'
4
+
5
+ export const editorialDarkTheme = ThemeComponent(
6
+ defineThemePluginMetadata({
7
+ resourceKind: 'theme',
8
+ name: 'Editorial Dark',
9
+ description: 'Default dark theme for slide scenes',
10
+ sourceFile: 'src/themes/scene-themes.ts',
11
+ pluginKey: 'core.editorial-dark',
12
+ supportedSceneFamilies: ['slide', 'custom'],
13
+ cssVariables: [
14
+ '--scene-bg',
15
+ '--scene-fg',
16
+ '--scene-muted',
17
+ '--scene-accent',
18
+ '--scene-panel',
19
+ '--scene-border',
20
+ '--scene-page-x',
21
+ '--scene-page-y',
22
+ '--scene-content-max',
23
+ '--scene-radius',
24
+ '--scene-section-gap',
25
+ ],
26
+ tags: ['builtin', 'theme'],
27
+ }),
28
+ )(defineSceneTheme({
29
+ key: 'core.editorial-dark',
30
+ name: 'Editorial Dark',
31
+ variables: {
32
+ '--scene-bg': '#020617',
33
+ '--scene-fg': '#f8fafc',
34
+ '--scene-muted': '#94a3b8',
35
+ '--scene-accent': '#38bdf8',
36
+ '--scene-panel': '#0f172a',
37
+ '--scene-border': '#1e293b',
38
+ '--scene-page-x': '64px',
39
+ '--scene-page-y': '56px',
40
+ '--scene-content-max': '1200px',
41
+ '--scene-radius': '24px',
42
+ '--scene-section-gap': '24px',
43
+ },
44
+ }))
@@ -0,0 +1,2 @@
1
+ export * from './transition-plugin'
2
+ export * from './transition-plugin-registry'
@@ -0,0 +1,10 @@
1
+ export {
2
+ annotateTransitionPlugin,
3
+ createTransitionPluginRegistry,
4
+ defineTransitionPlugin,
5
+ defineTransitionPluginMetadataForPlugin as defineTransitionPluginMetadata,
6
+ type TransitionPlugin,
7
+ type TransitionPluginDefinition,
8
+ type TransitionPluginKind,
9
+ type TransitionPluginRegistryEntry,
10
+ } from './transition-plugin'
@@ -0,0 +1,89 @@
1
+ import type { ReactElement } from 'react'
2
+ import { z } from 'zod'
3
+
4
+ import {
5
+ TransitionComponent,
6
+ defineTransitionPluginMetadata,
7
+ getTransitionPluginMetadata,
8
+ type TransitionPluginMetadata,
9
+ } from '@vibecuting/component-project-helper'
10
+
11
+ export type TransitionPluginKind = 'transition' | 'overlay'
12
+
13
+ export type TransitionPluginDefinition<TProps> = {
14
+ key: string
15
+ kind: TransitionPluginKind
16
+ metadata: TransitionPluginMetadata
17
+ propsSchema: z.ZodType<TProps>
18
+ renderBoundary: (props: TProps) => ReactElement
19
+ getBoundaryDurationInFrames: (props: TProps, fps: number) => number
20
+ getTimelineOverlapInFrames: (props: TProps, fps: number) => number
21
+ }
22
+
23
+ export type TransitionPlugin<TProps> = TransitionPluginDefinition<TProps> & {
24
+ parseProps(input: unknown): TProps
25
+ }
26
+
27
+ export function defineTransitionPlugin<TProps>(
28
+ definition: TransitionPluginDefinition<TProps>,
29
+ ): TransitionPlugin<TProps> {
30
+ return {
31
+ ...definition,
32
+ parseProps(input) {
33
+ return definition.propsSchema.parse(input) as TProps
34
+ },
35
+ }
36
+ }
37
+
38
+ export type TransitionPluginRegistryEntry<TProps> = TransitionPlugin<TProps>
39
+
40
+ export function createTransitionPluginRegistry<TProps>(
41
+ plugins: readonly TransitionPlugin<TProps>[],
42
+ ) {
43
+ const entries = new Map<string, TransitionPlugin<TProps>>()
44
+
45
+ for (const plugin of plugins) {
46
+ const metadata = getTransitionPluginMetadata(plugin)
47
+ if (!metadata) {
48
+ throw new Error(`transition plugin is missing TransitionComponent annotation: ${plugin.key}`)
49
+ }
50
+
51
+ if (plugin.key !== metadata.pluginKey) {
52
+ throw new Error(`transition plugin key mismatch: ${plugin.key}`)
53
+ }
54
+
55
+ if (plugin.kind !== metadata.transitionKind) {
56
+ throw new Error(`transition plugin kind mismatch: ${plugin.key}`)
57
+ }
58
+
59
+ if (entries.has(plugin.key)) {
60
+ throw new Error(`duplicate transition plugin key: ${plugin.key}`)
61
+ }
62
+
63
+ entries.set(plugin.key, plugin)
64
+ }
65
+
66
+ return {
67
+ get(key: string) {
68
+ const entry = entries.get(key)
69
+ if (!entry) {
70
+ throw new Error(`unknown transition plugin: ${key}`)
71
+ }
72
+ return entry
73
+ },
74
+ list() {
75
+ return [...entries.values()]
76
+ },
77
+ }
78
+ }
79
+
80
+ export function annotateTransitionPlugin<TProps>(
81
+ metadata: TransitionPluginMetadata,
82
+ plugin: TransitionPlugin<TProps>,
83
+ ) {
84
+ return TransitionComponent(metadata)(plugin)
85
+ }
86
+
87
+ export function defineTransitionPluginMetadataForPlugin(metadata: TransitionPluginMetadata) {
88
+ return defineTransitionPluginMetadata(metadata)
89
+ }
package/tsconfig.json CHANGED
@@ -1,5 +1,8 @@
1
1
  {
2
2
  "extends": "../../../tsconfig.packages.json",
3
+ "compilerOptions": {
4
+ "allowImportingTsExtensions": true
5
+ },
3
6
  "include": ["src/**/*.ts", "src/**/*.tsx"],
4
7
  "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
5
8
  }