mirror of
https://github.com/motion-canvas/motion-canvas.git
synced 2026-01-12 15:28:03 -05:00
feat: configurable framerate and resolution
This commit is contained in:
17
src/components/controls/Button.tsx
Normal file
17
src/components/controls/Button.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import styles from './Controls.module.scss';
|
||||
import type {JSX} from 'preact';
|
||||
import {classes} from '../../utils';
|
||||
|
||||
interface ButtonProps extends JSX.HTMLAttributes<HTMLButtonElement> {
|
||||
main?: boolean;
|
||||
}
|
||||
|
||||
export function Button(props: ButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classes(styles.button, [styles.main, props.main])}
|
||||
type={'button'}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -22,17 +22,20 @@
|
||||
height: 24px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
opacity: 0.54;
|
||||
&.main {
|
||||
opacity: 1;
|
||||
|
||||
&::after {
|
||||
background-color: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
.iconInput + &:hover {
|
||||
opacity: 1 !important;
|
||||
&.main::after {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.iconInput + &:hover::after {
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.iconInput:checked + & {
|
||||
opacity: 1;
|
||||
&:after {
|
||||
background: var(--theme);
|
||||
}
|
||||
@@ -42,31 +45,82 @@
|
||||
}
|
||||
}
|
||||
|
||||
.button {
|
||||
.iconButton {
|
||||
-webkit-appearance: none;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
opacity: 0.54;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
&::after {
|
||||
background-color: rgba(255, 255, 255, 0.54);
|
||||
}
|
||||
|
||||
&:hover::after {
|
||||
background-color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
flex-basis: 40%;
|
||||
flex-grow: 0;
|
||||
flex-shrink: 0;
|
||||
text-align: right;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.select,
|
||||
.input {
|
||||
.input,
|
||||
.button {
|
||||
background: #404040;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
padding: 0 8px;
|
||||
font-size: 14px;
|
||||
height: 24px;
|
||||
flex-shrink: 1;
|
||||
flex-grow: 1;
|
||||
flex-basis: 100%;
|
||||
min-width: 0;
|
||||
|
||||
&:hover {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
&.main {
|
||||
background-color: var(--theme);
|
||||
color: rgba(0, 0, 0, 0.87);
|
||||
font-weight: 700;
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 0 0 2px white inset;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.input[type='number'] {
|
||||
text-align: right;
|
||||
&::-webkit-inner-spin-button,
|
||||
&::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.select,
|
||||
.button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pause:after {
|
||||
|
||||
12
src/components/controls/Group.tsx
Normal file
12
src/components/controls/Group.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import styles from './Controls.module.scss';
|
||||
import type {JSX} from 'preact';
|
||||
|
||||
interface GroupProps extends JSX.HTMLAttributes<HTMLDivElement> {}
|
||||
|
||||
export function Group(props: GroupProps) {
|
||||
return (
|
||||
<div className={styles.group} {...props}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,7 @@ interface IconButtonProps {
|
||||
export function IconButton({icon, onClick}: IconButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={classes(styles.button, styles.icon, styles[icon])}
|
||||
className={classes(styles.iconButton, styles.icon, styles[icon])}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import styles from './Controls.module.scss';
|
||||
|
||||
import type {JSX} from 'preact';
|
||||
|
||||
interface InputProps extends JSX.HTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
8
src/components/controls/Label.tsx
Normal file
8
src/components/controls/Label.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import styles from './Controls.module.scss';
|
||||
import type {JSX} from 'preact';
|
||||
|
||||
interface LabelProps extends JSX.HTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
export function Label(props: LabelProps) {
|
||||
return <label className={styles.label} {...props} />;
|
||||
}
|
||||
@@ -1,4 +1,9 @@
|
||||
export * from "./Button";
|
||||
export * from "./Group";
|
||||
export * from "./Input";
|
||||
export * from "./Icon";
|
||||
export * from './IconButton';
|
||||
export * from './IconCheckbox';
|
||||
export * from "./IconType";
|
||||
export * from "./IconType";
|
||||
export * from "./Label";
|
||||
export * from "./Select";
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
|
||||
&.vertical {
|
||||
flex-direction: column;
|
||||
|
||||
> .left {
|
||||
max-height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.vertical) {
|
||||
> .left {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ export function ResizeableLayout({
|
||||
start,
|
||||
end,
|
||||
vertical = false,
|
||||
size = 540,
|
||||
size = 0.3,
|
||||
id = null,
|
||||
resizeable = true,
|
||||
}: ResizeableLayoutProps) {
|
||||
@@ -29,7 +29,9 @@ export function ResizeableLayout({
|
||||
useCallback(
|
||||
(dx, dy, x, y) => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setSize(vertical ? y - rect.y : x - rect.x);
|
||||
setSize(
|
||||
vertical ? (y - rect.y) / rect.height : (x - rect.x) / rect.width,
|
||||
);
|
||||
},
|
||||
[vertical, setSize],
|
||||
),
|
||||
@@ -38,19 +40,20 @@ export function ResizeableLayout({
|
||||
const style = useMemo<JSX.CSSProperties>(() => {
|
||||
if (!resizeable) return {};
|
||||
return vertical
|
||||
? {height: `${currentSize}px`}
|
||||
: {width: `${currentSize}px`};
|
||||
? {height: `${currentSize * 100}%`}
|
||||
: {width: `${currentSize * 100}%`};
|
||||
}, [currentSize, vertical, resizeable]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classes(
|
||||
styles.root,
|
||||
[styles.vertical, vertical],
|
||||
[styles.resizeable, resizeable],
|
||||
)}
|
||||
>
|
||||
<div ref={containerRef} className={styles.left} style={style}>
|
||||
<div className={styles.left} style={style}>
|
||||
{start}
|
||||
</div>
|
||||
<div onMouseDown={handleDrag} className={styles.separator} />
|
||||
|
||||
@@ -12,4 +12,9 @@
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.54;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ import styles from './Playback.module.scss';
|
||||
|
||||
import {IconType, IconButton, IconCheckbox} from '../controls';
|
||||
import {useDocumentEvent, usePlayer, usePlayerState} from '../../hooks';
|
||||
import {Select} from '../controls/Select';
|
||||
import {Input} from '../controls/Input';
|
||||
import {Select, Input} from '../controls';
|
||||
import {Framerate} from './Framerate';
|
||||
import {useCallback} from 'preact/hooks';
|
||||
import {classes} from '../../utils';
|
||||
|
||||
export function PlaybackControls() {
|
||||
const player = usePlayer();
|
||||
@@ -26,7 +26,7 @@ export function PlaybackControls() {
|
||||
player.requestNextFrame();
|
||||
break;
|
||||
case 'm':
|
||||
player.updateState({muted: !state.muted});
|
||||
player.toggleAudio();
|
||||
break;
|
||||
case 'l':
|
||||
player.updateState({loop: !state.loop});
|
||||
@@ -38,7 +38,7 @@ export function PlaybackControls() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.controls}>
|
||||
<div className={classes(styles.controls, [styles.disabled, state.render])}>
|
||||
<Select
|
||||
options={[
|
||||
{value: 0.25, text: 'x0.25'},
|
||||
|
||||
@@ -4,9 +4,10 @@ import type {PlayerRenderEvent} from '@motion-canvas/core/player/Player';
|
||||
import {IconType} from '../controls';
|
||||
import {Tabs} from '../tabs/Tabs';
|
||||
import {usePlayer, usePlayerState} from '../../hooks';
|
||||
import {useCallback, useEffect, useState} from 'preact/hooks';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'preact/hooks';
|
||||
import {Thread} from '@motion-canvas/core/threading';
|
||||
import {GeneratorHelper} from '@motion-canvas/core/helpers';
|
||||
import {Button, Label, Input, Group, Select} from '../controls';
|
||||
|
||||
interface SidebarProps {
|
||||
setOpen?: (value: boolean) => any;
|
||||
@@ -17,7 +18,7 @@ export function Sidebar({setOpen}: SidebarProps) {
|
||||
<Tabs onToggle={tab => setOpen(tab >= 0)} id="sidebar">
|
||||
{{
|
||||
icon: IconType.tune,
|
||||
pane: <div className={styles.pane}>Settings</div>,
|
||||
pane: <Properties />,
|
||||
}}
|
||||
{{
|
||||
icon: IconType.videoSettings,
|
||||
@@ -31,9 +32,26 @@ export function Sidebar({setOpen}: SidebarProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function Properties() {
|
||||
return (
|
||||
<div className={styles.pane}>
|
||||
<div className={styles.header}>Properties</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Rendering() {
|
||||
const player = usePlayer();
|
||||
const state = usePlayerState();
|
||||
const {width, height} = player.project.getSize();
|
||||
|
||||
const resolutions = useMemo(() => {
|
||||
return [
|
||||
{value: 0.5, text: `${width / 2}x${height / 2} (Half)`},
|
||||
{value: 1, text: `${width}x${height} (Full)`},
|
||||
{value: 2, text: `${width * 2}x${height * 2} (Double)`},
|
||||
];
|
||||
}, [width, height]);
|
||||
|
||||
const handleRender = useCallback(async ({frame, data}: PlayerRenderEvent) => {
|
||||
try {
|
||||
@@ -56,9 +74,57 @@ function Rendering() {
|
||||
return (
|
||||
<div className={styles.pane}>
|
||||
<div className={styles.header}>Rendering</div>
|
||||
<button onClick={() => player.toggleRendering()}>
|
||||
{state.render ? 'RENDERING...' : 'RENDER'}
|
||||
</button>
|
||||
<Group>
|
||||
<Label>Range</Label>
|
||||
<Input
|
||||
min={0}
|
||||
max={state.endFrame}
|
||||
type={'number'}
|
||||
value={state.startFrame}
|
||||
onChange={event => {
|
||||
player.updateState({
|
||||
startFrame: parseInt((event.target as HTMLInputElement).value),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
min={state.startFrame}
|
||||
max={state.duration}
|
||||
type={'number'}
|
||||
value={Math.min(state.duration, state.endFrame)}
|
||||
onChange={event => {
|
||||
const value = parseInt((event.target as HTMLInputElement).value);
|
||||
player.updateState({
|
||||
endFrame: value >= state.duration ? Infinity : value,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<Label>FPS</Label>
|
||||
<Select
|
||||
options={[
|
||||
{value: 30, text: '30 FPS'},
|
||||
{value: 60, text: '60 FPS'},
|
||||
]}
|
||||
value={state.fps}
|
||||
onChange={value => player.setFramerate(value)}
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<Label>Resolution</Label>
|
||||
<Select
|
||||
options={resolutions}
|
||||
value={state.scale}
|
||||
onChange={value => player.setScale(value)}
|
||||
/>
|
||||
</Group>
|
||||
<Group>
|
||||
<Label />
|
||||
<Button main onClick={() => player.toggleRendering()}>
|
||||
{state.render ? 'STOP RENDERING' : 'RENDER'}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,12 +14,17 @@ export function RangeTrack() {
|
||||
const [end, setEnd] = useState(state.endFrame);
|
||||
|
||||
const onDrop = useCallback(() => {
|
||||
player.updateState({
|
||||
startFrame: Math.max(0, Math.floor(start)),
|
||||
endFrame: end >= state.duration
|
||||
let startFrame = Math.max(0, Math.floor(start));
|
||||
let endFrame =
|
||||
end >= state.duration
|
||||
? Infinity
|
||||
: Math.min(state.duration, Math.floor(end)),
|
||||
});
|
||||
: Math.min(state.duration, Math.floor(end));
|
||||
|
||||
if (startFrame > endFrame) {
|
||||
[startFrame, endFrame] = [endFrame, startFrame];
|
||||
}
|
||||
|
||||
player.updateState({startFrame, endFrame});
|
||||
}, [start, end, state.duration]);
|
||||
|
||||
const [handleDragStart] = useDrag(
|
||||
@@ -27,7 +32,7 @@ export function RangeTrack() {
|
||||
dx => {
|
||||
setStart(start + (dx / fullLength) * state.duration);
|
||||
},
|
||||
[start, setStart, fullLength, state.duration],
|
||||
[start, fullLength, state.duration],
|
||||
),
|
||||
onDrop,
|
||||
);
|
||||
@@ -39,7 +44,7 @@ export function RangeTrack() {
|
||||
Math.min(state.duration, end) + (dx / fullLength) * state.duration,
|
||||
);
|
||||
},
|
||||
[end, setEnd, fullLength, state.duration],
|
||||
[end, fullLength, state.duration],
|
||||
),
|
||||
onDrop,
|
||||
);
|
||||
@@ -52,7 +57,7 @@ export function RangeTrack() {
|
||||
Math.min(state.duration, end) + (dx / fullLength) * state.duration,
|
||||
);
|
||||
},
|
||||
[start, end, fullLength, state.duration, setStart, setEnd],
|
||||
[start, end, fullLength, state.duration],
|
||||
),
|
||||
onDrop,
|
||||
);
|
||||
@@ -62,11 +67,19 @@ export function RangeTrack() {
|
||||
setEnd(state.endFrame);
|
||||
}, [state.startFrame, state.endFrame]);
|
||||
|
||||
let normalizedStart = start;
|
||||
let normalizedEnd = end;
|
||||
if (start > end) {
|
||||
normalizedStart = end;
|
||||
normalizedEnd = start;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
left: `${(Math.max(0, start) / state.duration) * 100}%`,
|
||||
right: `${100 - Math.min(1, (end + 1) / state.duration) * 100}%`,
|
||||
flexDirection: start > end ? 'row-reverse' : 'row',
|
||||
left: `${(Math.max(0, normalizedStart) / state.duration) * 100}%`,
|
||||
right: `${100 - Math.min(1, (normalizedEnd + 1) / state.duration) * 100}%`,
|
||||
}}
|
||||
className={styles.range}
|
||||
onMouseDown={event => {
|
||||
|
||||
@@ -216,12 +216,17 @@
|
||||
border-bottom: 1px solid var(--theme);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.handle {
|
||||
cursor: pointer;
|
||||
padding: 8px 2px;
|
||||
|
||||
&:first-child {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&:after {
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user