├── .github └── FUNDING.yml ├── .gitignore ├── LICENSE.md ├── README.md ├── media ├── astronaut_rain.mp3 ├── bindless_audio.mkv ├── cat ├── cobalt.mkv ├── okami_baiku_cropped.png ├── sphinx_of_black_quartz.wav ├── test.sh ├── test_recording.wav ├── the_quick_brown_fox.wav ├── thumbnail.png └── youtube.mkv ├── package-lock.json ├── package.json ├── public └── cobalt.mkv ├── src ├── motion-canvas.d.ts ├── plugin │ ├── client │ │ ├── Contexts.ts │ │ ├── Hooks.ts │ │ ├── MotionComposer.ts │ │ ├── Provider.tsx │ │ ├── Sources.ts │ │ ├── Types.ts │ │ ├── Util.ts │ │ ├── audio │ │ │ ├── AudioController.ts │ │ │ └── AudioProxy.ts │ │ ├── icon │ │ │ ├── AudioIcon.tsx │ │ │ ├── ClipperIcon.tsx │ │ │ ├── CompositionIcon.tsx │ │ │ ├── HandIcon.tsx │ │ │ ├── ImageIcon.tsx │ │ │ ├── LockIcon.tsx │ │ │ ├── MagnetIcon.tsx │ │ │ ├── MissingIcon.tsx │ │ │ ├── MuteIcon.tsx │ │ │ ├── SceneIcon.tsx │ │ │ ├── ScissorIcon.tsx │ │ │ ├── SelectIcon.tsx │ │ │ ├── SoloIcon.tsx │ │ │ ├── TargetIcon.tsx │ │ │ ├── VideoIcon.tsx │ │ │ ├── ViewLgIcon.tsx │ │ │ ├── ViewListIcon.tsx │ │ │ ├── ViewMdIcon.tsx │ │ │ ├── ViewSmIcon.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── media │ │ │ ├── AudioClipItem.tsx │ │ │ ├── ClipItem.tsx │ │ │ ├── ImageClipItem.tsx │ │ │ ├── Media.module.scss │ │ │ ├── MediaPane.tsx │ │ │ ├── MediaTabConfig.tsx │ │ │ ├── SceneClipItem.tsx │ │ │ └── VideoClipItem.tsx │ │ ├── overlay │ │ │ ├── Overlay.module.scss │ │ │ └── OverlayConfig.tsx │ │ ├── scenes │ │ │ ├── EmptyTimelineScene.meta │ │ │ ├── EmptyTimelineScene.tsx │ │ │ ├── ImageClipScene.meta │ │ │ ├── ImageClipScene.tsx │ │ │ ├── MissingClipScene.meta │ │ │ ├── MissingClipScene.tsx │ │ │ ├── VideoClipScene.meta │ │ │ └── VideoClipScene.tsx │ │ ├── shortcut │ │ │ ├── Footer.module.scss │ │ │ ├── FooterShortcuts.tsx │ │ │ ├── ShortcutMappings.tsx │ │ │ └── useShortcutHover.tsx │ │ └── timeline │ │ │ ├── AudioTrack.tsx │ │ │ ├── Playhead.tsx │ │ │ ├── RangeSelector.tsx │ │ │ ├── ScrubPreview.tsx │ │ │ ├── Timeline.module.scss │ │ │ ├── Timeline.tsx │ │ │ ├── TimelineTrack.tsx │ │ │ ├── TimelineTrackLabel.tsx │ │ │ ├── Timestamps.tsx │ │ │ ├── Toolbar.tsx │ │ │ └── clip │ │ │ ├── AudioClip.tsx │ │ │ ├── Clip.module.scss │ │ │ ├── Clip.tsx │ │ │ ├── EventLabel.tsx │ │ │ ├── ImageClip.tsx │ │ │ ├── MissingClip.tsx │ │ │ ├── SceneClip.tsx │ │ │ ├── VideoClip.tsx │ │ │ └── Waveform.tsx │ ├── common │ │ └── FileTypes.ts │ └── vite │ │ └── index.ts ├── project.meta ├── project.ts ├── scenes │ ├── Circle.meta │ ├── Circle.tsx │ ├── Rectangle.meta │ ├── Rectangle.tsx │ ├── Square.meta │ ├── Square.tsx │ ├── Video.meta │ ├── Video.tsx │ └── example.meta └── test.svg ├── tsconfig.json └── vite.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: Aurailus 4 | patreon: aurailus 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated files 2 | node_modules 3 | output 4 | dist 5 | 6 | # Editor directories and files 7 | .vscode/* 8 | !.vscode/extensions.json 9 | .idea 10 | .DS_Store 11 | *.suo 12 | *.ntvs* 13 | *.njsproj 14 | *.sln 15 | *.sw? 16 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Auri Collings 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Motion Composer 2 | 3 | A (heavily WIP) plugin to add more video editing features to [Motion Canvas](https://motioncanvas.io/), including a nonlinear track editor, adding non-scene clips, coordinating multiple audio tracks, and more. 4 | 5 | ## Roadmap 6 | 7 | - [ ] Scan '/media' folder for videos, images, and audio, and add them to the Clips and Media tab. 8 | - [ ] Write a Vite plugin for loading them. It should expose metadata for their lengths, maybe a thumbnail as well, and then allow the file to be publicly accessible over the network. 9 | - [ ] Allow dragging clips into the track list. 10 | - [x] Show a warning scene if a clip's source is missing. 11 | - [ ] Editor tools 12 | - [ ] Composition Mode 13 | - [ ] Clipper Mode 14 | - [ ] Cut tool 15 | - [ ] Selection 16 | - [ ] Infinite cropping for image scenes 17 | - [ ] Reconcile existing scenes when a clip is moved or resized to maintain proper ordering and data. Remove clips with 0 length. 18 | - [ ] Allow scenes to invoke audio tracks, display waveform embedded in scene. 19 | - [ ] Allow modifying audio clip volume. 20 | - [x] Audio Clip Proxy 21 | - [x] Override playback to play in order. 22 | - [x] Fix scene end detection. 23 | - [x] Fix playback range behaviour. 24 | - [x] Fix play bar position and frame counts. 25 | - [x] Check if modifying a scene fucks things up. 26 | - [x] Make sure having no scene works. 27 | - [x] Dummy scenes to display video and images. 28 | - [ ] Video export. 29 | 30 | ## Stretch Goals 31 | 32 | - [ ] More advanced editor tools: scissor tool, etc. 33 | - [ ] Video stabilization and color grading. 34 | - [x] Mute / Solo audio tracks. 35 | - [ ] Color clips in the timeline. 36 | 37 | ## Project Structure 38 | 39 | This is currently just a Motion Canvas project, so that development is easier. `scenes/`, `clips`, and `public` are for testing data, and shouldn't contain anything important. The actual plugin code is in `src/plugin`. The client plugin (which is the only one so far) is in `src/plugin/client`. The Vite plugin will be in `src/plugin/vite`. 40 | 41 | ## Contributing 42 | 43 | Contributions are welcome! Just take care to follow the code style and conventions of the project. If you're unsure, feel free to ask in an issue or pull request. We want this project to be as fully-featured as possible, so any help is appreciated. 44 | -------------------------------------------------------------------------------- /media/astronaut_rain.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/astronaut_rain.mp3 -------------------------------------------------------------------------------- /media/bindless_audio.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/bindless_audio.mkv -------------------------------------------------------------------------------- /media/cat: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/cat -------------------------------------------------------------------------------- /media/cobalt.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/cobalt.mkv -------------------------------------------------------------------------------- /media/okami_baiku_cropped.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/okami_baiku_cropped.png -------------------------------------------------------------------------------- /media/sphinx_of_black_quartz.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/sphinx_of_black_quartz.wav -------------------------------------------------------------------------------- /media/test.sh: -------------------------------------------------------------------------------- 1 | ffmpeg -i sphinx_of_black_quartz.wav -ac 1 -filter:a aresample=100 -map 0:a -c:a pcm_s16le -f data - 2 | -------------------------------------------------------------------------------- /media/test_recording.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/test_recording.wav -------------------------------------------------------------------------------- /media/the_quick_brown_fox.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/the_quick_brown_fox.wav -------------------------------------------------------------------------------- /media/thumbnail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/thumbnail.png -------------------------------------------------------------------------------- /media/youtube.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/media/youtube.mkv -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "motion-composer", 3 | "private": true, 4 | "version": "0.0.0", 5 | "scripts": { 6 | "start": "vite", 7 | "serve": "vite", 8 | "dev": "vite", 9 | "build": "tsc && vite build" 10 | }, 11 | "dependencies": { 12 | "@motion-canvas/2d": "^3.15.1", 13 | "@motion-canvas/core": "^3.15.1", 14 | "@motion-canvas/ffmpeg": "^1.1.0", 15 | "audio-decode": "^2.2.0", 16 | "clsx": "^2.1.0", 17 | "sharp": "^0.33.3", 18 | "web-audio-api": "^0.2.2" 19 | }, 20 | "devDependencies": { 21 | "@motion-canvas/ui": "^3.15.1", 22 | "@motion-canvas/vite-plugin": "^3.15.1", 23 | "sass": "^1.72.0", 24 | "typescript": "^5.2.2", 25 | "vite": "^4.0.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /public/cobalt.mkv: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aurailus/MotionComposer/3c262d8a60f673041bc388042918b42946aa5bc7/public/cobalt.mkv -------------------------------------------------------------------------------- /src/motion-canvas.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/plugin/client/Contexts.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'preact'; 2 | import { Signal } from '@preact/signals'; 3 | import { useContext } from 'preact/hooks'; 4 | import { Vector2 } from '@motion-canvas/core'; 5 | 6 | import MotionComposer from './MotionComposer'; 7 | import AudioController from './audio/AudioController'; 8 | import { ShortcutModule } from './shortcut/ShortcutMappings'; 9 | import { ClipSource, EditorMode, EditorTool } from './Types'; 10 | 11 | /** Audio Context. */ 12 | 13 | export interface AudioContextData { 14 | audio: AudioController; 15 | } 16 | 17 | export const AudioContext = createContext({} as any); 18 | 19 | export function useAudio() { 20 | return MotionComposer.audio; 21 | } 22 | 23 | /** UI Context. */ 24 | 25 | export interface UIContextData { 26 | mediaTabOpen: boolean; 27 | 28 | updateMediaTabOpen: (open: boolean) => void; 29 | 30 | addSource: Signal; 31 | addSourceDragPos: Signal; 32 | } 33 | 34 | export const UIContext = createContext({} as any); 35 | 36 | export function useUIContext() { 37 | return useContext(UIContext); 38 | } 39 | 40 | /** Shortcuts Context. */ 41 | 42 | export interface ShortcutsContextData { 43 | currentModule: ShortcutModule; 44 | setCurrentModule: (module: ShortcutModule) => void; 45 | }; 46 | 47 | export const ShortcutsContext = createContext({} as any); 48 | 49 | export function useShortcuts() { 50 | return useContext(ShortcutsContext); 51 | } 52 | 53 | /** Timeline Context */ 54 | 55 | export interface TimelineContextData { 56 | /** First visible frame */ 57 | firstFrame: number; 58 | 59 | /** Last visible frame. */ 60 | lastFrame: number; 61 | 62 | /** Frames per pixel rounded to the closest power of two. */ 63 | density: number; 64 | 65 | /** View length in pixels. */ 66 | viewLength: number; 67 | 68 | /** Scroll offset in pixels. */ 69 | viewOffset: number; 70 | 71 | framesToPercents: (value: number) => number; 72 | framesToPixels: (value: number) => number; 73 | pixelsToFrames: (value: number) => number; 74 | pointerToFrames: (value: number) => number; 75 | 76 | tool: EditorTool; 77 | setTool: (tool: EditorTool) => void; 78 | 79 | mode: EditorMode; 80 | setMode: (mode: EditorMode) => void; 81 | 82 | snap: boolean; 83 | setSnap: (snap: boolean) => void; 84 | } 85 | 86 | export const TimelineContext = createContext({} as any); 87 | 88 | export function useTimeline() { 89 | return useContext(TimelineContext); 90 | } 91 | -------------------------------------------------------------------------------- /src/plugin/client/Hooks.ts: -------------------------------------------------------------------------------- 1 | import { MutableRefObject, RefObject } from 'preact/compat'; 2 | import { useCallback, useState, useMemo, useRef, useEffect } from 'preact/hooks'; 3 | 4 | import { addEventListener } from './Util'; 5 | import MotionComposer from './MotionComposer'; 6 | import { useSubscribableValue } from '@motion-canvas/ui'; 7 | 8 | /** useRef() but the initial value takes a lazy initializer. */ 9 | export function useLazyRef(initializer: () => T): MutableRefObject { 10 | const [ref] = useState(() => ({ current: initializer() })); 11 | return ref; 12 | } 13 | 14 | type StoreReturnValue = T extends Record ? Readonly : T extends Array ? Readonly : T; 15 | 16 | function StoreFnType(): StoreReturnValue; 17 | function StoreFnType(v: T | ((value: T) => T)): StoreReturnValue; 18 | function StoreFnType(_v?: T | ((value: T) => T)): StoreReturnValue { 19 | throw new Error('This function is only for typing purposes.'); 20 | }; 21 | 22 | /** A store is basically a signal. `store()` is a getter, `store(value)` is a setter. */ 23 | export type Store = typeof StoreFnType; 24 | 25 | /** Returns a store for the value provided. */ 26 | export function useStore(initial: T | (() => T)): Store { 27 | const value = useLazyRef(() => initial instanceof Function ? initial() : initial); 28 | const [ , setId ] = useState(0); 29 | 30 | const fn = useCallback(function (newValue?: T | ((value: T) => T)) { 31 | if (arguments.length === 0) return value.current; 32 | 33 | const oldValue = value.current; 34 | value.current = (newValue instanceof Function) ? (newValue as any)(value.current) : newValue!; 35 | if (value.current !== oldValue) setId(id => (id + 1) % Number.MAX_SAFE_INTEGER); 36 | 37 | return value.current; 38 | }, []); 39 | 40 | return fn as Store; 41 | } 42 | 43 | let uuidNext = 0; 44 | 45 | /** Set the next UUID. Use with caution. */ 46 | export function setUUIDNext(next: number) { 47 | uuidNext = next; 48 | } 49 | 50 | /** Get the next UUID. */ 51 | export function getUUIDNext() { 52 | return uuidNext; 53 | } 54 | 55 | /** Gets a UUID by returning uuidNext, and then incrementing it internally. */ 56 | export function getUUID() { 57 | return uuidNext++; 58 | } 59 | 60 | /** Returns a memoized ID for this component. Calls `getUUID()` under the hood. */ 61 | export function useUUID() { 62 | return useMemo(() => getUUID(), []); 63 | } 64 | 65 | export function useHover(onHover?: () => void, onBlur?: () => void): 66 | [ RefObject, boolean ] { 67 | 68 | const [ hovered, setHovered ] = useState(false); 69 | const ref = useRef(null); 70 | 71 | useEffect(() => { 72 | const node = ref.current; 73 | if (!node) return; 74 | 75 | const handelMouseEnter = () => (setHovered(true), onHover?.()); 76 | const handleMouseLeave = () => (setHovered(false), onBlur?.()); 77 | 78 | node.addEventListener('mouseenter', handelMouseEnter); 79 | node.addEventListener('mouseleave', handleMouseLeave); 80 | 81 | return () => { 82 | node.removeEventListener('mouseenter', handelMouseEnter); 83 | node.removeEventListener('mouseleave', handleMouseLeave); 84 | }; 85 | }, [ ref.current ]); 86 | 87 | return [ ref, hovered ]; 88 | } 89 | 90 | type UseShortcutOptions = { 91 | press?: () => void; 92 | pressRelease?: () => void; 93 | hold?: () => void; 94 | holdRelease?: () => void; 95 | release?: () => void; 96 | elem?: HTMLElement, 97 | passive?: boolean; 98 | holdTimeout?: number; 99 | } 100 | 101 | const BASE_HOLD_TIMEOUT = 150; 102 | 103 | export function useShortcut(key: string, shortcut: UseShortcutOptions | (() => void), deps: any[]) { 104 | const options: UseShortcutOptions = useMemo(() => { 105 | if (shortcut instanceof Function) return { press: shortcut }; 106 | return shortcut; 107 | }, deps); 108 | 109 | 110 | useEffect(() => { 111 | const elem = options.elem || document.body; 112 | 113 | let held = false; 114 | let timeout: number | undefined; 115 | 116 | const listeners: (() => void)[] = []; 117 | 118 | listeners.push(addEventListener(elem, 'keydown', (e: KeyboardEvent) => { 119 | if (document.activeElement.tagName === 'INPUT') return; 120 | if (e.key.toUpperCase() !== key.toUpperCase()) return; 121 | if (!options.passive) { 122 | e.preventDefault(); 123 | e.stopPropagation(); 124 | } 125 | if (e.repeat) return; 126 | if (!options.hold) options.press?.(); 127 | timeout = setTimeout(() => { 128 | options.hold?.(); 129 | held = true; 130 | }, options.holdTimeout ?? BASE_HOLD_TIMEOUT); 131 | })); 132 | 133 | listeners.push(addEventListener(elem, 'keyup', (e: KeyboardEvent) => { 134 | if (document.activeElement.tagName === 'INPUT') return; 135 | if (e.key.toUpperCase() !== key.toUpperCase()) return; 136 | if (!options.passive) { 137 | e.preventDefault(); 138 | e.stopPropagation(); 139 | } 140 | if (timeout) clearTimeout(timeout); 141 | 142 | if (!held) { 143 | if (options.hold) options.press?.(); 144 | options.pressRelease?.(); 145 | } 146 | else { 147 | options.holdRelease?.(); 148 | } 149 | options.release?.(); 150 | 151 | held = false; 152 | clearTimeout(timeout); 153 | })); 154 | 155 | return () => listeners.forEach(fn => fn()); 156 | }, [ options ]); 157 | } 158 | 159 | /** 160 | * Creates a stateful value that is stored in local storage via a unique key, providing a simple way to 161 | * store persistent state. State is stored using `JSON.stringify` on update, and retrieved using `JSON.parse`. 162 | * Based on https://www.joshwcomeau.com/react/persisting-react-state-in-localstorage/. 163 | * 164 | * @param def - The default value if no stored value exists. 165 | * @param key - The unique key to store the value under. 166 | * @param serverDefault - The default value if the hook is used in SSR. 167 | * @returns the value and a function to update it, wrapped in an array. 168 | */ 169 | 170 | export function useStoredState(def: T | (() => T), key: string, serverDefault?: T | (() => T)): [ 171 | T, (value: T | ((currentValue: T) => T)) => void ] { 172 | 173 | const [ value, setValue ] = useState(() => { 174 | const stored = window?.localStorage.getItem(key); 175 | try { 176 | return stored !== null && stored !== undefined ? JSON.parse(stored) : def; 177 | } catch (e) { 178 | console.warn('StoredState error:' + e); 179 | const defObj = ('window' in globalThis ? def : (serverDefault ?? def)); 180 | return typeof defObj === 'function' ? (defObj as any)() : defObj; 181 | } 182 | }); 183 | 184 | useEffect(() => window.localStorage.setItem(key, JSON.stringify(value)), [ key, value ]); 185 | 186 | return [ value, setValue ]; 187 | } 188 | 189 | export function useClips() { 190 | return useSubscribableValue(MotionComposer.onClipsChanged); 191 | } 192 | 193 | export function useCurrentClip() { 194 | return useSubscribableValue(MotionComposer.onCurrentClipChanged); 195 | } 196 | 197 | export function useTracks() { 198 | const tracks = useSubscribableValue(MotionComposer.onTracksChanged); 199 | const targetTrack = useSubscribableValue(MotionComposer.onTargetTrackChanged); 200 | return useMemo(() => ({ tracks, targetTrack }), [ tracks, targetTrack ]); 201 | } 202 | -------------------------------------------------------------------------------- /src/plugin/client/MotionComposer.ts: -------------------------------------------------------------------------------- 1 | import { Video } from '@motion-canvas/2d'; 2 | import { Player, Project, ProjectMetadata, Scene, ValueDispatcher } from '@motion-canvas/core'; 3 | 4 | import { ensure } from './Util'; 5 | import { getUUIDNext, setUUIDNext } from './Hooks'; 6 | import AudioProxy from './audio/AudioProxy'; 7 | import { setVideo } from './scenes/VideoClipScene'; 8 | import { setImage } from './scenes/ImageClipScene'; 9 | import AudioController from './audio/AudioController'; 10 | import { getSources, onSourcesChanged, updateSceneSources } from './Sources'; 11 | import { Clip, ClipInfo, ClipSource, PluginSettings, Track } from './Types'; 12 | 13 | import VideoClipScene from './scenes/VideoClipScene?scene'; 14 | import ImageClipScene from './scenes/ImageClipScene?scene'; 15 | import MissingClipScene from './scenes/MissingClipScene?scene'; 16 | import EmptyTimelineScene from './scenes/EmptyTimelineScene?scene'; 17 | 18 | const INTERNAL_CLIP_NAMES = [ 'EmptyTimeline', 'MissingClip', 'VideoClip', 'ImageClip' ] as const; 19 | type InternalClipName = typeof INTERNAL_CLIP_NAMES[number]; 20 | 21 | type SettingsWrapper = { 22 | get(field: T, def?: PluginSettings[T]): PluginSettings[T]; 23 | set(field: T, value: PluginSettings[T]): void; 24 | } 25 | 26 | export class MotionComposer { 27 | 28 | // Public properties. 29 | 30 | public readonly audio = new AudioController(); 31 | 32 | // Internals. 33 | 34 | private player: Player = null; 35 | private settings: SettingsWrapper = null; 36 | private internalClips: Record = null; 37 | private sceneSubscriptions = new Map void)>(); 38 | 39 | // Synchronized state accessed through getters, subscribers, and setters. 40 | 41 | private readonly tracks = new ValueDispatcher([]); 42 | private readonly targetTrack = new ValueDispatcher(1); 43 | private readonly clips = new ValueDispatcher([]); 44 | private readonly currentClip = new ValueDispatcher(null); 45 | 46 | public get onTracksChanged() { return this.tracks.subscribable; } 47 | public get onTargetTrackChanged() { return this.targetTrack.subscribable; } 48 | public get onClipsChanged() { return this.clips.subscribable; } 49 | public get onCurrentClipChanged() { return this.currentClip.subscribable; } 50 | 51 | getTracks() { return this.tracks.current; } 52 | getTargetTrack() { return this.targetTrack.current; } 53 | getClips() { return this.clips.current; } 54 | getCurrentClip() { return this.currentClip.current; } 55 | 56 | constructor() { 57 | // Recompute everything when the sources change. 58 | onSourcesChanged.subscribe(() => { 59 | if (this.clips.current.length <= 0) return; 60 | this.targetTrack.current = this.settings.get('targetTrack') ?? 1; 61 | this.tracks.current = this.settings.get('tracks') ?? []; 62 | this.updateClipCaches(true); 63 | }); 64 | } 65 | 66 | /** 67 | * Sets the project's clips, and updates the internal state, cache, and audio. 68 | * 69 | * @param clips - The clips to set. 70 | */ 71 | 72 | setClips(clips: readonly Clip[][]): void { 73 | console.warn('SETTING CLIPS'); 74 | 75 | // Set the value without triggering a change event, since `updateClipCaches` will. 76 | (this.clips as any).value = clips; 77 | this.updateClipCaches(true); 78 | 79 | // Update the persistent settings data. 80 | this.settings.set('clips', [ ...clips ].map(channel => 81 | [ ...channel.map(clip => ({ ...clip, cache: undefined })) ])); 82 | this.settings.set('uuidNext', getUUIDNext()); 83 | 84 | // Update the audio's copy of the clips. 85 | this.audio.setClips(clips.flat(1)); 86 | } 87 | 88 | /** 89 | * Sets the project's tracks, and updates the internal state and cache. 90 | * 91 | * @param tracks - The tracks to set. 92 | */ 93 | 94 | setTracks(tracks: Track[]): void { 95 | console.warn('SETTING TRACKS'); 96 | 97 | this.tracks.current = tracks; 98 | this.settings.set('tracks', tracks); 99 | this.audio.setTracks(tracks); 100 | } 101 | 102 | /** 103 | * Sets the target track, i.e. the track that new clips will be added to. 104 | * This is only for Audio, as there can be only one video track. 105 | * 106 | * @param targetTrack - The target track index. 107 | */ 108 | 109 | setTargetTrack(targetTrack: number): void { 110 | console.warn('SETTING TARGET TRACK'); 111 | this.targetTrack.current = targetTrack; 112 | this.settings.set('targetTrack', targetTrack); 113 | } 114 | 115 | /** 116 | * Patch and modify the Motion Canvas project before Motion Canvas starts. 117 | * 118 | * @param project - The project. 119 | * @returns the modified project. 120 | */ 121 | 122 | patchProject(project: Project) { 123 | // Add internal scenes. 124 | project.scenes.push(EmptyTimelineScene); 125 | project.scenes.push(MissingClipScene); 126 | project.scenes.push(VideoClipScene); 127 | project.scenes.push(ImageClipScene); 128 | 129 | // Wipe audio property from the project. 130 | if (project.audio) console.warn('Project.audio is not supported. Please add your audio to the timeline.') 131 | project.audio = null; 132 | 133 | // Patch the video pool. 134 | this.patchVideo(); 135 | // Create the settings wrapper. 136 | this.settings = this.createSettingsWrapper(project.meta); 137 | 138 | // Return the modified project. 139 | return project; 140 | } 141 | 142 | /** 143 | * Patches the existing video element to work properly with Motion Composer. 144 | */ 145 | 146 | private patchVideo() { 147 | // Make all videos muted. 148 | const pool = (Video as any).pool as Record; 149 | 150 | (Video as any).pool = new Proxy(pool, { 151 | get(target, prop, receiver) { 152 | if (prop === '__raw__') return pool; 153 | return Reflect.get(target, prop, receiver); 154 | }, 155 | set(target, prop, value, receiver) { 156 | Reflect.set(target, prop, value, receiver); 157 | value.muted = true; 158 | return true; 159 | } 160 | }); 161 | 162 | // Set the video playback rate to match the player speed. 163 | const composer = this; 164 | const oldSeekedVideo = (Video.prototype as any).seekedVideo; 165 | const oldFastSeekedVideo = (Video.prototype as any).fastSeekedVideo; 166 | 167 | (Video.prototype as any).seekedVideo = (function() { 168 | const video = oldSeekedVideo.call(this); 169 | video.playbackRate *= composer.player.status.speed; 170 | return video; 171 | }); 172 | (Video.prototype as any).fastSeekedVideo = (function() { 173 | const video = oldFastSeekedVideo.call(this); 174 | video.playbackRate *= composer.player.status.speed; 175 | return video; 176 | }); 177 | } 178 | 179 | /** 180 | * Creates the settings wrapper object, which allows Motion Composer to modify 181 | * persistent properties in project.meta's `motion-composer` field. 182 | * 183 | * @param meta - The project meta object. 184 | * @returns the settings wrapper object. 185 | */ 186 | 187 | private createSettingsWrapper(meta: ProjectMetadata) { 188 | return { 189 | get(field: T, def?: PluginSettings[T]): PluginSettings[T] { 190 | return (meta.get() as any)['motion-composer']?.[field] ?? def; 191 | }, 192 | set(field: T, value: PluginSettings[T]) { 193 | meta.set({ 'motion-composer': { ...(meta.get() as any)['motion-composer'] ?? {}, [field]: value } } as any); 194 | } 195 | } 196 | }; 197 | 198 | /** 199 | * Patches the Motion Canvas player and playback manager to work with Motion Composer clips. This stuff modifies 200 | * a lot of internal behaviour, and is prone to breaking with Motion Canvas updates. I've done my best to silo 201 | * all breakable functionality in this function, so if something stops working, you know where to look. 202 | * 203 | * @param player - The player object. 204 | */ 205 | 206 | patchPlayer(player: Player) { 207 | // Store the player object. 208 | this.player = player; 209 | 210 | // Update scene sources when they change. 211 | player.playback.onScenesRecalculated.subscribe(scenes => updateSceneSources(scenes), true); 212 | 213 | function makeInternalClip(name: InternalClipName): Clip { 214 | const sceneName = `${name}Scene`; 215 | const scene = (player.playback as any).scenes.value.find((s: Scene) => s.name === sceneName); 216 | ensure(scene, `Internal MotionComposer scene not found: ${sceneName}`) 217 | const source: ClipSource = { type: 'scene', path: sceneName, name: sceneName, duration: 1, scene }; 218 | const cache: ClipInfo = { source } as any; 219 | const clip: Clip = { type: 'scene', path: sceneName, length: 1, offset: 0, start: 0, volume: 0, uuid: -1, cache }; 220 | return clip; 221 | } 222 | 223 | // Populate the internal clips object with the internal clips. 224 | this.internalClips = Object.fromEntries(INTERNAL_CLIP_NAMES.map((name) => [ name, makeInternalClip(name) ])) as any; 225 | 226 | // Store a reference to this object, since we're binding our overridden methods to the class we're overriding. 227 | const composer = this; 228 | 229 | /** Helper method used by overridden PlaybackManager.seek() and PlaybackManager.next() to advance scene frames. */ 230 | async function advanceSceneWithoutSeek(scene: Scene, frames: number) { 231 | for (let i = 0; i < frames / player.status.speed; i++) { 232 | await scene.next(); 233 | ensure(!scene.isFinished(), 'Tried to advance past the end of a scene.'); 234 | } 235 | } 236 | 237 | /** Override PlaybackManager.getNextScene() method to find the clip's next scene. */ 238 | (player.playback as any).getNextScene = (function() { 239 | const clip = composer.updateCurrentClip(composer.currentClip.current?.cache.clipRange[1] ?? 0); 240 | return clip ? clip.cache.source.scene : null; 241 | }).bind(player.playback); 242 | 243 | /** Override PlaybackManager.findBestScene() method to use this.updateCurrent(). */ 244 | (player.playback as any).findBestScene = (function(frame: number) { 245 | const clip = composer.updateCurrentClip(frame); 246 | return clip.cache.source.scene; 247 | }).bind(player.playback); 248 | 249 | /** Override PlaybackManager.next() to detect clip endings and request the next scene properly. */ 250 | (player.playback as any).next = (async function() { 251 | // Animate the previous scene if it still exists, until the current scene stops. 252 | if (this.previousScene) { 253 | await this.previousScene.next(); 254 | if (this.currentScene.isFinished()) this.previousScene = null; 255 | } 256 | 257 | // Move the frame counter. 258 | this.frame += this.speed; 259 | 260 | // What is this for?? 261 | if (this.currentScene.isFinished()) return true; 262 | 263 | // Compute the next frame in the scene. 264 | if (this.currentScene !== composer.internalClips.EmptyTimeline.cache.source.scene && 265 | this.currentScene !== composer.internalClips.MissingClip.cache.source.scene) await this.currentScene.next(); 266 | 267 | // If the current scene is done transitioning, clear the previous scene. 268 | if (this.previousScene && this.currentScene.isAfterTransitionIn()) this.previousScene = null; 269 | 270 | // If the current scene is over, or the current clip is over, locate the next scene and move to it. 271 | if (this.currentScene.canTransitionOut() || this.frame >= composer.currentClip.current.cache.clipRange[1]) { 272 | this.previousScene = this.currentScene; 273 | const nextScene = this.getNextScene(this.previousScene); 274 | if (nextScene) { 275 | this.currentScene = nextScene; 276 | await this.currentScene.reset(this.previousScene); 277 | await advanceSceneWithoutSeek(this.currentScene, composer.currentClip.current.cache.startFrames); 278 | } 279 | if (!nextScene || this.currentScene.isAfterTransitionIn()) this.previousScene = null; 280 | } 281 | 282 | return this.currentScene.isFinished(); 283 | }).bind(player.playback); 284 | 285 | /** Override PlaybackManager.seek() method to swap to the correct clip and shift its start frame. */ 286 | (player.playback as any).seek = (async function (frame: number) { 287 | // Frame is too high, we need to skip back to the start of the clip. 288 | if (this.frame > frame) { 289 | // Update the current scene if we need to. 290 | const scene = this.findBestScene(frame); 291 | if (scene !== this.currentScene) { 292 | this.previousScene = null; 293 | this.currentScene = scene; 294 | } 295 | 296 | // If the scene is not the EmptyTimeline or MissingClip scene, 297 | // we need to reset the scene and then advance it to the beginning of the clip. 298 | if (scene !== composer.internalClips.EmptyTimeline.cache.source.scene && 299 | scene !== composer.internalClips.MissingClip.cache.source.scene) { 300 | // Update the frame to the start of the clip. 301 | this.frame = composer.currentClip.current.cache.clipRange[0] ?? 0; 302 | // Reset the current scene. 303 | await this.currentScene.reset(); 304 | // Advance the scene to the start frame of the clip. 305 | await advanceSceneWithoutSeek(this.currentScene, composer.currentClip.current.cache.startFrames); 306 | } 307 | // Otherwise, we just need to reset the scene and update the frame. 308 | else { 309 | await this.currentScene.reset(); 310 | this.frame = frame; 311 | } 312 | } 313 | 314 | // While the frame is too low, we need to advance the playback state. 315 | // PlaybackManager.next() will handle swapping the scene if necessary. 316 | while (this.frame < frame && !this.finished) { 317 | const finished = await this.next(); 318 | if (finished) break; 319 | } 320 | }).bind(player.playback); 321 | 322 | /** Compute the final frame count properly based on the clips. */ 323 | const oldPlaybackRecalculate = player.playback.recalculate.bind(player.playback); 324 | (player.playback as any).recalculate = (async function() { 325 | await oldPlaybackRecalculate(); 326 | const duration = (composer.clips.current[0] ?? []).reduce((lastMax, clip) => 327 | Math.max(lastMax, clip.cache.clipRange[1]), 0); 328 | if (duration === 0) return; 329 | this.frame = duration; 330 | this.duration = duration; 331 | }).bind(player.playback); 332 | 333 | 334 | // Override the audio properties to use an AudioProxy object. 335 | (player.audio as any).setSource = () => { /* no-op */ } 336 | (player.audio as any).source = 'SOURCE_EXISTS'; 337 | (player.audio as any).audioElement = new AudioProxy(this.audio); 338 | 339 | // Recalculate the player's duration and stuff when the clips change. 340 | this.onClipsChanged.subscribe(() => (player as any).requestRecalculation()); 341 | 342 | // Load the initial state from the settings. 343 | this.tracks.current = this.settings.get('tracks') ?? []; 344 | this.audio.setTracks(this.tracks.current); 345 | this.targetTrack.current = this.settings.get('targetTrack') ?? 1; 346 | setUUIDNext(this.settings.get('uuidNext') ?? 0); 347 | this.setClips(this.settings.get('clips') ?? []); 348 | 349 | // Update the current clip to the one that will be seeked to. 350 | // This is necessary for the scene previews to render correctly on initial load. 351 | this.updateCurrentClip((this.player as any).requestedSeek ?? 0); 352 | 353 | // Force update the player's speed, as although it's loaded by Motion Canvas, 354 | // it doesn't actually update the playback properly. 355 | const desiredSpeed = (player as any).playerState.current.speed ?? 1; 356 | (player as any).playerState.current.speed = 1; 357 | player.setSpeed(desiredSpeed); 358 | } 359 | 360 | /** 361 | * Updates all clip caches, and updates scene subscriptions. Called by `setClips`, and when a scene changes. 362 | * Also manages subscribing and unsubscribing to scene events as needed to re-call this function. 363 | */ 364 | 365 | private updateClipCaches(dispatchEvent: boolean) { 366 | console.warn('REFRESHING CLIPS CACHE'); 367 | 368 | const hangingScenes = new Set([ ...this.sceneSubscriptions.keys() ]); 369 | const sources = getSources(); 370 | 371 | this.clips.current.forEach((layer, channel) => { 372 | let lastEndFrames = -1; 373 | 374 | for (let clip of layer) { 375 | let source = sources.find(s => s.name === clip.path && s.type === clip.type); 376 | 377 | const sourceFrames = source 378 | ? this.player.status.secondsToFrames(source.duration) 379 | : undefined; 380 | const offsetFrames = this.player.status.secondsToFrames(clip.offset); 381 | const startFrames = this.player.status.secondsToFrames(clip.start); 382 | const lengthFrames = this.player.status.secondsToFrames(clip.length); 383 | 384 | ensure(offsetFrames >= lastEndFrames, 'Clips must not overlap.'); 385 | lastEndFrames = offsetFrames + lengthFrames; 386 | 387 | const clipRange = [ offsetFrames, offsetFrames + lengthFrames ] as [ number, number ]; 388 | 389 | ensure(!sourceFrames || (startFrames >= 0 && startFrames < sourceFrames), 390 | 'Clip start out of bounds.'); 391 | ensure(!sourceFrames || (lengthFrames > 0 && startFrames + lengthFrames <= sourceFrames), 392 | 'Clips length out of bounds.') 393 | ensure(clipRange[0] >= 0 && clipRange[1] > clipRange[0], 394 | 'Clip must not end before it begins.'); 395 | 396 | clip.cache = { 397 | clipRange, 398 | lengthFrames, 399 | startFrames, 400 | sourceFrames, 401 | source, 402 | channel 403 | }; 404 | 405 | if (source?.scene && hangingScenes.has(source.scene)) hangingScenes.delete(source.scene); 406 | 407 | if (source?.scene && !this.sceneSubscriptions.has(source.scene)) 408 | this.sceneSubscriptions.set(source.scene, 409 | source.scene.onRecalculated.subscribe(() => this.updateClipCaches(true))); 410 | 411 | hangingScenes.forEach(scene => { 412 | this.sceneSubscriptions.get(scene)?.(); 413 | this.sceneSubscriptions.delete(scene); 414 | }); 415 | } 416 | }); 417 | 418 | if (dispatchEvent) this.clips.current = [ ...this.clips.current ]; 419 | 420 | const numAudioTracks = Math.max(this.clips.current.length - 1, 1); 421 | while (this.tracks.current.length - 1 < numAudioTracks) 422 | this.tracks.current.push({ solo: false, muted: false, locked: false }); 423 | if (this.tracks.current.length - 1 > numAudioTracks) (this.tracks as any).value = 424 | this.tracks.current.slice(0, numAudioTracks + 1); 425 | if (dispatchEvent) this.tracks.current = [ ...this.tracks.current ]; 426 | if (this.targetTrack.current >= numAudioTracks + 1 || this.targetTrack.current < 1) this.targetTrack.current = 1; 427 | } 428 | 429 | /** 430 | * Sets the current clip to the clip that should be rendered at the specified frame. 431 | * Called by the player's method overrides when the frame changes. 432 | * 433 | * @param frame - The frame to find the clip for. 434 | */ 435 | 436 | private updateCurrentClip(frame: number): Clip { 437 | let prev: Clip | null = null; 438 | let next: Clip | null = null; 439 | let found: Clip | null = null; 440 | 441 | // We want to find the previous, current, and next clips for the current frame. 442 | // The previous and next are used when creating the empty timeline scene, as it should know 443 | // how long it should exist for. 444 | for (let candidate of this.clips.current[0] ?? []) { 445 | ensure(candidate.cache, 'Uncached clip found!'); 446 | if (candidate.cache.clipRange[1] < frame) prev = candidate; 447 | if (frame >= candidate.cache.clipRange[0] && frame < candidate.cache.clipRange[1]) { 448 | found = candidate; 449 | break; 450 | } 451 | else if (frame < candidate.cache.clipRange[0]) { 452 | next = candidate; 453 | break; 454 | } 455 | } 456 | 457 | // If we found a clip, we need to make sure that it has a scene property, 458 | // or add one, or set it to the MissingClip scene if the source doesn't exist. 459 | // And then we return it. 460 | if (found) { 461 | // If the clip is missing, we want to return a MissingClip clip instead. 462 | if (!found.cache.source) { 463 | const clip = this.internalClips.MissingClip; 464 | clip.cache = { 465 | clipRange: found.cache.clipRange, 466 | lengthFrames: found.cache.lengthFrames, 467 | startFrames: 0, 468 | sourceFrames: found.cache.sourceFrames, 469 | source: clip.cache.source, 470 | channel: 0 471 | }; 472 | this.currentClip.current = clip; 473 | return clip; 474 | } 475 | 476 | // If the clip is a video type, we want to return the video clip scene, 477 | // and we want to set the clip scene to the video clip scene. 478 | if (found.cache.source?.type === 'video') { 479 | setVideo(`/media/${found.cache.source.name!}`, found.cache.source.duration, found.length + found.start); 480 | found.cache.source.scene = this.internalClips.VideoClip.cache.source.scene; 481 | } 482 | else if (found.cache.source?.type === 'image') { 483 | setImage(`/media/${found.cache.source.name!}`, found.length + found.start); 484 | found.cache.source.scene = this.internalClips.ImageClip.cache.source.scene; 485 | } 486 | 487 | // Set the current clip to the found clip, and return it 488 | this.currentClip.current = found; 489 | return found; 490 | } 491 | // If we couldn't find a clip, we want to return the EmptyTimeline clip. 492 | // The EmptyTimelineClip should last for the total duration between the previous and next clips. 493 | else { 494 | const clip = this.internalClips.EmptyTimeline; 495 | const clipRange: [ number, number ] = [ 496 | prev ? prev.cache.clipRange[1] : 0, 497 | next ? next.cache.clipRange[0] : (this.player as any).endFrame ]; 498 | clip.cache = { 499 | clipRange, 500 | lengthFrames: clipRange[1] - clipRange[0], 501 | startFrames: 0, 502 | sourceFrames: clipRange[1] - clipRange[0], 503 | source: clip.cache.source, 504 | channel: 0 505 | } 506 | this.currentClip.current = clip; 507 | return clip; 508 | } 509 | } 510 | } 511 | 512 | const Instance = new MotionComposer(); 513 | export default Instance; 514 | -------------------------------------------------------------------------------- /src/plugin/client/Provider.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import { ComponentChildren } from 'preact'; 4 | import { useSignal } from '@preact/signals'; 5 | import { Vector2 } from '@motion-canvas/core'; 6 | import { useEffect, useState, useMemo } from 'preact/hooks'; 7 | 8 | import { ClipSource } from './Types'; 9 | import { addEventListener } from './Util'; 10 | import { UIContext, ShortcutsContext } from './Contexts'; 11 | import { ShortcutModule } from './shortcut/ShortcutMappings'; 12 | 13 | export default function Provider({ children }: { children: ComponentChildren }) { 14 | const [ mediaTabVisible, setMediaTabVisible ] = useState(false); 15 | 16 | const addSource = useSignal(null); 17 | const addSourceDragPos = useSignal(new Vector2()); 18 | 19 | const shortcutModule = useState('global'); 20 | 21 | // Display viewport shortcuts. 22 | 23 | const viewport = document.querySelector('[class^="_viewport_"]'); 24 | useEffect(() => { 25 | const toRemove: (() => void)[] = []; 26 | if (!viewport) return; 27 | toRemove.push(addEventListener(viewport, 'mouseenter', () => shortcutModule[1]('viewport'))); 28 | toRemove.push(addEventListener(viewport, 'mouseleave', () => shortcutModule[1]('global'))); 29 | return () => toRemove.forEach(fn => fn()); 30 | }, [ viewport ]); 31 | 32 | // All the context values (so many). 33 | 34 | const uiContextData = useMemo(() => ({ 35 | mediaTabOpen: mediaTabVisible, 36 | updateMediaTabOpen: setMediaTabVisible, 37 | addSource, 38 | addSourceDragPos 39 | }), [ mediaTabVisible ]); 40 | 41 | const shortcutsContextData = useMemo(() => ({ 42 | currentModule: shortcutModule[0], setCurrentModule: shortcutModule[1] }), [ shortcutModule ]); 43 | 44 | return ( 45 | 46 | 47 | {children} 48 | 49 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /src/plugin/client/Sources.ts: -------------------------------------------------------------------------------- 1 | import { ClipSource } from './Types'; 2 | import { useSubscribableValue } from '@motion-canvas/ui'; 3 | import { Scene, ValueDispatcher } from '@motion-canvas/core'; 4 | 5 | const AUDIO_FILES = import.meta.glob(`/media/*.(wav|mp3|ogg|flac)`, { query: '?meta' }); 6 | const VIDEO_FILES = import.meta.glob(`/media/*.(mp4|mkv|webm)`, { query: '?meta' }); 7 | const IMAGE_FILES = import.meta.glob(`/media/*.(png|jpg|jpeg|webp)`, { query: '?meta' }); 8 | 9 | let sources = new ValueDispatcher([]); 10 | 11 | function replaceSourcesOfType(type: string, insert: ClipSource[]) { 12 | if (type === 'image') insert.forEach(s => s.duration = Infinity); 13 | const existingOfType = sources.current.filter(s => s.type === type).sort((a, b) => a.path.localeCompare(b.path)); 14 | const sortedInsert = [ ...insert ].sort((a, b) => a.path.localeCompare(b.path)); 15 | 16 | let identical = existingOfType.length === sortedInsert.length; 17 | if (identical) { 18 | for (let i = 0; i < sortedInsert.length; i++) { 19 | const oldSc = existingOfType[i]; 20 | const newSc = sortedInsert[i]; 21 | if (oldSc.duration !== newSc.duration || 22 | oldSc.name !== newSc.name || 23 | oldSc.path !== newSc.path || 24 | oldSc.scene !== newSc.scene) { 25 | identical = false; 26 | break; 27 | } 28 | } 29 | } 30 | 31 | if (identical) return; 32 | 33 | sources.current = [ 34 | ...sources.current.filter(s => s.type !== type), 35 | ...sortedInsert, 36 | ]; 37 | } 38 | 39 | const INTERNAL_SCENES = [ 'EmptyTimelineScene', 'MissingClipScene', 'VideoClipScene', 'ImageClipScene' ]; 40 | 41 | Promise.all(Object.values(AUDIO_FILES).map(async (f) => 42 | (await f() as any).default)).then(sources => replaceSourcesOfType('audio', sources)); 43 | 44 | Promise.all(Object.values(VIDEO_FILES).map(async (f) => 45 | (await f() as any).default)).then(sources => replaceSourcesOfType('video', sources)); 46 | 47 | Promise.all(Object.values(IMAGE_FILES).map(async (f) => 48 | (await f() as any).default)).then(sources => replaceSourcesOfType('image', sources)); 49 | 50 | export function updateSceneSources(scenes: Scene[]) { 51 | console.warn('UPDATE SCENE SOURCES'); 52 | 53 | replaceSourcesOfType('scene', scenes.filter(({ name }) => !INTERNAL_SCENES.includes(name)).map(scene => ({ 54 | type: 'scene', 55 | path: scene.name, 56 | name: scene.name, 57 | duration: scene.playback.framesToSeconds(scene.lastFrame - scene.firstFrame), 58 | scene: scene, 59 | }))); 60 | } 61 | 62 | export const onSourcesChanged = sources.subscribable; 63 | 64 | export function getSources() { 65 | return sources.current; 66 | } 67 | 68 | export function useSources() { 69 | return useSubscribableValue(sources.subscribable); 70 | } 71 | -------------------------------------------------------------------------------- /src/plugin/client/Types.ts: -------------------------------------------------------------------------------- 1 | import { Scene } from '@motion-canvas/core'; 2 | 3 | import SceneClipItem from './media/SceneClipItem'; 4 | import VideoClipItem from './media/VideoClipItem'; 5 | import ImageClipItem from './media/ImageClipItem'; 6 | import AudioClipItem from './media/AudioClipItem'; 7 | 8 | export const ClipTypes = [ 'scene', 'video', 'image', 'audio' ] as const; 9 | 10 | export type ClipType = typeof ClipTypes[number]; 11 | 12 | export const ClipSourceComponents: Record = { 13 | scene: SceneClipItem, 14 | video: VideoClipItem, 15 | image: ImageClipItem, 16 | audio: AudioClipItem, 17 | } as const; 18 | 19 | export interface PluginSettings { 20 | uuidNext: number; 21 | clips: Clip[][]; 22 | tracks: Track[]; 23 | targetTrack: number; 24 | } 25 | 26 | export interface ClipSource { 27 | /** The type of the clip source. */ 28 | type: ClipType; 29 | 30 | /** The path of the clip source (in the filesystem, or the Scene name if it's a scene clip source.) */ 31 | path: string; 32 | 33 | /** The name of the clip source. */ 34 | name: string; 35 | 36 | /** The duration of the clip source. */ 37 | duration: number; 38 | 39 | /** A thumbnail image for the clip source, if one exists. */ 40 | thumbnail?: string; 41 | 42 | /** The audio peaks of the clip source, if it has audio. */ 43 | peaks?: number[]; 44 | 45 | /** The scene for this clip source, if the time is a scene. */ 46 | scene?: Scene; 47 | } 48 | 49 | export type ClipInfo = { 50 | /** The number of frames into the scene that this clip should start at. */ 51 | startFrames: number; 52 | 53 | /** The length of the clip in frames. */ 54 | lengthFrames: number; 55 | 56 | /** The frame range for the clip in the timeline. */ 57 | clipRange: [ number, number ]; 58 | 59 | /** The channel index for this clip. */ 60 | channel: number; 61 | 62 | } & ({ 63 | /** The clip's source. */ 64 | source: ClipSource; 65 | 66 | /** The length of the source in frames. */ 67 | sourceFrames: number; 68 | } | { 69 | /** If the clip's source is undefined, don't have the sourceFrames property. */ 70 | source: undefined; 71 | 72 | sourceFrames: undefined; 73 | }) 74 | 75 | export interface Clip { 76 | /** A unique UUID for the clip. */ 77 | uuid: number; 78 | 79 | /** The type of this clip. */ 80 | type: ClipType; 81 | 82 | /** The path to the clip's resource. */ 83 | path: string; 84 | 85 | /** The offset of the clip within its track. */ 86 | offset: number; 87 | 88 | /** How far into the underlying resource this clip starts. */ 89 | start: number; 90 | 91 | /** The length of the clip in seconds. */ 92 | length: number; 93 | 94 | /** The volume of the clip, as a multiplier from 0 to 1. */ 95 | volume: number; 96 | 97 | /** Cached clip info. This will exist if and only if the clip's source was resolved. */ 98 | cache: ClipInfo; 99 | } 100 | 101 | export interface Track { 102 | // name?: string; 103 | // color?: string; 104 | 105 | muted: boolean; 106 | solo: boolean; 107 | locked: boolean; 108 | } 109 | 110 | export type EditorTool = 'select' | 'cut' | 'shift'; 111 | export type EditorMode = 'compose' | 'clip'; 112 | 113 | export function copyClip(clip: Clip): Clip { 114 | const cacheSafe = { ...clip.cache }; 115 | delete cacheSafe.source; 116 | const newClip = structuredClone({ ...clip, cache: cacheSafe }); 117 | newClip.cache.source = clip.cache.source; 118 | return newClip; 119 | } 120 | -------------------------------------------------------------------------------- /src/plugin/client/Util.ts: -------------------------------------------------------------------------------- 1 | export function ensure(condition: T, message: string): asserts condition { 2 | if (!condition) throw new Error(message); 3 | } 4 | 5 | export function addEventListener(obj: T, event: string, listener: EventListener): () => void { 6 | obj.addEventListener(event, listener); 7 | return () => obj.removeEventListener(event, listener); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/plugin/client/audio/AudioController.ts: -------------------------------------------------------------------------------- 1 | import { signal, Signal } from '@preact/signals'; 2 | 3 | import { Clip, ClipSource, Track } from '../Types'; 4 | 5 | export const BUFFER_QUEUE_LOOKAHEAD = 200; 6 | 7 | export interface WaveformData { 8 | peaks: Uint16Array; 9 | sampleRate: number; 10 | } 11 | 12 | export interface AudioData { 13 | duration: number; 14 | absoluteMax: number; 15 | peaks: WaveformData[]; 16 | } 17 | 18 | export default class AudioController { 19 | private context: AudioContext; 20 | 21 | private volume: number = 1; 22 | 23 | private clips: Clip[] = []; 24 | // private tracks: Track[] = []; 25 | private audibleTracks: boolean[]; 26 | 27 | private data = new Map>; 28 | private buffers = new Map; 29 | private buffering = new Set(); 30 | 31 | private activeClips = new Map(); 32 | 33 | private latencyBehaviour: 'desync' | 'prep_audio' | 'delay_video' = 'prep_audio'; 34 | 35 | constructor() { 36 | this.context = new AudioContext({ latencyHint: 'interactive' }); 37 | } 38 | 39 | async cacheSource(audio: string) { 40 | if (this.buffers.has(audio) || this.buffering.has(audio)) return; 41 | this.buffering.add(audio); 42 | const buffer = await this.context.decodeAudioData(await (await fetch(`/media/${audio}`)).arrayBuffer()); 43 | this.buffers.set(audio, buffer); 44 | this.generateWaveform(audio); 45 | this.buffering.delete(audio); 46 | } 47 | 48 | async setClips(allClips: Clip[]) { 49 | this.clips = allClips.filter(clip => (clip.type === 'audio' || clip.type === 'video') && clip.cache.source); 50 | 51 | for (const clip of this.clips) { 52 | await this.cacheSource(clip.cache.source.name); 53 | } 54 | } 55 | 56 | async setTracks(tracks: Track[]) { 57 | const numSolos = tracks.filter(track => track.solo).length; 58 | this.audibleTracks = []; 59 | for (let i = 0; i < tracks.length; i++) { 60 | if (numSolos === 0) this.audibleTracks.push(!tracks[i].muted); 61 | else if (numSolos === 1) this.audibleTracks.push(tracks[i].solo); 62 | else this.audibleTracks.push(tracks[i].solo && !tracks[i].muted); 63 | } 64 | this.setVolume(this.volume, true); 65 | } 66 | 67 | getDuration() { 68 | return this.clips.reduce((max, clip) => Math.max(max, clip.length + clip.offset), 0); 69 | } 70 | 71 | getCurrentTime() { 72 | return this.context.currentTime; 73 | } 74 | 75 | stop() { 76 | this.activeClips.forEach(([ , source ]) => source.stop()); 77 | this.activeClips.clear(); 78 | } 79 | 80 | async play(time: number) { 81 | this.stop(); 82 | this.bufferClips(time); 83 | 84 | if (this.latencyBehaviour === 'delay_video') { 85 | await new Promise((res) => setTimeout(res, this.context.outputLatency)); 86 | } 87 | } 88 | 89 | bufferClips(time: number) { 90 | if (this.latencyBehaviour === 'prep_audio') time += this.context.outputLatency; 91 | 92 | for (const clip of this.clips) { 93 | if (clip.length + clip.offset < time || clip.offset - BUFFER_QUEUE_LOOKAHEAD / 1000 > time) continue; 94 | if (this.activeClips.has(clip.uuid)) continue; 95 | const trackVolume = this.audibleTracks[clip.cache.channel] ? 1 : 0; 96 | 97 | const source = new AudioBufferSourceNode(this.context, { buffer: this.buffers.get(clip.cache.source.name) }); 98 | const gain = new GainNode(this.context, { gain: Math.pow(clip.volume * this.volume * trackVolume, 1) }); 99 | 100 | source.connect(gain).connect(this.context.destination); 101 | 102 | const when = ((clip.offset > time) ? clip.offset - time : 0) + this.context.currentTime; 103 | const offset = ((clip.offset > time) ? 0 : time - clip.offset) + clip.start; 104 | const duration = clip.length - offset + clip.start; 105 | 106 | source.start(when, offset, duration); 107 | this.activeClips.set(clip.uuid, [ clip, source, gain ]); 108 | } 109 | } 110 | 111 | setVolume(volume: number, force?: boolean) { 112 | if (this.volume === volume && !force) return; 113 | this.volume = volume; 114 | this.activeClips.forEach(([ clip, , gain ]) => { 115 | const trackVolume = this.audibleTracks[clip.cache.channel] ? 1 : 0; 116 | gain.gain.value = Math.pow(clip.volume * this.volume * trackVolume, 1) 117 | }); 118 | } 119 | 120 | getAudioData(source: ClipSource) { 121 | let data = this.data.get(source.name); 122 | if (!data) this.data.set(source.name, data = signal(null)); 123 | return data; 124 | } 125 | 126 | private generateWaveform(audio: string) { 127 | const MAX_SAMPLE_RATE = 30 * 128; // 128 samples per frame. 128 | // const MIN_SAMPLE_RATE = 30 * 1; // 1 sample per frame. 129 | const MAX_PEAKS_ARR_LEN = 2048; 130 | 131 | const buffer = this.buffers.get(audio); 132 | if (!buffer) return; 133 | 134 | const samplesPerSecond = Math.min(buffer.sampleRate, MAX_SAMPLE_RATE); 135 | const len = samplesPerSecond * buffer.duration; 136 | const samplesPerInd = buffer.sampleRate / samplesPerSecond; 137 | 138 | const peaksFloat = new Float32Array(len); 139 | let absoluteMax = 0; 140 | 141 | for (let channelId = 0; channelId < buffer.numberOfChannels; channelId++) { 142 | const channel = buffer.getChannelData(channelId); 143 | for (let i = 0; i < len; i++) { 144 | const start = ~~(i * samplesPerInd); 145 | const end = ~~(start + samplesPerInd); 146 | let sum = Math.abs(channel[start]); 147 | for (let j = start + 1; j < end; j++) sum += Math.abs(channel[j]); 148 | const avg = sum / samplesPerInd; 149 | peaksFloat[i] += avg; 150 | } 151 | } 152 | 153 | for (let i = 0; i < len; i++) { 154 | peaksFloat[i] = peaksFloat[i] / buffer.numberOfChannels; 155 | if (peaksFloat[i] > absoluteMax) absoluteMax = peaksFloat[i]; 156 | } 157 | 158 | const fullPeaks = new Uint16Array(len); 159 | for (let i = 0; i < len; i++) fullPeaks[i] = ((peaksFloat[i] / absoluteMax) * 0xFFFF) | 0; 160 | 161 | const peaks: WaveformData[] = [ { peaks: fullPeaks, sampleRate: fullPeaks.length / buffer.duration } ]; 162 | while (peaks[peaks.length - 1].peaks.length > MAX_PEAKS_ARR_LEN) { 163 | const last = peaks[peaks.length - 1]; 164 | const newPeaks = new Uint16Array(Math.ceil(last.peaks.length / 2)); 165 | for (let i = 0; i < newPeaks.length - 1; i++) 166 | newPeaks[i] = (last.peaks[i * 2] + last.peaks[i * 2 + 1]) / 2; 167 | newPeaks[newPeaks.length - 1] = (last.peaks[(newPeaks.length - 1) * 2] + last.peaks[last.peaks.length - 1]) / 2; 168 | peaks.push({ peaks: newPeaks, sampleRate: last.sampleRate / 2 }); 169 | } 170 | 171 | let data = this.data.get(audio); 172 | if (!data) this.data.set(audio, data = signal(null)); 173 | data.value = { 174 | peaks, 175 | absoluteMax, 176 | duration: buffer.duration 177 | }; 178 | return data; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/plugin/client/audio/AudioProxy.ts: -------------------------------------------------------------------------------- 1 | import { ensure } from '../Util'; 2 | import AudioController, { BUFFER_QUEUE_LOOKAHEAD } from './AudioController'; 3 | 4 | const BUFFER_INTERVAL = 100; 5 | const UPDATE_INTERVAL = 16; 6 | 7 | ensure(BUFFER_INTERVAL + 50 < BUFFER_QUEUE_LOOKAHEAD, 'BUFFER_INTERVAL must be less than BUFFER_QUEUE_LOOKAHEAD.'); 8 | 9 | export default class AudioProxy { 10 | private controller: AudioController; 11 | 12 | private isMuted = false; 13 | private globalVolume = 1; 14 | private isPaused = true; 15 | 16 | private playbackTime = 0; 17 | private abort: AbortController | null = null; 18 | 19 | constructor (cache: AudioController) { 20 | this.controller = cache; 21 | } 22 | 23 | get currentTime(): number { 24 | return this.playbackTime; 25 | } 26 | 27 | set currentTime(time) { 28 | this.playbackTime = time; 29 | if (!this.isPaused) this.play(); 30 | } 31 | 32 | get muted(): boolean { 33 | return this.isMuted; 34 | } 35 | 36 | set muted(muted: boolean) { 37 | this.isMuted = muted; 38 | this.controller.setVolume(this.isMuted ? 0 : this.globalVolume); 39 | } 40 | 41 | get volume(): number { 42 | return this.globalVolume; 43 | } 44 | 45 | set volume(volume: number) { 46 | this.globalVolume = volume; 47 | this.controller.setVolume(this.isMuted ? 0 : this.globalVolume); 48 | } 49 | 50 | get duration(): number { 51 | return this.controller.getDuration(); 52 | } 53 | 54 | get paused(): boolean { 55 | return this.isPaused; 56 | } 57 | 58 | set src(_: string) { /* No-op */ } 59 | 60 | pause() { 61 | this.abort?.abort(); 62 | this.abort = null; 63 | if (this.isPaused) return; 64 | this.isPaused = true; 65 | this.controller.stop(); 66 | } 67 | 68 | async play() { 69 | this.pause(); 70 | this.isPaused = false; 71 | 72 | await this.controller.play(this.playbackTime); 73 | 74 | // If pausing happened before cache finished starting. 75 | if (this.paused) return; 76 | 77 | let lastTime = this.controller.getCurrentTime(); 78 | let sinceLastBuffer = 0; 79 | const abort = new AbortController(); 80 | this.abort = abort; 81 | 82 | const update = () => { 83 | if (abort.signal.aborted) return; 84 | const time = this.controller.getCurrentTime(); 85 | const delta = time - lastTime; 86 | this.playbackTime += delta; 87 | lastTime = time; 88 | sinceLastBuffer += delta; 89 | 90 | if (sinceLastBuffer > BUFFER_INTERVAL / 1000) { 91 | this.controller.bufferClips(this.playbackTime); 92 | sinceLastBuffer = 0; 93 | } 94 | 95 | setTimeout(update, UPDATE_INTERVAL); 96 | } 97 | 98 | setTimeout(update, UPDATE_INTERVAL); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/plugin/client/icon/AudioIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function AudioIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugin/client/icon/ClipperIcon.tsx: -------------------------------------------------------------------------------- 1 | 2 | /* @jsxImportSource preact */ 3 | 4 | export default function ClipperIcon({ class: className = '' }: { class?: string }) { 5 | return ( 6 | 7 | ); 8 | } 9 | 10 | -------------------------------------------------------------------------------- /src/plugin/client/icon/CompositionIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function CompositionIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/HandIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function HandIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/ImageIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function ImageIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugin/client/icon/LockIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function LockIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugin/client/icon/MagnetIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function MagnetIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugin/client/icon/MissingIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function MissingIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/MuteIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function MuteIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugin/client/icon/SceneIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function SceneIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/ScissorIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function ScissorIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/SelectIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function SelectIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/SoloIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function SoloIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugin/client/icon/TargetIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function TargetIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | -------------------------------------------------------------------------------- /src/plugin/client/icon/VideoIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function VideoIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/ViewLgIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function ViewLgIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/ViewListIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function ViewListIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/ViewMdIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function ViewMdIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/ViewSmIcon.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | export default function ViewSmIcon({ class: className = '' }: { class?: string }) { 4 | return ( 5 | 6 | ); 7 | } 8 | 9 | -------------------------------------------------------------------------------- /src/plugin/client/icon/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Audio } from './AudioIcon'; 2 | export { default as Video } from './VideoIcon'; 3 | export { default as Image } from './ImageIcon'; 4 | export { default as Scene } from './SceneIcon'; 5 | export { default as ViewLg } from './ViewLgIcon'; 6 | export { default as ViewMd } from './ViewMdIcon'; 7 | export { default as ViewSm } from './ViewSmIcon'; 8 | export { default as ViewList } from './ViewListIcon'; 9 | export { default as Missing } from './MissingIcon'; 10 | export { default as Scissor } from './ScissorIcon'; 11 | export { default as Select } from './SelectIcon'; 12 | export { default as Hand } from './HandIcon'; 13 | export { default as Composition } from './CompositionIcon'; 14 | export { default as Clipper } from './ClipperIcon'; 15 | export { default as Magnet } from './MagnetIcon'; 16 | export { default as Lock } from './LockIcon'; 17 | export { default as Mute } from './MuteIcon'; 18 | export { default as Solo } from './SoloIcon'; 19 | export { default as Target } from './TargetIcon'; 20 | -------------------------------------------------------------------------------- /src/plugin/client/index.ts: -------------------------------------------------------------------------------- 1 | import { makeEditorPlugin } from '@motion-canvas/ui'; 2 | 3 | import Provider from './Provider'; 4 | import MotionComposer from './MotionComposer'; 5 | import { MediaTabConfig } from './media/MediaTabConfig'; 6 | import { OverlayConfig } from './overlay/OverlayConfig'; 7 | 8 | export default makeEditorPlugin({ 9 | name: 'motion-composer', 10 | previewOverlay: OverlayConfig, 11 | tabs: [ MediaTabConfig ], 12 | provider: Provider, 13 | project: MotionComposer.patchProject.bind(MotionComposer), 14 | player: MotionComposer.patchPlayer.bind(MotionComposer) 15 | }); 16 | 17 | export { useStore, useLazyRef, useUUID, getUUID, useClips, useCurrentClip, useTracks } from './Hooks'; 18 | export type { Clip, ClipInfo, ClipSource, ClipType } from './Types'; 19 | -------------------------------------------------------------------------------- /src/plugin/client/media/AudioClipItem.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import { useRef, useState, useLayoutEffect } from 'preact/hooks'; 4 | 5 | import styles from './Media.module.scss'; 6 | 7 | import * as Icon from '../icon'; 8 | import ClipItem, { ClipItemChildProps } from './ClipItem'; 9 | 10 | const CANVAS = document.createElement('canvas'); 11 | const CTX = CANVAS.getContext('2d')!; 12 | 13 | export default function AudioClipItem(props: ClipItemChildProps) { 14 | const imgRef = useRef(null); 15 | const [ imgData, setImgData ] = useState(); 16 | 17 | useLayoutEffect(() => { 18 | const W = 109; 19 | const H = 109 * (11/16); 20 | const PIXELS_PER_WAVE = 3; 21 | const POW = 1; 22 | 23 | const numBlocks = Math.floor(W / PIXELS_PER_WAVE); 24 | const samplesPerBlock = Math.floor(props.source.peaks.length / 2 / numBlocks); 25 | 26 | CANVAS.width = W; 27 | CANVAS.height = H; 28 | CTX.clearRect(0, 0, W, H); 29 | 30 | CTX.fillStyle = getComputedStyle(imgRef.current).getPropertyValue('fill'); 31 | CTX.fillStyle = `color-mix(in hsl, ${CTX.fillStyle} ${1/samplesPerBlock*4*100}%, transparent)`; 32 | 33 | CTX.beginPath(); 34 | 35 | for (let i = 0; i < numBlocks; i++) { 36 | for (let j = 0; j < samplesPerBlock; j++) { 37 | let minPeak = -props.source.peaks[(i * samplesPerBlock + j) * 2] / 32768; 38 | let maxPeak = -props.source.peaks[(i * samplesPerBlock + j) * 2 + 1] / 32768; 39 | if (minPeak > maxPeak) { 40 | const temp = minPeak; 41 | minPeak = maxPeak; 42 | maxPeak = temp; 43 | } 44 | 45 | const minPos = Math.pow(Math.abs(minPeak), POW) * (minPeak < 0 ? -1 : 1) * 3/2 * H/2; 46 | const maxPos = Math.pow(Math.abs(maxPeak), POW) * (maxPeak < 0 ? -1 : 1) * 3/2 * H/2; 47 | CTX.fillRect(i * PIXELS_PER_WAVE, H/2 + minPos, PIXELS_PER_WAVE, maxPos - minPos); 48 | } 49 | } 50 | 51 | CTX.fill(); 52 | setImgData(CANVAS.toDataURL()); 53 | }, [ props.source ]); 54 | 55 | return ( 56 | } 63 | /> 64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /src/plugin/client/media/ClipItem.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import clsx from 'clsx'; 4 | import { FunctionComponent, VNode } from 'preact'; 5 | import { formatDuration } from '@motion-canvas/ui'; 6 | 7 | import styles from './Media.module.scss'; 8 | 9 | import { ClipSource } from '../Types'; 10 | 11 | export interface ClipItemChildProps { 12 | source: ClipSource; 13 | 14 | onDragStart: (evt: PointerEvent) => void; 15 | // onDragEnd: (evt: PointerEvent) => void; 16 | // onDragMove: (evt: PointerEvent) => void; 17 | } 18 | 19 | export interface ClipItemProps extends ClipItemChildProps { 20 | class: string; 21 | 22 | name: string; 23 | duration: number; 24 | icon: FunctionComponent<{ class: string }>; 25 | thumbnail: string | VNode; 26 | } 27 | 28 | export default function ClipItem(props: ClipItemProps) { 29 | const Icon = props.icon; 30 | 31 | function handleDragStart(evt: PointerEvent) { 32 | evt.preventDefault(); 33 | evt.stopPropagation(); 34 | // (evt.currentTarget as any).setPointerCapture(evt.pointerId); 35 | props.onDragStart(evt); 36 | } 37 | 38 | // function handleDragMove(evt: PointerEvent) { 39 | // // if (!(evt.currentTarget as any).hasPointerCapture(evt.pointerId)) return; 40 | // evt.preventDefault(); 41 | // evt.stopPropagation(); 42 | 43 | // props.onDragMove(evt); 44 | // } 45 | 46 | // function handleDragEnd(evt: PointerEvent) { 47 | // // if (!(evt.currentTarget as any).hasPointerCapture(evt.pointerId)) return; 48 | // evt.preventDefault(); 49 | // evt.stopPropagation(); 50 | 51 | // props.onDragEnd(evt); 52 | // } 53 | 54 | return ( 55 |
61 | {(typeof props.thumbnail === 'string') 62 | ? 63 | : props.thumbnail} 64 | 65 |

{props.name}

66 |

67 | {props.duration === Infinity 68 | ? '--:--:--' 69 | : formatDuration(props.duration)} 70 |

71 |
72 | ); 73 | } 74 | -------------------------------------------------------------------------------- /src/plugin/client/media/ImageClipItem.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import styles from './Media.module.scss'; 4 | 5 | import * as Icon from '../icon'; 6 | import ClipItem, { ClipItemChildProps } from './ClipItem'; 7 | 8 | export default function ImageClipItem(props: ClipItemChildProps) { 9 | return ( 10 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/plugin/client/media/Media.module.scss: -------------------------------------------------------------------------------- 1 | .media_tab { 2 | margin-right: -16px; 3 | margin-top: -44px; 4 | } 5 | 6 | .view { 7 | display: flex; 8 | gap: 4px; 9 | width: 100%; 10 | justify-content: flex-start; 11 | flex-direction: row-reverse; 12 | margin-bottom: 16px; 13 | padding-right: 6px; 14 | 15 | > * { 16 | flex-grow: 0; 17 | color: #ccc; 18 | } 19 | } 20 | 21 | .media_container { 22 | display: grid; 23 | gap: 8px; 24 | 25 | .clip_item { 26 | background-color: var(--surface-color-hover); 27 | overflow: hidden; 28 | display: grid; 29 | grid-template-columns: auto 1fr; 30 | grid-template-rows: auto auto auto; 31 | border-radius: 6px; 32 | position: relative; 33 | cursor: pointer; 34 | box-shadow: 0px 2px 6px 0px rgba(0, 0, 0, 0.1); 35 | 36 | &:hover { 37 | background-color: var(--surface-color-light); 38 | } 39 | 40 | .thumbnail { 41 | background-color: color-mix(in hsl, white 4%, transparent); 42 | flex-grow: 1; 43 | display: grid; 44 | overflow: hidden; 45 | aspect-ratio: 16/11; 46 | grid-column: 1 / 3; 47 | grid-row: 1 / 2; 48 | } 49 | 50 | &.scene .thumbnail { 51 | width: 100%; 52 | object-fit: cover; 53 | } 54 | 55 | &.audio .thumbnail { 56 | width: 100%; 57 | aspect-ratio: 16/11; 58 | padding: 4px; 59 | fill: var(--theme); 60 | background-color: var(--background-color-dark); 61 | image-rendering: pixelated; 62 | image-rendering: crisp-edges; 63 | } 64 | 65 | &.video .thumbnail, &.image .thumbnail { 66 | width: 100%; 67 | aspect-ratio: 16/11; 68 | object-fit: cover; 69 | } 70 | 71 | .name { 72 | overflow: hidden; 73 | text-overflow: ellipsis; 74 | white-space: nowrap; 75 | margin: 0; 76 | font-size: 12px; 77 | font-weight: 600; 78 | padding-top: 10px; 79 | padding-bottom: 6px; 80 | padding-right: 6px; 81 | line-height: 1; 82 | grid-row: 2/3; 83 | grid-column: 2/3; 84 | } 85 | 86 | .duration { 87 | font-size: 12px; 88 | font-weight: bold; 89 | margin: 0; 90 | opacity: 0.4; 91 | font-family: var(--font-family-mono); 92 | padding-bottom: 8px; 93 | line-height: 1; 94 | grid-row: 3/4; 95 | grid-column: 2/3; 96 | } 97 | 98 | .icon { 99 | opacity: 1; 100 | width: 18px; 101 | padding: 6px; 102 | box-sizing: content-box; 103 | height: 20px; 104 | grid-row: 2/4; 105 | grid-column: 1/2; 106 | color: #777; 107 | } 108 | } 109 | 110 | &.lg { 111 | grid-template-columns: repeat(auto-fill, 180px); 112 | } 113 | 114 | &.md { 115 | grid-template-columns: repeat(auto-fill, 117px); 116 | } 117 | 118 | &.sm { 119 | grid-template-columns: repeat(auto-fill, 86px); 120 | 121 | .clip_item { 122 | .icon { 123 | position: absolute; 124 | top: -63px; 125 | left: 60px; 126 | width: 14px; 127 | color: white; 128 | filter: drop-shadow(0px 1px 4px rgba(0.2, 0.2, 0.2, 1)) drop-shadow(0px 1px 6px rgba(0.2, 0.2, 0.2, 1)) drop-shadow(0px 2px 8px rgba(0.2, 0.2, 0.2, 1)); 129 | } 130 | 131 | .name { 132 | padding-top: 6px; 133 | padding-bottom: 4px; 134 | padding-right: 4px; 135 | } 136 | 137 | .name, .duration { 138 | padding-left: 6px; 139 | } 140 | 141 | .duration { 142 | padding-bottom: 6px; 143 | font-size: 10px; 144 | font-weight: black; 145 | } 146 | } 147 | } 148 | 149 | &.list { 150 | gap: 2px; 151 | 152 | .clip_item { 153 | background: transparent; 154 | box-shadow: none; 155 | overflow: visible; 156 | grid-template-columns: auto 1fr auto; 157 | grid-template-rows: 1fr; 158 | place-items: center; 159 | gap: 8px; 160 | padding: 6px; 161 | margin: 0 0 0 -6px; 162 | 163 | &:hover { 164 | background-color: var(--surface-color-hover); 165 | } 166 | 167 | .thumbnail { 168 | position: absolute; 169 | top: calc(100% + 12px); 170 | left: -6px; 171 | z-index: 10000; 172 | width: 200px; 173 | aspect-ratio: 16/11; 174 | opacity: 0; 175 | scale: 0.9; 176 | transform-origin: top left; 177 | transition: opacity 0.08s 0s, scale 0.08s 0s; 178 | pointer-events: none; 179 | border-radius: 8px; 180 | box-shadow: 0px 2px 10px 0px rgba(0, 0, 0, 0.3); 181 | } 182 | 183 | &:hover .thumbnail { 184 | opacity: 1; 185 | scale: 1; 186 | transition: opacity 0.08s 0.2s, scale 0.08s 0.2s; 187 | } 188 | 189 | .name, .icon, .duration { 190 | grid-row: unset; 191 | grid-column: unset; 192 | padding: 0; 193 | width: 100%; 194 | } 195 | 196 | .icon { 197 | width: 18px; 198 | margin-right: 4px; 199 | color: #666; 200 | } 201 | } 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /src/plugin/client/media/MediaPane.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import clsx from 'clsx'; 4 | import { Vector2 } from '@motion-canvas/core'; 5 | import { useEffect, useMemo } from 'preact/hooks'; 6 | import { Button, useStorage} from '@motion-canvas/ui'; 7 | 8 | import styles from './Media.module.scss'; 9 | 10 | import * as Icon from '../icon'; 11 | import { useSources } from '../Sources'; 12 | import { useUIContext } from '../Contexts'; 13 | import useShortcutHover from '../shortcut/useShortcutHover'; 14 | import { ClipSource, ClipSourceComponents, ClipTypes } from '../Types'; 15 | 16 | export default function MediaPane() { 17 | const uiCtx = useUIContext() 18 | const clipSources = useSources(); 19 | const [ shortcutRef ] = useShortcutHover('media'); 20 | 21 | const [ view, setView ] = useStorage<'lg' | 'md' | 'sm' | 'list'>('md'); 22 | 23 | useEffect(() => { 24 | if (uiCtx.addSource.value === null) return; 25 | 26 | const onDragMove = (evt: PointerEvent) => { 27 | console.log('move'); 28 | } 29 | 30 | const onDragEnd = (evt: PointerEvent) => { 31 | console.log('end'); 32 | } 33 | 34 | window.addEventListener('pointermove', onDragMove); 35 | window.addEventListener('pointerup', onDragEnd); 36 | 37 | return () => { 38 | window.removeEventListener('pointermove', onDragMove); 39 | window.removeEventListener('pointerup', onDragEnd); 40 | } 41 | }, [ uiCtx.addSource.value ]); 42 | 43 | const clipSourceElements = useMemo(() => { 44 | function onDragStart(source: ClipSource, evt: PointerEvent) { 45 | console.log('dragStart'); 46 | uiCtx.addSource.value = source; 47 | uiCtx.addSourceDragPos.value = new Vector2(evt.clientX, evt.clientY); 48 | } 49 | 50 | function onDragMove(source: ClipSource, evt: PointerEvent) { 51 | console.log('dragMove'); 52 | uiCtx.addSourceDragPos.value = new Vector2(evt.clientX, evt.clientY); 53 | } 54 | 55 | function onDragEnd(source: ClipSource, evt: PointerEvent) { 56 | console.log('dragEnd'); 57 | uiCtx.addSource.value = null; 58 | } 59 | 60 | return clipSources 61 | .sort((a, b) => (ClipTypes.indexOf(a.type) - ClipTypes.indexOf(b.type)) || a.name.localeCompare(b.name)) 62 | .map(s => { 63 | const Component = ClipSourceComponents[s.type]; 64 | return ( 65 | onDragStart(s, evt)} 69 | // onDragMove={(evt) => onDragMove(s, evt)} 70 | // onDragEnd={(evt) => onDragEnd(s, evt)} 71 | /> 72 | ); 73 | }); 74 | }, [ clipSources ]); 75 | 76 | return ( 77 |
78 |
79 | 80 | 81 | 82 | 83 |
84 |
85 | {clipSourceElements} 86 |
87 |
88 | ); 89 | } 90 | -------------------------------------------------------------------------------- /src/plugin/client/media/MediaTabConfig.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import { useEffect } from 'preact/hooks'; 4 | import { Pane, PluginTabConfig, PluginTabProps, Tab } from '@motion-canvas/ui'; 5 | 6 | import styles from './Media.module.scss'; 7 | 8 | import { useUIContext } from '../Contexts'; 9 | import MediaPane from './MediaPane'; 10 | 11 | function MediaTabIcon({ tab }: PluginTabProps) { 12 | return ( 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | function MediaTab() { 20 | // Let the plugin know that the media tab is open. 21 | const ctx = useUIContext(); 22 | useEffect(() => { 23 | ctx.updateMediaTabOpen(true); 24 | return () => ctx.updateMediaTabOpen(false); 25 | }, []); 26 | 27 | // Render the media tab. 28 | return ( 29 | 30 |
31 | 32 |
33 |
34 | ); 35 | } 36 | 37 | export const MediaTabConfig: PluginTabConfig = { 38 | name: 'Clips & Media', 39 | tabComponent: MediaTabIcon, 40 | paneComponent: MediaTab, 41 | }; 42 | -------------------------------------------------------------------------------- /src/plugin/client/media/SceneClipItem.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import { Scene, DependencyContext } from '@motion-canvas/core'; 4 | import { useApplication } from '@motion-canvas/ui'; 5 | import { useRef, useLayoutEffect } from 'preact/hooks'; 6 | 7 | import styles from './Media.module.scss'; 8 | 9 | import * as Icon from '../icon'; 10 | import MotionComposer from '../MotionComposer'; 11 | import ClipItem, { ClipItemChildProps } from './ClipItem'; 12 | 13 | const SRC_CANVAS = document.createElement('canvas'); 14 | const SRC_CTX = SRC_CANVAS.getContext('2d')!; 15 | const DST_CANVAS = document.createElement('canvas'); 16 | const DST_CTX = DST_CANVAS.getContext('2d')!; 17 | 18 | const PREVIEW_WIDTH = 240; 19 | const RESOLUTION_MULT = 2; 20 | const PREVIEW_FRAME_OFFSET = 30; 21 | const BLANK_IMG_SRC = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; 22 | 23 | export default function SceneClipItem(props: ClipItemChildProps) { 24 | const { player } = useApplication(); 25 | const imgRef = useRef(null); 26 | 27 | const srcRef = useRef(BLANK_IMG_SRC); 28 | 29 | useLayoutEffect(() => { 30 | const scene = props.source.scene; 31 | if (!scene) return; 32 | 33 | async function genThumbnail() { 34 | // try { 35 | // await new Promise(res => setTimeout(res, 0)); 36 | // const currentClip = MotionComposer.getCurrentClip(); 37 | 38 | // const moveBackFrames = (currentClip?.cache && currentClip.cache.source?.scene === scene) 39 | // ? Math.max(player.playback.frame - currentClip.cache.clipRange[0], 0) 40 | // : 0; 41 | 42 | // // await DependencyContext.consumePromises(); 43 | // await scene.reset(); 44 | // // await DependencyContext.consumePromises(); 45 | // const size = scene.getSize(); 46 | 47 | // const framesInScene = scene.lastFrame - scene.firstFrame; 48 | // const maxFrameOffset = Math.floor(framesInScene / 2); 49 | // for (let i = 0; i < Math.min(PREVIEW_FRAME_OFFSET, maxFrameOffset); i++) await scene.next(); 50 | 51 | // SRC_CANVAS.width = size.x; 52 | // SRC_CANVAS.height = size.y; 53 | // DST_CANVAS.width = PREVIEW_WIDTH * RESOLUTION_MULT; 54 | // DST_CANVAS.height = PREVIEW_WIDTH * (size.y / size.x) * RESOLUTION_MULT; 55 | 56 | // // await DependencyContext.consumePromises(); 57 | // await scene.render(SRC_CTX); 58 | // // await DependencyContext.consumePromises(); 59 | // DST_CTX.drawImage(SRC_CANVAS, 0, 0, DST_CANVAS.width, DST_CANVAS.height); 60 | // srcRef.current = DST_CANVAS.toDataURL(); 61 | // imgRef.current!.src = srcRef.current; 62 | 63 | // await scene.reset(); 64 | // // await DependencyContext.consumePromises(); 65 | // for (let i = 0; i < moveBackFrames; i++) await scene.next(); 66 | // // await DependencyContext.consumePromises(); 67 | // } 68 | // catch (e) { 69 | // console.error('error while rendering thumbnail'); 70 | // srcRef.current = BLANK_IMG_SRC; 71 | // imgRef.current!.src = srcRef.current; 72 | // } 73 | } 74 | 75 | return scene.onCacheChanged.subscribe(genThumbnail); 76 | }, [ imgRef.current, props.source.scene ]); 77 | 78 | return ( 79 | } 86 | /> 87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /src/plugin/client/media/VideoClipItem.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import styles from './Media.module.scss'; 4 | 5 | import * as Icon from '../icon'; 6 | import ClipItem, { ClipItemChildProps } from './ClipItem'; 7 | 8 | export default function VideoClipItem(props: ClipItemChildProps) { 9 | return ( 10 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/plugin/client/overlay/Overlay.module.scss: -------------------------------------------------------------------------------- 1 | .timeline_overlay { 2 | position: fixed; 3 | z-index: 1000; 4 | bottom: 28px; 5 | left: 52px; 6 | right: 0; 7 | overflow: hidden; 8 | display: grid; 9 | } 10 | 11 | .playback_overlay { 12 | position: fixed; 13 | z-index: 1000; 14 | right: 2px; 15 | height: 50px; 16 | overflow: hidden; 17 | display: grid; 18 | pointer-events: none; 19 | } 20 | 21 | .shortcuts_overlay { 22 | position: fixed; 23 | z-index: 1000; 24 | bottom: 0; 25 | left: 0; 26 | width: auto; 27 | right: 192px; 28 | overflow: hidden; 29 | } 30 | -------------------------------------------------------------------------------- /src/plugin/client/overlay/OverlayConfig.tsx: -------------------------------------------------------------------------------- 1 | /* @jsxImportSource preact */ 2 | 3 | import styles from './Overlay.module.scss'; 4 | 5 | import { useState, useLayoutEffect } from 'preact/hooks'; 6 | import { OverlayWrapper, PluginOverlayConfig } from '@motion-canvas/ui'; 7 | 8 | import Timeline from '../timeline/Timeline'; 9 | import { FooterShortcuts } from '../shortcut/FooterShortcuts'; 10 | 11 | /** 12 | * Identifies the existing timeline in the DOM, and renders our new timeline overtop of it. 13 | * Listens for height change events so that we can always perfectly shadow the original timeline. 14 | */ 15 | 16 | function Overlay() { 17 | const [ timelineHeight, setTimelineHeight ] = useState(0); 18 | 19 | useLayoutEffect(() => { 20 | const heightStyleElem = document.querySelector('*[class^=_timelineWrapper]') 21 | .parentElement.parentElement.parentElement.children[0]; 22 | 23 | const updateHeight = () => { 24 | const timeline = heightStyleElem.parentElement.children[2].querySelector('*[class^=_timelineWrapper]'); 25 | if (!timeline) setTimelineHeight(0); 26 | else setTimelineHeight(timeline.getBoundingClientRect().height) 27 | }; 28 | 29 | updateHeight(); 30 | const observer = new MutationObserver(() => updateHeight()); 31 | observer.observe(heightStyleElem, { attributes: true, attributeFilter: ['style'] }); 32 | }, []); 33 | 34 | return ( 35 | 36 | {timelineHeight > 0 &&
38 | 39 |
} 40 |
41 | 42 |
43 |
44 | ); 45 | } 46 | 47 | export const OverlayConfig: PluginOverlayConfig = { 48 | component: Overlay, 49 | }; 50 | -------------------------------------------------------------------------------- /src/plugin/client/scenes/EmptyTimelineScene.meta: -------------------------------------------------------------------------------- 1 | { 2 | "version": 0, 3 | "timeEvents": [], 4 | "seed": 1037183482 5 | } -------------------------------------------------------------------------------- /src/plugin/client/scenes/EmptyTimelineScene.tsx: -------------------------------------------------------------------------------- 1 | import { Txt, Circle, makeScene2D, Rect } from '@motion-canvas/2d'; 2 | import { waitFor } from '@motion-canvas/core'; 3 | 4 | export default makeScene2D(function* (view) { 5 | view.fill('#0c0c0c'); 6 | view.lineWidth(20); 7 | 8 | const sceneSize = view.size(); 9 | 10 | view.add( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {/* */} 20 | 28 | No Clip 29 | 30 | 31 | ) 32 | yield* waitFor(1); 33 | }); 34 | -------------------------------------------------------------------------------- /src/plugin/client/scenes/ImageClipScene.meta: -------------------------------------------------------------------------------- 1 | { 2 | "version": 0, 3 | "timeEvents": [], 4 | "seed": 3379964600 5 | } -------------------------------------------------------------------------------- /src/plugin/client/scenes/ImageClipScene.tsx: -------------------------------------------------------------------------------- 1 | import { waitFor } from '@motion-canvas/core'; 2 | import { Img, makeScene2D} from '@motion-canvas/2d'; 3 | 4 | const STATE = { 5 | source: '', 6 | clipDuration: 0 7 | } 8 | 9 | export function setImage(source: string, clipDuration: number) { 10 | STATE.source = source; 11 | STATE.clipDuration = clipDuration; 12 | } 13 | 14 | export default makeScene2D(function* (view) { 15 | if (!STATE.source) return; 16 | view.add(); 17 | yield* waitFor(STATE.clipDuration); 18 | }); 19 | -------------------------------------------------------------------------------- /src/plugin/client/scenes/MissingClipScene.meta: -------------------------------------------------------------------------------- 1 | { 2 | "version": 0, 3 | "timeEvents": [], 4 | "seed": 8360968 5 | } -------------------------------------------------------------------------------- /src/plugin/client/scenes/MissingClipScene.tsx: -------------------------------------------------------------------------------- 1 | import { Txt, Circle, makeScene2D, Rect } from '@motion-canvas/2d'; 2 | import { waitFor } from '@motion-canvas/core'; 3 | 4 | export default makeScene2D(function* (view) { 5 | view.fill('#1c0c0c'); 6 | view.lineWidth(20); 7 | 8 | const sceneSize = view.size(); 9 | 10 | view.add( 11 | <> 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | {/* */} 20 | 28 | Clip Missing 29 | 30 | 31 | ) 32 | yield* waitFor(1); 33 | }); 34 | -------------------------------------------------------------------------------- /src/plugin/client/scenes/VideoClipScene.meta: -------------------------------------------------------------------------------- 1 | { 2 | "version": 0, 3 | "timeEvents": [], 4 | "seed": 2870004809 5 | } -------------------------------------------------------------------------------- /src/plugin/client/scenes/VideoClipScene.tsx: -------------------------------------------------------------------------------- 1 | import { Video, makeScene2D} from '@motion-canvas/2d'; 2 | import { createRef, waitFor } from '@motion-canvas/core'; 3 | 4 | const STATE = { 5 | source: '', 6 | sourceDuration: 0, 7 | playDuration: 0 8 | } 9 | 10 | export function setVideo(source: string, sourceDuration: number, playDuration: number) { 11 | STATE.source = source; 12 | STATE.sourceDuration = sourceDuration; 13 | STATE.playDuration = playDuration; 14 | } 15 | 16 | export default makeScene2D(function* (view) { 17 | if (!STATE.source) return; 18 | 19 | const videoRef = createRef