@lalalic/markcut 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +27 -0
- package/.github/user-steer.md +9 -0
- package/.vscode/settings.json +3 -0
- package/AGENTS.md +271 -0
- package/README.md +219 -0
- package/SKILL.md +209 -0
- package/docs/dynamic-components.md +191 -0
- package/docs/edit-mode.md +220 -0
- package/docs/json-descriptive.md +539 -0
- package/docs/label-mode.md +110 -0
- package/docs/markdown-descriptive.md +751 -0
- package/docs/templates.md +52 -0
- package/package.json +64 -0
- package/remotion.config.ts +5 -0
- package/scripts/artlist-dl.mjs +190 -0
- package/scripts/build-pipeline.sh +19 -0
- package/scripts/build-player.sh +20 -0
- package/src/Root.tsx +55 -0
- package/src/config.mjs +88 -0
- package/src/context/EventContext.tsx +168 -0
- package/src/context/index.tsx +42 -0
- package/src/descriptive/compiler.test.ts +1135 -0
- package/src/descriptive/compiler.ts +1230 -0
- package/src/descriptive/dsl.ts +455 -0
- package/src/descriptive/markdown.test.ts +866 -0
- package/src/descriptive/markdown.ts +674 -0
- package/src/descriptive/resolve.test.ts +951 -0
- package/src/descriptive/resolve.ts +891 -0
- package/src/entry.tsx +163 -0
- package/src/index.ts +4 -0
- package/src/player/browser.tsx +356 -0
- package/src/player/bundle/player.js +60259 -0
- package/src/player/bundler.mjs +269 -0
- package/src/player/label-server.mjs +599 -0
- package/src/player/pipeline.mjs +11123 -0
- package/src/player/pipeline.ts +117 -0
- package/src/player/server-shared.mjs +144 -0
- package/src/player/server.mjs +1006 -0
- package/src/render/cli-tools.ts +177 -0
- package/src/render/cli.mjs +628 -0
- package/src/schema/index.ts +259 -0
- package/src/types/Audio.tsx +56 -0
- package/src/types/Component.tsx +135 -0
- package/src/types/Effect.tsx +88 -0
- package/src/types/Folder.tsx +180 -0
- package/src/types/FrameSyncStyle.tsx +51 -0
- package/src/types/Image.tsx +51 -0
- package/src/types/Include.tsx +394 -0
- package/src/types/Map.tsx +252 -0
- package/src/types/Rhythm.tsx +58 -0
- package/src/types/Scene.tsx +42 -0
- package/src/types/Subtitle.tsx +218 -0
- package/src/types/Video.tsx +70 -0
- package/src/types/keyframes.ts +454 -0
- package/src/utils/__tests__/vtt.test.ts +129 -0
- package/src/utils/index.ts +168 -0
- package/src/utils/tween.ts +118 -0
- package/src/vision/cli.mjs +1187 -0
- package/src/vision/vision_prompts.md +67 -0
- package/tests/dsl.test.ts +317 -0
- package/tests/fixtures/audio.json +25 -0
- package/tests/fixtures/basic.json +27 -0
- package/tests/fixtures/component-all.json +38 -0
- package/tests/fixtures/components.json +38 -0
- package/tests/fixtures/effects.json +64 -0
- package/tests/fixtures/full.json +51 -0
- package/tests/fixtures/map.json +23 -0
- package/tests/fixtures/md/all-nodes.md +28 -0
- package/tests/fixtures/md/basic.md +6 -0
- package/tests/fixtures/md/component-imports.md +20 -0
- package/tests/fixtures/md/edge-cases.md +33 -0
- package/tests/fixtures/md/effects.md +17 -0
- package/tests/fixtures/md/frontmatter.md +20 -0
- package/tests/fixtures/md/full-feature.md +58 -0
- package/tests/fixtures/md/imports-block.md +19 -0
- package/tests/fixtures/md/include-main.md +11 -0
- package/tests/fixtures/md/include-sub.md +25 -0
- package/tests/fixtures/md/jsx-code-fence.md +21 -0
- package/tests/fixtures/md/map.md +11 -0
- package/tests/fixtures/md/nested-scenes.md +25 -0
- package/tests/fixtures/md/rhythm.md +17 -0
- package/tests/fixtures/md/scenes.md +16 -0
- package/tests/fixtures/md/tween.md +11 -0
- package/tests/fixtures/md/vars-test.md +6 -0
- package/tests/fixtures/scenes.json +40 -0
- package/tests/fixtures/subtitle.json +59 -0
- package/tests/fixtures/subvideo.json +59 -0
- package/tests/fixtures/templates/courseware.md +351 -0
- package/tests/fixtures/tween-visual.json +28 -0
- package/tests/fixtures/video-series.json +54 -0
- package/tests/md-descriptive.test.ts +742 -0
- package/tests/render.test.ts +985 -0
- package/tests/schema.test.ts +68 -0
- package/tests/server.test.ts +308 -0
- package/tests/utils.ts +391 -0
- package/tests/vitest.config.ts +18 -0
- package/tests/vitest.integration.config.ts +16 -0
- package/tsconfig.json +20 -0
package/src/entry.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { AbsoluteFill, continueRender, delayRender } from "remotion";
|
|
3
|
+
import { ComposeContext, EventProvider, type ComposeContextValue } from "./context/index";
|
|
4
|
+
|
|
5
|
+
import { FolderLeaf } from "./types/Folder";
|
|
6
|
+
import { SubtitleOverlay } from "./types/Subtitle";
|
|
7
|
+
import { getDurationInSeconds } from "./utils/index";
|
|
8
|
+
import { root as rootSchema, type Root } from "./schema/index";
|
|
9
|
+
import {
|
|
10
|
+
compileDescriptiveRoot,
|
|
11
|
+
type CompileOptions,
|
|
12
|
+
type DescriptiveRoot,
|
|
13
|
+
} from "./descriptive/compiler";
|
|
14
|
+
|
|
15
|
+
export interface MarkCutProps {
|
|
16
|
+
/** Stream tree. Will be parsed by zod (defaults applied). */
|
|
17
|
+
root: unknown;
|
|
18
|
+
/** Optional host-provided Container + components registry. */
|
|
19
|
+
compose?: Partial<ComposeContextValue>;
|
|
20
|
+
/** Background of the canvas. Defaults to black. */
|
|
21
|
+
background?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface DescriptiveCompositionProps extends Omit<MarkCutProps, "root"> {
|
|
25
|
+
root: DescriptiveRoot;
|
|
26
|
+
compileOptions?: CompileOptions;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const DefaultContainer: ComposeContextValue["Container"] = ({ children, style, className }) => (
|
|
30
|
+
<div className={className} style={{ position: "absolute", inset: 0, ...style }}>
|
|
31
|
+
{children}
|
|
32
|
+
</div>
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Load the component registry from root.imports.
|
|
37
|
+
* - If string: import it as an ESM module (pre-bundled by server)
|
|
38
|
+
* - If object: use it directly (programmatic API)
|
|
39
|
+
*/
|
|
40
|
+
function useComponentRegistry(imports: unknown): Record<string, React.ComponentType<any>> | null {
|
|
41
|
+
const [registry, setRegistry] = React.useState<Record<string, React.ComponentType<any>> | null>(null);
|
|
42
|
+
// delayRender() returns a numeric handle that continueRender() consumes.
|
|
43
|
+
const handleRef = React.useRef<number | null>(null);
|
|
44
|
+
|
|
45
|
+
React.useEffect(() => {
|
|
46
|
+
if (!imports) {
|
|
47
|
+
// No registry to load — stay null so ComposeContext falls back to
|
|
48
|
+
// `compose.components` (programmatic API). Returning `{}` here would
|
|
49
|
+
// shadow the host-provided registry with an empty object.
|
|
50
|
+
setRegistry(null);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (typeof imports === "object" && imports !== null && !Array.isArray(imports)) {
|
|
54
|
+
setRegistry(imports as Record<string, React.ComponentType<any>>);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (typeof imports === "string") {
|
|
58
|
+
if (!handleRef.current) {
|
|
59
|
+
handleRef.current = delayRender("Loading component registry: " + imports);
|
|
60
|
+
}
|
|
61
|
+
import(/* webpackIgnore: true */ imports)
|
|
62
|
+
.then((mod: any) => {
|
|
63
|
+
// The bundle exports all components as named exports
|
|
64
|
+
setRegistry(mod.default ?? mod);
|
|
65
|
+
if (handleRef.current) {
|
|
66
|
+
continueRender(handleRef.current);
|
|
67
|
+
handleRef.current = null;
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
.catch((err: Error) => {
|
|
71
|
+
console.error("Failed to load component registry:", err);
|
|
72
|
+
setRegistry(null);
|
|
73
|
+
if (handleRef.current) {
|
|
74
|
+
continueRender(handleRef.current);
|
|
75
|
+
handleRef.current = null;
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
setRegistry(null);
|
|
81
|
+
}, [imports]);
|
|
82
|
+
|
|
83
|
+
return registry;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function MarkCut({ root, compose, background = "#000" }: MarkCutProps) {
|
|
87
|
+
const parsed = React.useMemo<Root>(() => {
|
|
88
|
+
if (!root) {
|
|
89
|
+
// Return a minimal valid root when no data is provided (e.g. studio placeholder)
|
|
90
|
+
return {
|
|
91
|
+
id: "root",
|
|
92
|
+
type: "root",
|
|
93
|
+
width: 1080,
|
|
94
|
+
height: 1920,
|
|
95
|
+
fps: 30,
|
|
96
|
+
visible: true,
|
|
97
|
+
isSeries: false,
|
|
98
|
+
children: [],
|
|
99
|
+
durationInSeconds: 0.1,
|
|
100
|
+
} as unknown as Root;
|
|
101
|
+
}
|
|
102
|
+
return rootSchema.parse(root) as unknown as Root;
|
|
103
|
+
}, [root]);
|
|
104
|
+
|
|
105
|
+
// Load component registry (bundle URL or inline map)
|
|
106
|
+
const registry = useComponentRegistry(parsed.imports);
|
|
107
|
+
|
|
108
|
+
// engine pre-pass: stamp durationInSeconds onto every node
|
|
109
|
+
React.useMemo(() => getDurationInSeconds(parsed as any, true), [parsed]);
|
|
110
|
+
|
|
111
|
+
const value = React.useMemo<ComposeContextValue>(
|
|
112
|
+
() => ({
|
|
113
|
+
Container: compose?.Container ?? DefaultContainer,
|
|
114
|
+
onError: compose?.onError,
|
|
115
|
+
components: registry ?? compose?.components,
|
|
116
|
+
}),
|
|
117
|
+
[compose, registry],
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
return (
|
|
121
|
+
<ComposeContext.Provider value={value}>
|
|
122
|
+
<EventProvider>
|
|
123
|
+
<AbsoluteFill style={{ background }}>
|
|
124
|
+
{parsed.stylesheet && <style>{parsed.stylesheet}</style>}
|
|
125
|
+
<FolderLeaf stream={parsed as any} />
|
|
126
|
+
{parsed.subtitle && <SubtitleOverlay subtitle={parsed.subtitle} />}
|
|
127
|
+
</AbsoluteFill>
|
|
128
|
+
</EventProvider>
|
|
129
|
+
</ComposeContext.Provider>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function DescriptiveComposition({
|
|
134
|
+
root,
|
|
135
|
+
compose,
|
|
136
|
+
background = "#000",
|
|
137
|
+
compileOptions,
|
|
138
|
+
}: DescriptiveCompositionProps) {
|
|
139
|
+
const compiled = React.useMemo(
|
|
140
|
+
() => compileDescriptiveRoot(root, compileOptions),
|
|
141
|
+
[root, compileOptions],
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return (
|
|
145
|
+
<MarkCut
|
|
146
|
+
root={compiled}
|
|
147
|
+
compose={compose}
|
|
148
|
+
background={background}
|
|
149
|
+
/>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export { rootSchema, FolderLeaf };
|
|
154
|
+
export * from "./schema/index";
|
|
155
|
+
export * from "./context/index";
|
|
156
|
+
export * from "./descriptive/compiler";
|
|
157
|
+
export * from "./descriptive/markdown";
|
|
158
|
+
export { getDurationInSeconds } from "./utils/index";
|
|
159
|
+
export { builtinAnimations } from "./types/keyframes";
|
|
160
|
+
// Resolve functions (TTS, STT, media probe) are available via
|
|
161
|
+
// import from "markcut/descriptive-resolve" for CLI use.
|
|
162
|
+
// Not re-exported from entry to avoid bundling Node.js modules
|
|
163
|
+
// (node:child_process, node:fs, etc.) into the browser-side render bundle.
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser player entry point.
|
|
3
|
+
* Bundled with esbuild and served by the player server.
|
|
4
|
+
* Renders stream tree JSON using @remotion/player with MarkCut.
|
|
5
|
+
*/
|
|
6
|
+
import * as React from "react";
|
|
7
|
+
import { createRoot } from "react-dom/client";
|
|
8
|
+
import * as ReactDOM from "react-dom";
|
|
9
|
+
import * as Remotion from "remotion";
|
|
10
|
+
import { Player } from "@remotion/player";
|
|
11
|
+
import { MarkCut, getDurationInSeconds } from "../entry";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Register all player-bundled packages on a global registry so the import map
|
|
15
|
+
* shim (below) can re-export them to dynamically-loaded component bundles.
|
|
16
|
+
*
|
|
17
|
+
* Any package added here must also be listed as --external in bundler.mjs's
|
|
18
|
+
* `getSharedExternals()` so esbuild leaves its import specifier as a bare
|
|
19
|
+
* import in the component bundle output. The import map then resolves that
|
|
20
|
+
* bare import back to this same module instance, avoiding duplicate React,
|
|
21
|
+
* Remotion, or other runtime singleton issues.
|
|
22
|
+
*/
|
|
23
|
+
if (typeof window !== "undefined") {
|
|
24
|
+
(globalThis as any).__remotionShared = {
|
|
25
|
+
"react": React,
|
|
26
|
+
"react-dom": ReactDOM,
|
|
27
|
+
"remotion": Remotion,
|
|
28
|
+
"@remotion/player": { Player },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
// Stub for node:module — some component deps (e.g. @remotion/tailwind-v4)
|
|
32
|
+
// import from node:module at module level for build-time helpers that are
|
|
33
|
+
// never actually called during rendering. Provide a harmless stub so the
|
|
34
|
+
// import doesn't fail in the browser.
|
|
35
|
+
if (!(globalThis as any).__nodeModuleStub) {
|
|
36
|
+
const nodeModuleStubCode = `export function createRequire() { return () => ({ resolve: () => { throw new Error("node:module.createRequire is not available in the browser"); } }); }`;
|
|
37
|
+
const nodeModuleBlob = new Blob([nodeModuleStubCode], { type: "application/javascript" });
|
|
38
|
+
(globalThis as any).__nodeModuleStub = URL.createObjectURL(nodeModuleBlob);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Generate import map shim dynamically from the shared registry.
|
|
42
|
+
// Each entry creates a blob URL that re-exports everything from the global,
|
|
43
|
+
// so component bundles can import("react"), import("remotion"), etc. and
|
|
44
|
+
// receive the same instances already loaded in the main player bundle.
|
|
45
|
+
if (!document.querySelector('#rmtr-import-map')) {
|
|
46
|
+
try {
|
|
47
|
+
const imports: Record<string, string> = {};
|
|
48
|
+
// Add node:* stubs
|
|
49
|
+
imports["node:module"] = (globalThis as any).__nodeModuleStub;
|
|
50
|
+
imports["node:fs"] = (globalThis as any).__nodeModuleStub;
|
|
51
|
+
imports["node:path"] = (globalThis as any).__nodeModuleStub;
|
|
52
|
+
imports["node:os"] = (globalThis as any).__nodeModuleStub;
|
|
53
|
+
const shared = (globalThis as any).__remotionShared;
|
|
54
|
+
|
|
55
|
+
for (const [specifier, mod] of Object.entries(shared) as [string, any][]) {
|
|
56
|
+
const exportNames = Object.keys(mod);
|
|
57
|
+
const hasDefault = 'default' in mod;
|
|
58
|
+
const lines: string[] = [
|
|
59
|
+
`const _mod = globalThis.__remotionShared[${JSON.stringify(specifier)}];`,
|
|
60
|
+
];
|
|
61
|
+
for (const name of exportNames) {
|
|
62
|
+
if (name === 'default') continue;
|
|
63
|
+
lines.push(
|
|
64
|
+
`const __${name} = _mod[${JSON.stringify(name)}];`,
|
|
65
|
+
`export { __${name} as ${name} };`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (hasDefault) {
|
|
69
|
+
lines.push('export default _mod.default;');
|
|
70
|
+
}
|
|
71
|
+
const blob = new Blob([lines.join('\n')], { type: "application/javascript" });
|
|
72
|
+
imports[specifier] = URL.createObjectURL(blob);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// react/jsx-runtime — built manually because it's not a package import
|
|
76
|
+
// but rather a sub-path of react that React.createElement handles directly.
|
|
77
|
+
const blobJsx = new Blob([`
|
|
78
|
+
const R = globalThis.__remotionShared["react"];
|
|
79
|
+
const { createElement, Fragment } = R;
|
|
80
|
+
export { Fragment };
|
|
81
|
+
export function jsx(type, props, key) {
|
|
82
|
+
return createElement(type, key != null ? { ...props, key } : props);
|
|
83
|
+
}
|
|
84
|
+
export function jsxs(type, props, key) {
|
|
85
|
+
return createElement(type, key != null ? { ...props, key } : props);
|
|
86
|
+
}
|
|
87
|
+
export function jsxDEV(type, props, key, isStaticChildren, source, self) {
|
|
88
|
+
return createElement(type, key != null ? { ...props, key } : props);
|
|
89
|
+
}
|
|
90
|
+
`], { type: "application/javascript" });
|
|
91
|
+
imports["react/jsx-runtime"] = URL.createObjectURL(blobJsx);
|
|
92
|
+
|
|
93
|
+
const script = document.createElement("script");
|
|
94
|
+
script.type = "importmap";
|
|
95
|
+
script.id = "rmtr-import-map";
|
|
96
|
+
script.textContent = JSON.stringify({ imports });
|
|
97
|
+
document.head.appendChild(script);
|
|
98
|
+
} catch (e) {
|
|
99
|
+
console.warn("Failed to create shared module import map:", e);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function PlayerApp() {
|
|
105
|
+
const playerRef = React.useRef<any>(null);
|
|
106
|
+
const [ready, setReady] = React.useState(false);
|
|
107
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
108
|
+
const [data, setData] = React.useState<any>(null);
|
|
109
|
+
const [refreshKey, setRefreshKey] = React.useState(0);
|
|
110
|
+
const [muted, setMuted] = React.useState(false);
|
|
111
|
+
const [volume, setVolume] = React.useState(1);
|
|
112
|
+
const seekAttemptedRef = React.useRef(false);
|
|
113
|
+
const mountedRef = React.useRef(true);
|
|
114
|
+
|
|
115
|
+
// Parse URL params
|
|
116
|
+
const urlParams = new URLSearchParams(typeof window !== "undefined" ? window.location.search : "");
|
|
117
|
+
const autoPlay = urlParams.get("autoplay") === "true";
|
|
118
|
+
const startAt = parseFloat(urlParams.get("start") || urlParams.get("t") || "0") || 0;
|
|
119
|
+
|
|
120
|
+
// Derive player config from data early so effects can reference them safely.
|
|
121
|
+
// These are computed on every render but only meaningful when data is set.
|
|
122
|
+
const fps = data?.fps ?? 30;
|
|
123
|
+
const durationInSeconds = data ? (getDurationInSeconds(data, true) || 5) : 5;
|
|
124
|
+
const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
|
|
125
|
+
|
|
126
|
+
const loadData = React.useCallback(() => {
|
|
127
|
+
setReady(false);
|
|
128
|
+
const variant = (window as any).VARIANT || "default";
|
|
129
|
+
const url = variant !== "default" ? `/api/video-data?variant=${variant}` : "/api/video-data";
|
|
130
|
+
fetch(url)
|
|
131
|
+
.then((r) => r.json())
|
|
132
|
+
.then((json) => {
|
|
133
|
+
const root = json.root || json;
|
|
134
|
+
setData(root);
|
|
135
|
+
setReady(true);
|
|
136
|
+
})
|
|
137
|
+
.catch((e) => setError(e.message));
|
|
138
|
+
}, []);
|
|
139
|
+
|
|
140
|
+
React.useEffect(() => {
|
|
141
|
+
loadData();
|
|
142
|
+
const handler = () => { setRefreshKey(k => k + 1); };
|
|
143
|
+
window.addEventListener("refresh-player", handler);
|
|
144
|
+
return () => { mountedRef.current = false; window.removeEventListener("refresh-player", handler); };
|
|
145
|
+
}, [loadData]);
|
|
146
|
+
|
|
147
|
+
React.useEffect(() => {
|
|
148
|
+
if (refreshKey > 0) loadData();
|
|
149
|
+
}, [refreshKey, loadData]);
|
|
150
|
+
|
|
151
|
+
// Seek to startAt once player is mounted
|
|
152
|
+
React.useEffect(() => {
|
|
153
|
+
if (!ready || !data || seekAttemptedRef.current) return;
|
|
154
|
+
if (startAt > 0 && playerRef.current) {
|
|
155
|
+
// Small delay to let the player fully initialize
|
|
156
|
+
const timer = setTimeout(() => {
|
|
157
|
+
if (!mountedRef.current || !playerRef.current) return;
|
|
158
|
+
const frame = Math.round(startAt * fps);
|
|
159
|
+
playerRef.current.seekTo(frame);
|
|
160
|
+
seekAttemptedRef.current = true;
|
|
161
|
+
}, 100);
|
|
162
|
+
return () => clearTimeout(timer);
|
|
163
|
+
}
|
|
164
|
+
seekAttemptedRef.current = true;
|
|
165
|
+
}, [ready, data, startAt, fps]);
|
|
166
|
+
|
|
167
|
+
// Keyboard shortcuts
|
|
168
|
+
React.useEffect(() => {
|
|
169
|
+
if (!ready || !playerRef.current) return;
|
|
170
|
+
|
|
171
|
+
const FWD_SECONDS = 5;
|
|
172
|
+
const BACK_SECONDS = 5;
|
|
173
|
+
const VOLUME_STEP = 0.1;
|
|
174
|
+
|
|
175
|
+
function seekRelative(deltaSec: number) {
|
|
176
|
+
const p = playerRef.current;
|
|
177
|
+
if (!p) return;
|
|
178
|
+
const frame = p.getCurrentFrame() + Math.round(deltaSec * fps);
|
|
179
|
+
p.seekTo(Math.max(0, frame));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function seekPercent(pct: number) {
|
|
183
|
+
const p = playerRef.current;
|
|
184
|
+
if (!p) return;
|
|
185
|
+
p.seekTo(Math.round(pct * durationInFrames));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function showHelp() {
|
|
189
|
+
// eslint-disable-next-line no-console
|
|
190
|
+
console.log(`%c🎬 MarkCut Player Shortcuts
|
|
191
|
+
━━━━━━━━━━━━━━━━━━━━━
|
|
192
|
+
Space / K Play / Pause
|
|
193
|
+
← / → Seek -${BACK_SECONDS}s / +${FWD_SECONDS}s
|
|
194
|
+
Shift+←/→ Seek -1 / +1 frame
|
|
195
|
+
J Rewind 2×
|
|
196
|
+
L Forward 2×
|
|
197
|
+
↑ / ↓ Volume +${Math.round(VOLUME_STEP * 100)}% / -${Math.round(VOLUME_STEP * 100)}%
|
|
198
|
+
M Mute toggle
|
|
199
|
+
F Fullscreen toggle
|
|
200
|
+
0-9 Seek to 0%-90%
|
|
201
|
+
? Show this help`, "font-size:14px;");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function onKey(e: KeyboardEvent) {
|
|
205
|
+
// Ignore if focus is inside a form element
|
|
206
|
+
const tag = (e.target as HTMLElement).tagName;
|
|
207
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return;
|
|
208
|
+
|
|
209
|
+
const p = playerRef.current;
|
|
210
|
+
if (!p) return;
|
|
211
|
+
|
|
212
|
+
switch (e.key) {
|
|
213
|
+
case " ":
|
|
214
|
+
case "k":
|
|
215
|
+
case "K":
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
p.toggle();
|
|
218
|
+
break;
|
|
219
|
+
|
|
220
|
+
case "ArrowLeft":
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
seekRelative(e.shiftKey ? -1 / fps : -BACK_SECONDS);
|
|
223
|
+
break;
|
|
224
|
+
|
|
225
|
+
case "ArrowRight":
|
|
226
|
+
e.preventDefault();
|
|
227
|
+
seekRelative(e.shiftKey ? 1 / fps : FWD_SECONDS);
|
|
228
|
+
break;
|
|
229
|
+
|
|
230
|
+
case "ArrowUp":
|
|
231
|
+
e.preventDefault();
|
|
232
|
+
setVolume(v => {
|
|
233
|
+
const nv = Math.min(1, v + VOLUME_STEP);
|
|
234
|
+
if (typeof p.setVolume === "function") p.setVolume(nv);
|
|
235
|
+
return nv;
|
|
236
|
+
});
|
|
237
|
+
break;
|
|
238
|
+
|
|
239
|
+
case "ArrowDown":
|
|
240
|
+
e.preventDefault();
|
|
241
|
+
setVolume(v => {
|
|
242
|
+
const nv = Math.max(0, v - VOLUME_STEP);
|
|
243
|
+
if (typeof p.setVolume === "function") p.setVolume(nv);
|
|
244
|
+
return nv;
|
|
245
|
+
});
|
|
246
|
+
break;
|
|
247
|
+
|
|
248
|
+
case "m":
|
|
249
|
+
case "M":
|
|
250
|
+
e.preventDefault();
|
|
251
|
+
setMuted(m => {
|
|
252
|
+
const nm = !m;
|
|
253
|
+
if (typeof p.setVolume === "function") p.setVolume(nm ? 0 : volume);
|
|
254
|
+
return nm;
|
|
255
|
+
});
|
|
256
|
+
break;
|
|
257
|
+
|
|
258
|
+
case "f":
|
|
259
|
+
case "F":
|
|
260
|
+
e.preventDefault();
|
|
261
|
+
if (typeof p.requestFullscreen === "function") {
|
|
262
|
+
p.requestFullscreen();
|
|
263
|
+
} else {
|
|
264
|
+
document.fullscreenElement
|
|
265
|
+
? document.exitFullscreen()
|
|
266
|
+
: document.documentElement.requestFullscreen();
|
|
267
|
+
}
|
|
268
|
+
break;
|
|
269
|
+
|
|
270
|
+
case "j":
|
|
271
|
+
case "J":
|
|
272
|
+
e.preventDefault();
|
|
273
|
+
// Toggle between normal and 2× reverse
|
|
274
|
+
p.playbackRate = p.playbackRate === -2 ? 1 : -2;
|
|
275
|
+
break;
|
|
276
|
+
|
|
277
|
+
case "l":
|
|
278
|
+
case "L":
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
// Toggle between normal and 2× forward
|
|
281
|
+
p.playbackRate = p.playbackRate === 2 ? 1 : 2;
|
|
282
|
+
break;
|
|
283
|
+
|
|
284
|
+
case "/":
|
|
285
|
+
case "?":
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
showHelp();
|
|
288
|
+
break;
|
|
289
|
+
|
|
290
|
+
default:
|
|
291
|
+
if (e.key >= "0" && e.key <= "9") {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
seekPercent(parseInt(e.key) / 10);
|
|
294
|
+
}
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
window.addEventListener("keydown", onKey);
|
|
300
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
301
|
+
}, [ready, data, fps, durationInFrames, volume]);
|
|
302
|
+
|
|
303
|
+
// Expose seek API for external scripts
|
|
304
|
+
React.useEffect(() => {
|
|
305
|
+
if (!data) return;
|
|
306
|
+
(window as any).__remotionSeekTo = (timeInSeconds: number) => {
|
|
307
|
+
const frame = Math.round(timeInSeconds * fps);
|
|
308
|
+
playerRef.current?.seekTo(frame);
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
if (error) {
|
|
313
|
+
return React.createElement("div", {
|
|
314
|
+
style: { color: "red", padding: 40, fontFamily: "sans-serif" },
|
|
315
|
+
}, "Error: " + error);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (!ready) {
|
|
319
|
+
return React.createElement("div", {
|
|
320
|
+
style: { color: "#888", padding: 40, fontFamily: "sans-serif" },
|
|
321
|
+
}, "Loading...");
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const width = data.width || 1080;
|
|
325
|
+
const height = data.height || 1920;
|
|
326
|
+
|
|
327
|
+
return React.createElement("div", {
|
|
328
|
+
style: { width: "100%", height: "100%", background: "#000" },
|
|
329
|
+
},
|
|
330
|
+
React.createElement(Player, {
|
|
331
|
+
ref: playerRef,
|
|
332
|
+
component: MarkCut,
|
|
333
|
+
inputProps: {
|
|
334
|
+
root: data,
|
|
335
|
+
compose: {},
|
|
336
|
+
},
|
|
337
|
+
durationInFrames,
|
|
338
|
+
fps,
|
|
339
|
+
compositionWidth: width,
|
|
340
|
+
compositionHeight: height,
|
|
341
|
+
style: { width: "100%", height: "100%" },
|
|
342
|
+
controls: true,
|
|
343
|
+
showPlaybackRateControl: true,
|
|
344
|
+
allowFullscreen: true,
|
|
345
|
+
clickToPlay: false,
|
|
346
|
+
doubleClickToFullscreen: true,
|
|
347
|
+
autoPlay: autoPlay,
|
|
348
|
+
})
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const container = document.getElementById("root");
|
|
353
|
+
if (container) {
|
|
354
|
+
const root = createRoot(container);
|
|
355
|
+
root.render(React.createElement(PlayerApp));
|
|
356
|
+
}
|