feat: support for multiple projects (#57)

This PR makes it possible to specify multiple project files when configuring Motion Canvas:
```ts
export default defineConfig({
  plugins: [
    motionCanvas({
      project: [
        './src/projectA.ts',
        './src/projectB.ts',
      ],
    }),
  ],
});
```
When visiting [http://localhost:9000](http://localhost:9000),
Motion Canvas will display a project selection screen.

Each project receives its own url and is hosted separately.
For instance, `./src/projectA.ts`
would be available at [http://localhost:9000/projectA](http://localhost:9000/projectA).

When editing, it's possible to open the project selection view by clicking on the
Motion Canvas icon located in the top left corner.

When rendering, each project is saved in its own folder inside the output directory.

The project name should no longer be configured when creating a new instance.
Instead, the name is derived based on the file name.
```ts
export default new Project({
  name: 'something' // <- does nothing
  scenes: [example],
  background: '#141414',
});
```
This commit is contained in:
Jacob
2022-08-29 17:17:44 +02:00
committed by GitHub
parent 26911481fa
commit 573752dd4d
40 changed files with 525 additions and 266 deletions

View File

@@ -10,7 +10,7 @@ export const ProjectSize = {
};
export interface ProjectConfig {
name: string;
name?: string;
scenes: Scene[];
audio?: string;
audioOffset?: number;
@@ -140,7 +140,6 @@ export class Project {
private height: number;
public constructor({
name,
scenes,
audio,
audioOffset,
@@ -150,7 +149,6 @@ export class Project {
}: ProjectConfig) {
this.setCanvas(canvas);
this.setSize(size);
this.name = name;
this.background = background;
if (audio) {
@@ -320,6 +318,7 @@ export class Project {
frame,
data: this.canvas.toDataURL(this._fileType, this._quality),
mimeType: this._fileType,
project: this.name,
});
}),
);

View File

@@ -6,7 +6,12 @@ declare module 'vite/types/customEvent' {
interface CustomEventMap {
'motion-canvas:meta': {source: string; data: import('./Meta').Metadata};
'motion-canvas:meta-ack': {source: string};
'motion-canvas:export': {frame: number; data: string; mimeType: string};
'motion-canvas:export': {
frame: number;
data: string;
mimeType: string;
project: string;
};
'motion-canvas:export-ack': {frame: number};
'motion-canvas:assets': {urls: string[]};
}

View File

@@ -3,7 +3,6 @@ import {Project} from '@motion-canvas/core/lib';
import example from './scenes/example?scene';
export default new Project({
name: 'project',
scenes: [example],
background: '#141414',
});

View File

@@ -3,7 +3,6 @@ import {Project} from '@motion-canvas/core/lib';
import example from './scenes/example?scene';
export default new Project({
name: 'project',
scenes: [example],
background: '#141414',
});

View File

@@ -22,13 +22,13 @@ the entire length of the video, it will only render a portion of the frames in
your animation. This can be useful for quickly rendering small changes in your
animation.
##### FPS (Frames Per Second)
##### Frame rate
The frame rate at which the preview plays and the number of frames that will
render per one second of runtime. The most common values are 24, 30, and 60,
though any whole integer value is allowed. Motion Canvas animations are
resilient to changes in frame rate, so most animations will not be affected by
changing the FPS.
changing it.
##### Resolution

View File

@@ -3,7 +3,6 @@ import {Project} from '@motion-canvas/core/lib';
import example from './scenes/example?scene';
export default new Project({
name: 'project',
scenes: [example],
background: '#141414',
});

View File

@@ -9,9 +9,10 @@
sizes="16x16"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAABKklEQVQ4jcWTvUoDURCFv/y0A2tnZ95AW7FZwQdQSO9aRLAJbp0mPkGCTSCBmPQW8Q1SuKTU4AvkEeQO2K6MuSFmN+pCCg9cLhdmzpwzh1tK05RdUN6p+yeCftKK+kmrW4SguvGaNc3PGGRUVEE18x4Dl9dlheP76OZxMQUCIOzVa++/W2jENYaV7oqEWTMETMmhv7fi+w4i4IVhxaaeAq+9es0aL4CJqoaqOsmSrGNsxCZ16ideMeiMVPVrJyISqerC7IhIsN3CoGMeTfYceKARn6/sqOozcADkktmMcU3y5KeZrQQ4ARxwpqpts5O3kIEvGvnJ1vwB7PuquYgc5RVs4tZHeAe8WbOIlIA9r3IJU/DXcc61nXOpcy7I1hb9C5aOLTeHf/6NwCdua48fJxuYPgAAAABJRU5ErkJggg=="
/>
<link rel="stylesheet" href="{{style}}" />
<title>Motion Canvas</title>
</head>
<body>
<script type="module" src="/@id/__x00__virtual:editor"></script>
<script type="module" src="{{source}}"></script>
</body>
</html>

View File

@@ -9,13 +9,17 @@
sizes="16x16"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAABKklEQVQ4jcWTvUoDURCFv/y0A2tnZ95AW7FZwQdQSO9aRLAJbp0mPkGCTSCBmPQW8Q1SuKTU4AvkEeQO2K6MuSFmN+pCCg9cLhdmzpwzh1tK05RdUN6p+yeCftKK+kmrW4SguvGaNc3PGGRUVEE18x4Dl9dlheP76OZxMQUCIOzVa++/W2jENYaV7oqEWTMETMmhv7fi+w4i4IVhxaaeAq+9es0aL4CJqoaqOsmSrGNsxCZ16ideMeiMVPVrJyISqerC7IhIsN3CoGMeTfYceKARn6/sqOozcADkktmMcU3y5KeZrQQ4ARxwpqpts5O3kIEvGvnJ1vwB7PuquYgc5RVs4tZHeAe8WbOIlIA9r3IJU/DXcc61nXOpcy7I1hb9C5aOLTeHf/6NwCdua48fJxuYPgAAAABJRU5ErkJggg=="
/>
<title>Editor | Motion Canvas</title>
<title>Motion Canvas</title>
</head>
<body>
<script type="module">
import project from '@motion-canvas/template/dist/template.mjs';
import editor from '/src/main.tsx';
editor(project);
import {index} from '/src/main.tsx';
index([
{name: 'project.html', url: './src/project.ts'},
{name: 'exampleA', url: './src/exampleA.ts'},
{name: 'exampleB', url: './src/exampleB.ts'},
{name: 'exampleC', url: './src/exampleC.ts'},
]);
</script>
</body>
</html>

View File

@@ -21,8 +21,7 @@
},
"files": [
"dist",
"types",
"editor.html"
"types"
],
"peerDependencies": {
"@motion-canvas/core": "*"

21
packages/ui/project.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAOwAAADsAEnxA+tAAABKklEQVQ4jcWTvUoDURCFv/y0A2tnZ95AW7FZwQdQSO9aRLAJbp0mPkGCTSCBmPQW8Q1SuKTU4AvkEeQO2K6MuSFmN+pCCg9cLhdmzpwzh1tK05RdUN6p+yeCftKK+kmrW4SguvGaNc3PGGRUVEE18x4Dl9dlheP76OZxMQUCIOzVa++/W2jENYaV7oqEWTMETMmhv7fi+w4i4IVhxaaeAq+9es0aL4CJqoaqOsmSrGNsxCZ16ideMeiMVPVrJyISqerC7IhIsN3CoGMeTfYceKARn6/sqOozcADkktmMcU3y5KeZrQQ4ARxwpqpts5O3kIEvGvnJ1vwB7PuquYgc5RVs4tZHeAe8WbOIlIA9r3IJU/DXcc61nXOpcy7I1hb9C5aOLTeHf/6NwCdua48fJxuYPgAAAABJRU5ErkJggg=="
/>
<title>Motion Canvas</title>
</head>
<body>
<script type="module">
import project from '@motion-canvas/template/dist/projectA';
import {editor} from '/src/main.tsx';
editor(project);
</script>
</body>
</html>

View File

@@ -1,42 +0,0 @@
import './index.scss';
import {Sidebar} from './components/sidebar';
import {Timeline} from './components/timeline';
import {Viewport} from './components/viewport';
import {ResizeableLayout} from './components/layout';
import {useState, useMemo} from 'preact/hooks';
import {AppContext} from './AppContext';
import type {InspectedElement} from '@motion-canvas/core/lib/scenes';
export function App() {
const [sidebarOpen, setSidebarOpen] = useState(false);
const [inspectedElement, setInspectedElement] =
useState<InspectedElement | null>(null);
const contextState = useMemo(
() => ({
inspectedElement,
setInspectedElement,
}),
[inspectedElement],
);
return (
<ResizeableLayout
id={'main-timeline'}
size={0.7}
vertical
start={
<AppContext.Provider value={contextState}>
<ResizeableLayout
resizeable={sidebarOpen}
id={'sidebar-vieport'}
start={<Sidebar setOpen={setSidebarOpen} />}
end={<Viewport />}
/>
</AppContext.Provider>
}
end={<Timeline />}
/>
);
}

View File

@@ -1,16 +0,0 @@
import {createContext} from 'preact';
import type {InspectedElement} from '@motion-canvas/core/lib/scenes';
export interface AppState {
inspectedElement: InspectedElement | null;
setInspectedElement: (element: InspectedElement | null) => void;
}
const AppContext = createContext<AppState>({
inspectedElement: null,
setInspectedElement: () => {
throw new Error('setSelectedNode not implemented');
},
});
export {AppContext};

View File

@@ -0,0 +1,26 @@
import {Sidebar} from './components/sidebar';
import {Timeline} from './components/timeline';
import {Viewport} from './components/viewport';
import {ResizeableLayout} from './components/layout';
import {useState} from 'preact/hooks';
export function Editor() {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<ResizeableLayout
id={'main-timeline'}
size={0.7}
vertical
start={
<ResizeableLayout
resizeable={sidebarOpen}
id={'sidebar-vieport'}
start={<Sidebar setOpen={setSidebarOpen} />}
end={<Viewport />}
/>
}
end={<Timeline />}
/>
);
}

View File

@@ -0,0 +1,52 @@
.root {
background-color: var(--surface-color);
border-radius: 4px;
width: 100%;
margin: 16px;
max-width: 480px;
max-height: calc(100% - 32px);
overflow-y: auto;
}
.header {
position: sticky;
top: 0;
font-size: 16px;
line-height: 24px;
text-transform: uppercase;
font-weight: bold;
display: flex;
gap: 16px;
padding: 16px;
border-radius: 4px 4px 0 0;
background-color: var(--surface-color);
border-bottom: 1px solid var(--background-color-dark);
}
.list {
padding: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.element {
text-decoration: none;
display: block;
padding: 8px;
border-radius: 4px;
&:hover {
background-color: var(--surface-color-hover);
}
}
.title {
padding-bottom: 4px;
color: rgba(255, 255, 255, 0.6);
}
.subtitle {
font-size: 11px;
color: rgba(255, 255, 255, 0.32);
}

26
packages/ui/src/Index.tsx Normal file
View File

@@ -0,0 +1,26 @@
import styles from './Index.module.scss';
export interface ProjectData {
name: string;
url: string;
}
export interface IndexProps {
projects: ProjectData[];
}
export function Index({projects}: IndexProps) {
return (
<div className={styles.root}>
<div className={styles.header}>Projects</div>
<div className={styles.list}>
{projects.map(project => (
<a className={styles.element} href={`/${project.name}`}>
<div className={styles.title}>{project.name}</div>
<div className={styles.subtitle}>{project.url}</div>
</a>
))}
</div>
</div>
);
}

View File

@@ -13,7 +13,7 @@
height: 24px;
display: block;
content: '';
background: #ffffff;
background: var(--icon-color);
}
}
@@ -37,11 +37,11 @@
}
&.main::after {
background-color: #fff;
background-color: var(--icon-color);
}
.iconInput + &:hover::after {
background-color: #fff;
background-color: var(--icon-color);
}
.iconInput:checked + & {
@@ -49,7 +49,7 @@
background: var(--theme);
}
&.main:after {
background: #ffffff;
background: var(--icon-color);
}
}
}
@@ -70,7 +70,7 @@
}
&:hover::after {
background-color: #fff;
background-color: var(--icon-color);
}
}
@@ -88,13 +88,14 @@
text-align: right;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.select,
.input,
.button,
.value {
background: #404040;
background: var(--surface-color-light);
color: rgba(255, 255, 255, 0.6);
border: 0;
border-radius: 4px;
@@ -108,13 +109,13 @@
min-width: 0;
&:hover {
background-color: #333;
background-color: var(--surface-color-hover);
}
&.main {
background-color: var(--theme);
color: rgba(0, 0, 0, 0.87);
font-weight: 700;
font-weight: bold;
&:hover {
box-shadow: 0 0 0 2px white inset;
@@ -165,6 +166,9 @@
cursor: pointer;
}
.motionCanvas:after {
-webkit-mask-image: url('../../img/icons/motion_canvas.svg');
}
.pause:after {
-webkit-mask-image: url('../../img/icons/pause.svg');
}

View File

@@ -4,13 +4,22 @@ import {IconType} from './IconType';
import {classes} from '../../utils';
import {JSX} from 'preact';
interface IconProps extends JSX.HTMLAttributes<HTMLDivElement> {
type IntrinsicTag = keyof JSX.IntrinsicElements;
type IconProps<T extends IntrinsicTag> = JSX.IntrinsicElements[T] & {
type: IconType;
className?: string;
}
as?: T;
};
export function Icon({type, className, ...rest}: IconProps) {
export function Icon<T extends IntrinsicTag = 'div'>({
type,
className,
as = 'div' as T,
...rest
}: IconProps<T>) {
const As = as as string;
return (
<div className={classes(styles.icon, styles[type], className)} {...rest} />
<As className={classes(styles.icon, styles[type], className)} {...rest} />
);
}

View File

@@ -1,13 +1,14 @@
export enum IconType {
dragIndicator = 'dragIndicator',
motionCanvas = 'motionCanvas',
pause = 'pause',
play = 'play',
repeat = 'repeat',
skipNext = 'skipNext',
skipPrevious = 'skipPrevious',
volumeOn = 'volumeOn',
volumeOff = 'volumeOff',
tune = 'tune',
videoSettings = 'videoSettings',
volumeOn = 'volumeOn',
volumeOff = 'volumeOff',
schedule = 'schedule',
dragIndicator = 'dragIndicator',
}

View File

@@ -4,5 +4,11 @@ import type {JSX} from 'preact';
type LabelProps = JSX.HTMLAttributes<HTMLLabelElement>;
export function Label(props: LabelProps) {
return <label className={styles.label} {...props} />;
return (
<label
title={props.children as string}
className={styles.label}
{...props}
/>
);
}

View File

@@ -44,7 +44,7 @@
width: 100%;
height: 100%;
display: block;
background-color: #141414;
background-color: var(--background-color-dark);
}
&:hover::before {

View File

@@ -1,6 +1,6 @@
.progress {
height: 2px;
background-color: #080808;
background-color: var(--background-color-dark);
}
.progressFill {

View File

@@ -1,6 +1,5 @@
import {parseColor} from 'mix-color';
import {useContext, useMemo, useState} from 'preact/hooks';
import {AppContext} from '../../AppContext';
import {useMemo, useState} from 'preact/hooks';
import {useCurrentScene, useCurrentFrame} from '../../hooks';
import {classes} from '../../utils';
@@ -10,9 +9,10 @@ import styles from './Sidebar.module.scss';
import type {InspectedAttributes} from '@motion-canvas/core/lib/scenes';
import {isInspectable} from '@motion-canvas/core/lib/scenes/Inspectable';
import {Pane} from '../tabs';
import {useInspection} from '../../contexts';
export function Properties() {
const {inspectedElement} = useContext(AppContext);
const {inspectedElement} = useInspection();
const scene = useCurrentScene();
const {attributes, supportsInspection} = useMemo(

View File

@@ -32,7 +32,7 @@ export function Rendering() {
return (
<Pane title="Rendering">
<Group>
<Label>Range</Label>
<Label>range</Label>
<Input
min={0}
max={state.endFrame}
@@ -54,7 +54,7 @@ export function Rendering() {
/>
</Group>
<Group>
<Label>FPS</Label>
<Label>frame rate</Label>
<Input
type="number"
min={1}
@@ -66,7 +66,7 @@ export function Rendering() {
/>
</Group>
<Group>
<Label>Resolution</Label>
<Label>resolution</Label>
<Input
type="number"
min={1}
@@ -76,7 +76,7 @@ export function Rendering() {
player.project.setSize(value, height);
}}
/>
X
x
<Input
type="number"
min={1}
@@ -88,7 +88,7 @@ export function Rendering() {
/>
</Group>
<Group>
<Label>Scale</Label>
<Label>scale</Label>
<Select
options={scales}
value={state.scale}
@@ -96,7 +96,7 @@ export function Rendering() {
/>
</Group>
<Group>
<Label>Color Space</Label>
<Label>color space</Label>
<Select
options={colorSpaces}
value={state.colorSpace}
@@ -104,7 +104,7 @@ export function Rendering() {
/>
</Group>
<Group>
<Label>File Type</Label>
<Label>file type</Label>
<Select
options={fileTypes}
value={state.fileType}
@@ -112,7 +112,7 @@ export function Rendering() {
/>
</Group>
<Group>
<Label>Quality</Label>
<Label>quality</Label>
<Input
type="number"
min={0}

View File

@@ -1,5 +1,5 @@
.root {
background-color: #242424;
background-color: var(--surface-color);
}
.copied {
@@ -48,7 +48,7 @@
display: block;
content: '';
margin-bottom: 4px;
background-color: #444;
background-color: var(--surface-color-light);
}
.threadList {
@@ -67,7 +67,7 @@
width: 16px;
height: 2px;
margin-right: 4px;
background-color: #444;
background-color: var(--surface-color-light);
}
}
}

View File

@@ -1,5 +1,5 @@
import {IconType} from '../controls';
import {Tabs} from '../tabs';
import {Tabs, TabType} from '../tabs';
import {Properties} from './Properties';
import {Rendering} from './Rendering';
import {Threads} from './Threads';
@@ -10,19 +10,35 @@ interface SidebarProps {
export function Sidebar({setOpen}: SidebarProps) {
return (
<Tabs onToggle={tab => setOpen(tab >= 0)} id="sidebar">
{{
icon: IconType.tune,
pane: <Properties />,
}}
{{
icon: IconType.videoSettings,
pane: <Rendering />,
}}
{{
icon: IconType.schedule,
pane: <Threads />,
}}
</Tabs>
<>
<Tabs onToggle={tab => setOpen(tab >= 0)} id="sidebar">
{{
type: TabType.Link,
icon: IconType.motionCanvas,
url: window.location.pathname === '/' ? undefined : '/',
}}
{{
type: TabType.Space,
}}
{{
type: TabType.Pane,
icon: IconType.tune,
pane: <Properties />,
}}
{{
type: TabType.Pane,
icon: IconType.videoSettings,
pane: <Rendering />,
}}
{{
type: TabType.Pane,
icon: IconType.schedule,
pane: <Threads />,
}}
{{
type: TabType.Space,
}}
</Tabs>
</>
);
}

View File

@@ -2,17 +2,18 @@
width: 100%;
height: 100%;
display: flex;
background-color: #242424;
background-color: var(--surface-color);
}
.tabs {
flex-grow: 0;
flex-shrink: 0;
padding: 4px 0;
width: 48px;
display: flex;
justify-content: center;
flex-direction: column;
background-color: #181818;
background-color: var(--background-color);
}
.tab {
@@ -27,9 +28,18 @@
}
&.active {
background: #242424;
background: var(--surface-color);
opacity: 1;
}
&.disabled {
pointer-events: none;
opacity: 0.16;
}
}
.space {
flex-grow: 1;
}
.panes {

View File

@@ -2,49 +2,89 @@ import styles from './Tabs.module.scss';
import {Icon, IconType} from '../controls';
import {ComponentChildren} from 'preact';
import {useCallback, useContext, useEffect} from 'preact/hooks';
import {useCallback, useEffect, useLayoutEffect} from 'preact/hooks';
import {classes} from '../../utils';
import {useStorage} from '../../hooks';
import {AppContext} from '../../AppContext';
import {useInspection} from '../../contexts';
export enum TabType {
Link,
Pane,
Space,
}
type Tab =
| {
type: TabType.Pane;
icon: IconType;
pane: ComponentChildren;
}
| {
type: TabType.Link;
icon: IconType;
url?: string;
}
| {
type: TabType.Space;
};
interface TabsProps {
children: {icon: IconType; pane: ComponentChildren}[];
children: Tab[];
onToggle?: (tab: number) => void;
id?: string;
}
export function Tabs({children, onToggle, id}: TabsProps) {
const [tab, setTab] = useStorage(id, 1);
const [tab, setTab] = useStorage(id, -1);
const toggleTab = useCallback(
(value: number) => {
const newTab = value === tab ? -1 : value;
const newTab = value === tab ? -1 : getPane(children[value]) ? value : -1;
setTab(newTab);
},
[tab, setTab],
[tab, setTab, children],
);
useEffect(() => {
onToggle?.(tab);
useLayoutEffect(() => {
if (tab > -1 && !getPane(children[tab])) {
setTab(-1);
} else {
onToggle?.(tab);
}
}, [onToggle, tab]);
const {inspectedElement} = useContext(AppContext);
const {inspectedElement} = useInspection();
useEffect(() => {
if (inspectedElement && tab !== -1) {
setTab(0);
setTab(2);
}
}, [inspectedElement]);
return (
<div className={styles.root}>
<div className={styles.panes}>{children[tab]?.pane}</div>
<div className={styles.panes}>{getPane(children[tab])}</div>
<div className={styles.tabs}>
{children.map(({icon}, index) => (
<Icon
type={icon}
onClick={() => toggleTab(index)}
className={classes(styles.tab, [styles.active, tab === index])}
/>
))}
{children.map((data, index) =>
data.type === TabType.Link ? (
<Icon
as="a"
type={data.icon}
href={data.url}
className={classes(styles.tab, [styles.disabled, !data.url])}
/>
) : data.type === TabType.Pane ? (
<Icon
type={data.icon}
onClick={() => toggleTab(index)}
className={classes(styles.tab, [styles.active, tab === index])}
/>
) : (
<div className={styles.space} />
),
)}
</div>
</div>
);
}
function getPane(tab: Tab) {
return tab && tab.type === TabType.Pane ? tab.pane : false;
}

View File

@@ -1,5 +1,5 @@
.root {
background-color: #242424;
background-color: var(--surface-color);
width: 100%;
height: 100%;
@@ -19,7 +19,7 @@
overflow-y: hidden;
flex-grow: 1;
position: relative;
background-color: #181818;
background-color: var(--background-color);
width: 100%;
}
@@ -38,14 +38,14 @@
.trackContainer {
width: 100%;
height: 100%;
background-color: #242424;
background-color: var(--surface-color);
&::before {
width: 100%;
height: 32px;
display: block;
content: '';
background-color: #181818;
background-color: var(--background-color);
}
}
@@ -93,7 +93,7 @@
.scene {
position: relative;
border-radius: 4px;
background-color: #444;
background-color: var(--surface-color-light);
overflow: hidden;
.transition {
@@ -208,7 +208,7 @@
font-size: 12px;
line-height: 20px;
font-weight: bold;
color: rgba(0, 0, 0, 1);
color: #000000;
content: attr(data-frame);
}
}

View File

@@ -2,8 +2,8 @@ import styles from './Viewport.module.scss';
import {useCurrentScene, usePlayerTime} from '../../hooks';
import {useContext, useLayoutEffect, useRef} from 'preact/hooks';
import {ViewportContext} from './ViewportContext';
import {AppContext} from '../../AppContext';
import {isInspectable} from '@motion-canvas/core/lib/scenes/Inspectable';
import {useInspection} from '../../contexts';
export function Debug() {
const time = usePlayerTime();
@@ -11,7 +11,7 @@ export function Debug() {
const canvasRef = useRef<HTMLCanvasElement>();
const contextRef = useRef<CanvasRenderingContext2D>();
const state = useContext(ViewportContext);
const {inspectedElement, setInspectedElement} = useContext(AppContext);
const {inspectedElement, setInspectedElement} = useInspection();
useLayoutEffect(() => {
contextRef.current ??= canvasRef.current.getContext('2d');

View File

@@ -1,5 +1,4 @@
import {useCallback, useContext, useEffect, useRef} from 'preact/hooks';
import {AppContext} from '../../AppContext';
import {useCallback, useEffect, useRef} from 'preact/hooks';
import {
useCurrentScene,
@@ -15,7 +14,7 @@ import {Grid} from './Grid';
import styles from './Viewport.module.scss';
import {ViewportContext, ViewportState} from './ViewportContext';
import {isInspectable} from '@motion-canvas/core/lib/scenes/Inspectable';
import {usePlayer} from '../../contexts';
import {useInspection, usePlayer} from '../../contexts';
const ZOOM_SPEED = 0.1;
@@ -36,7 +35,7 @@ export function View() {
zoom: 1,
grid: false,
});
const {inspectedElement, setInspectedElement} = useContext(AppContext);
const {inspectedElement, setInspectedElement} = useInspection();
useEffect(() => {
setState({

View File

@@ -28,7 +28,7 @@
}
.playback {
background: #181818;
background: var(--background-color);
display: grid;
grid-template-columns: 1fr min-content 1fr;
padding: 12px;

View File

@@ -1,3 +1,4 @@
export * from './inspection';
export * from './player';
export * from './project';
export * from './timeline';

View File

@@ -0,0 +1,38 @@
import {ComponentChildren, createContext} from 'preact';
import type {InspectedElement} from '@motion-canvas/core/lib/scenes';
import {useContext, useMemo, useState} from 'preact/hooks';
export interface AppState {
inspectedElement: InspectedElement | null;
setInspectedElement: (element: InspectedElement | null) => void;
}
const InspectionContext = createContext<AppState>({
inspectedElement: null,
setInspectedElement: () => {
throw new Error('setSelectedNode not implemented');
},
});
export function InspectionProvider({children}: {children: ComponentChildren}) {
const [inspectedElement, setInspectedElement] =
useState<InspectedElement | null>(null);
const state = useMemo(
() => ({
inspectedElement,
setInspectedElement,
}),
[inspectedElement],
);
return (
<InspectionContext.Provider value={state}>
{children}
</InspectionContext.Provider>
);
}
export function useInspection() {
return useContext(InspectionContext);
}

View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 0 24 24" width="24px" fill="#000000">
<path d="M23.6,17c-0.1,0.7-0.7,1.1-1.3,1.1c-0.1,0-0.1,0-0.2,0l-4.8-0.8l0.8,4.8c0.1,0.7-0.4,1.4-1.1,1.5c-0.1,0-0.1,0-0.2,0
c-0.7,0-1.2-0.5-1.3-1.1l-0.7-4.8l-4.3,2.2c-0.7,0.3-1.5,0.1-1.8-0.6c-0.1-0.2-0.1-0.4-0.1-0.6c0-0.5,0.3-0.9,0.7-1.2l4.3-2.2
L10.1,12c-0.5-0.5-0.5-1.4,0-1.9c0.3-0.3,0.6-0.4,0.9-0.4s0.7,0.1,0.9,0.4l3.4,3.4l2.2-4.3c0.3-0.7,1.1-0.9,1.8-0.6
c0.5,0.2,0.7,0.7,0.7,1.2c0,0.2-0.1,0.4-0.1,0.6l-2.2,4.3l4.8,0.7C23.3,15.6,23.7,16.3,23.6,17z"/>
<path d="M15.5,8.9L15.5,8.9c-0.5,0.5-1.4,0.5-1.9,0l-2.8-2.8c-0.5-0.5-0.5-1.4,0-1.9l0,0c0.5-0.5,1.4-0.5,1.9,0L15.5,7
C16,7.5,16,8.4,15.5,8.9z"/>
<path d="M9.4,2.8L9.4,2.8C8.8,3.3,8,3.3,7.5,2.8l0,0C7,2.2,7,1.4,7.5,0.9l0,0c0.5-0.5,1.4-0.5,1.9,0l0,0C9.9,1.4,9.9,2.2,9.4,2.8z"
/>
<path d="M8.4,8.4L8.4,8.4c-0.5,0.5-1.4,0.5-1.9,0L2.3,4.2c-0.5-0.5-0.5-1.4,0-1.9l0,0c0.5-0.5,1.4-0.5,1.9,0l4.2,4.2
C8.9,7.1,8.9,7.9,8.4,8.4z"/>
<path d="M8.9,15.5L8.9,15.5C8.4,16,7.5,16,7,15.5L1.3,9.8c-0.5-0.5-0.5-1.4,0-1.9l0,0c0.5-0.5,1.4-0.5,1.9,0l5.7,5.7
C9.4,14.1,9.4,15,8.9,15.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,21 +1,30 @@
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap');
:root {
--theme: #33a6ff;
--theme-overlay: rgba(51, 166, 255, 0.24);
--icon-color: #ffffff;
--background-color: #181818;
--background-color-dark: #141414;
--surface-color: #242424;
--surface-color-light: #444444;
--surface-color-hover: #333333;
}
* {
box-sizing: border-box;
font-family: 'JetBrains Mono', sans-serif;
font-size: 14px;
&::-webkit-scrollbar {
width: 16px;
height: 16px;
background-color: #242424;
background-color: var(--surface-color);
}
&::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.24);
border: 6px solid #242424;
border: 6px solid var(--surface-color);
border-radius: 8px;
}
@@ -29,11 +38,14 @@ body {
overflow: hidden;
padding: 0;
margin: 0;
background-color: #000000;
background-color: var(--background-color);
color: rgba(255, 255, 255, 0.6);
}
main {
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -1,20 +1,32 @@
import './index.scss';
import type {Project} from '@motion-canvas/core/lib';
import {Player} from '@motion-canvas/core/lib/player';
import {render} from 'preact';
import {App} from './App';
import {PlayerProvider, ProjectProvider} from './contexts';
import {ComponentChild, render} from 'preact';
import {Editor} from './Editor';
import {Index, ProjectData} from './Index';
import {InspectionProvider, PlayerProvider, ProjectProvider} from './contexts';
export default (project: Project) => {
const app = document.createElement('main');
function renderRoot(vnode: ComponentChild) {
const root = document.createElement('main');
document.body.appendChild(root);
render(vnode, root);
}
export function editor(project: Project) {
const player = new Player(project);
document.body.appendChild(app);
document.title = `${project.name} | Motion Canvas`;
render(
renderRoot(
<PlayerProvider player={player}>
<ProjectProvider project={project}>
<App />
<InspectionProvider>
<Editor />
</InspectionProvider>
</ProjectProvider>
</PlayerProvider>,
app,
);
};
}
export function index(projects: ProjectData[]) {
renderRoot(<Index projects={projects} />);
}

View File

@@ -1,3 +1,10 @@
import type {Project} from '@motion-canvas/core/lib';
declare const _default: (project: Project) => void;
export default _default;
export function editor(project: Project): void;
export function index(
projects: {
name: string;
url: string;
}[],
): void;

View File

@@ -1,5 +1,6 @@
import {defineConfig} from 'vite';
import preact from '@preact/preset-vite';
import * as fs from 'fs';
export default defineConfig({
build: {
@@ -12,5 +13,17 @@ export default defineConfig({
external: ['@motion-canvas/core'],
},
},
plugins: [preact()],
plugins: [
preact(),
{
name: 'copy-files',
async buildStart() {
this.emitFile({
type: 'asset',
fileName: 'editor.html',
source: await fs.promises.readFile('./editor.html'),
});
},
},
],
});

View File

@@ -1,39 +0,0 @@
const path = require('path');
module.exports = (env, argv) => ({
entry: './src/index.ts',
mode: argv.mode ?? 'development',
devtool: argv.mode === 'production' ? false : 'inline-source-map',
module: {
rules: [
{
test: /\.tsx?$/,
loader: 'ts-loader',
},
{
test: /\.svg$/,
type: 'asset',
},
{
test: /\.scss$/,
use: [
{loader: 'style-loader'},
{loader: 'css-loader', options: {modules: true}},
{loader: 'sass-loader'},
],
},
],
},
resolve: {
extensions: ['.js', '.ts', '.tsx'],
},
output: {
path: path.resolve(__dirname, 'dist'),
},
devServer: {
port: 9001,
headers: {
'Access-Control-Allow-Origin': '*',
},
},
});

View File

@@ -6,15 +6,25 @@ import mime from 'mime-types';
export interface MotionCanvasPluginConfig {
/**
* The import path of the project file.
* The import path of the project file or an array of paths.
*
* @remarks
* The file must contain a default export exposing an instance of the
* Each file must contain a default export exposing an instance of the
* {@link Project} class.
*
* @example
* ```ts
* motionCanvas({
* project: [
* './src/firstProject.ts',
* './src/secondProject.ts',
* ]
* })
* ```
*
* @default './src/project.ts'
*/
project?: string;
project?: string | string[];
/**
* A directory path to which the animation will be rendered.
*
@@ -37,41 +47,57 @@ export interface MotionCanvasPluginConfig {
* @default /\.(wav|mp3|ogg)$/
*/
bufferedAssets?: RegExp;
editor?: {
/**
* The import path of the editor factory file.
*
* @remarks
* The file must contain a default export exposing a factory function.
* This function will be called with the project as its first argument.
* Its task is to create the user interface.
*
* @default '\@motion-canvas/ui'
*/
factory?: string;
/**
* The import path of the editor styles.
*
* @default '\@motion-canvas/ui/dist/style.css'
*/
styles?: string;
};
/**
* The import path of the editor package.
*
* @remarks
* This path will be resolved using Node.js module resolution rules.
* It should lead to a directory containing the following files:
* - `editor.html` - The HTML template for the editor.
* - `styles.css` - The editor styles.
* - `main.js` - A module exporting necessary factory functions.
*
* `main.js` should export the following functions:
* - `editor` - Receives the project as its first argument and creates the
* user interface.
* - `index` - Receives a list of all projects as its first argument and
* creates the initial page for selecting a project.
*
* @default '\@motion-canvas/ui'
*/
editor?: string;
}
interface ProjectData {
name: string;
url: string;
}
export default ({
project = './src/project.ts',
output = './output',
bufferedAssets = /\.(wav|mp3|ogg)$/,
editor: {
styles = '@motion-canvas/ui/dist/style.css',
factory = '@motion-canvas/ui',
} = {},
editor = '@motion-canvas/ui',
}: MotionCanvasPluginConfig = {}): Plugin => {
const editorPath = path.dirname(require.resolve('@motion-canvas/ui'));
const editorId = 'virtual:editor';
const resolvedEditorId = '\0' + editorId;
const editorPath = path.dirname(require.resolve(editor));
const editorFile = fs.readFileSync(path.resolve(editorPath, 'editor.html'));
const htmlParts = editorFile
.toString()
.replace('{{style}}', `/@fs/${path.resolve(editorPath, 'style.css')}`)
.split('{{source}}');
const createHtml = (src: string) => htmlParts[0] + src + htmlParts[1];
const resolvedEditorId = '\0virtual:editor';
const timeStamps: Record<string, number> = {};
const outputPath = path.resolve(output);
const projects: ProjectData[] = [];
const projectLookup: Record<string, ProjectData> = {};
for (const url of typeof project === 'string' ? [project] : project) {
const {name} = path.parse(url);
const data = {name, url};
projects.push(data);
projectLookup[name] = data;
}
let viteConfig: ResolvedConfig;
@@ -94,24 +120,37 @@ export default ({
async configResolved(resolvedConfig) {
viteConfig = resolvedConfig;
},
resolveId(id) {
if (id === editorId) {
return resolvedEditorId;
}
},
async load(id) {
if (id === resolvedEditorId) {
return source(
`import '${styles}';`,
`import editor from '${factory}';`,
`import project from '${project}?project';`,
`editor(project);`,
);
}
const [base, query] = id.split('?');
const {name, dir} = path.posix.parse(base);
if (id.startsWith(resolvedEditorId)) {
if (projects.length === 1) {
return source(
`import {editor} from '${editor}';`,
`import project from '${projects[0].url}?project';`,
`editor(project);`,
);
}
if (query) {
const params = new URLSearchParams(query);
const name = params.get('project');
if (name && name in projectLookup) {
return source(
`import {editor} from '${editor}';`,
`import project from '${projectLookup[name].url}?project';`,
`editor(project);`,
);
}
}
return source(
`import {index} from '${editor}';`,
`index(${JSON.stringify(projects)});`,
);
}
if (query) {
const params = new URLSearchParams(query);
if (params.has('scene')) {
@@ -142,7 +181,6 @@ export default ({
if (params.has('project')) {
const metaFile = `${name}.meta`;
await createMeta(path.join(dir, metaFile));
const projectFile = `${name}`;
return source(
`import '@motion-canvas/core/lib/patches/Factory';`,
@@ -150,8 +188,9 @@ export default ({
`import '@motion-canvas/core/lib/patches/Shape';`,
`import '@motion-canvas/core/lib/patches/Container';`,
`import meta from './${metaFile}';`,
`import project from './${projectFile}';`,
`import project from './${name}';`,
`project.meta = meta`,
`project.name = '${name}'`,
`export default project;`,
);
}
@@ -242,16 +281,18 @@ export default ({
const file = fs.readFileSync(
path.resolve(viteConfig.root, req.url.slice(1)),
);
const stream = Readable.from(file);
stream.on('end', console.log).pipe(res);
Readable.from(file).pipe(res);
return;
}
if (req.url === '/') {
const stream = fs.createReadStream(
path.resolve(editorPath, '../editor.html'),
);
stream.pipe(res);
res.end(createHtml('/@id/__x00__virtual:editor'));
return;
}
const name = req.url?.slice(1);
if (name && name in projectLookup) {
res.end(createHtml(`/@id/__x00__virtual:editor?project=${name}`));
return;
}
@@ -268,10 +309,10 @@ export default ({
});
server.ws.on(
'motion-canvas:export',
async ({frame, mimeType, data}, client) => {
async ({frame, mimeType, data, project}, client) => {
const name = frame.toString().padStart(6, '0');
const extension = mime.extension(mimeType);
const file = path.join(outputPath, name + '.' + extension);
const file = path.join(outputPath, project, name + '.' + extension);
const directory = path.dirname(file);
if (!fs.existsSync(directory)) {
@@ -293,9 +334,12 @@ export default ({
jsxImportSource: '@motion-canvas/core/lib',
},
build: {
lib: {
entry: `${project}?project`,
formats: ['es'],
assetsDir: './',
rollupOptions: {
preserveEntrySignatures: 'strict',
input: Object.fromEntries(
projects.map(project => [project.name, project.url + '?project']),
),
},
},
server: {