@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
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Video Templates
|
|
2
|
+
|
|
3
|
+
Ready-to-use markdown templates for common video scenarios. Each template:
|
|
4
|
+
|
|
5
|
+
- Contains **YAML frontmatter** with usage guidance (when, how, key decisions)
|
|
6
|
+
- Lists **required external packages** (npm imports)
|
|
7
|
+
- Uses **src:auto prompt:"..."** placeholders for AI-generated images/videos
|
|
8
|
+
- Includes **TTS narration** with configured voice and pacing
|
|
9
|
+
- Provides **scene structure** with named scenes, layouts, and durations
|
|
10
|
+
|
|
11
|
+
## Quick Start
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
# 1. Pick a template
|
|
15
|
+
cp docs/templates/courseware.md my-video.md
|
|
16
|
+
|
|
17
|
+
# 2. Fill in [bracketed] placeholders with actual content
|
|
18
|
+
# 3. Render
|
|
19
|
+
npx markcut render my-video.md --aspect 16x9
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Template Catalog
|
|
23
|
+
|
|
24
|
+
| Template | Scenario | Duration | Key Components |
|
|
25
|
+
|---|---|---|---|
|
|
26
|
+
| [courseware.md](templates/courseware.md) | 课件 / Lessons | 1-5 min | MarpSlide (markdown→slides), SlideText, MathDisplay |
|
|
27
|
+
| [product-ad.md](templates/product-ad.md) | 产品广告 / Ads | 15-45s | TextReveal, DeviceMockup, QRCode, FeatureCard |
|
|
28
|
+
| [movie-review.md](templates/movie-review.md) | 影视讲解 / Reviews | 1-3 min | CinematicText, StarRating, TextAnnotation, SplitScreen |
|
|
29
|
+
| [audiobook.md](templates/audiobook.md) | 有声图书 / Audiobooks | 3-30 min | BookCover, AmbientBackground, ProgressBar |
|
|
30
|
+
| [story-video.md](templates/story-video.md) | 故事视频 / Stories | 1-5 min | StoryTitle, SpeechBubble, ParticleEffect |
|
|
31
|
+
| [travel-log.md](templates/travel-log.md) | 旅行日志 / Travel | 1-3 min | LocationCard, WeatherIcon, TravelStats, Map node |}
|
|
32
|
+
|
|
33
|
+
## Frontmatter Fields Used by Templates
|
|
34
|
+
|
|
35
|
+
| Field | Purpose | Example |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| `tts` | TTS CLI command with `{input}` `{output}` | `edge-tts --voice "zh-CN-XiaoxiaoNeural" --text "{input}" --write-media "{output}"` |
|
|
38
|
+
| `stt` | STT CLI command with `{input}` `{output}` | `whisper "{input}" --model tiny --language zh --output_format vtt --output_dir "{output}"` |
|
|
39
|
+
| `tti` | TTI CLI command with `{input}` `{output}` | `pi --model agnes-2.0-flash --print "generate image: {input}" --output "{output}"` |
|
|
40
|
+
| `ttv` | TTV CLI command with `{input}` `{output}` | `pi --model agnes-2.0-flash --print "generate video: {input}" --output "{output}"` |
|
|
41
|
+
|
|
42
|
+
## src:auto — AI-Generated Media
|
|
43
|
+
|
|
44
|
+
Templates use a special `src:auto` value on `image` and `video` nodes to indicate AI generation:
|
|
45
|
+
|
|
46
|
+
```md
|
|
47
|
+
- image src:auto prompt:"sunset over Tokyo skyline, cinematic" duration:5
|
|
48
|
+
- video src:auto prompt:"person walking through cherry blossom park" duration:3
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `prompt:` key provides the generation prompt to the configured TTI/TTV CLI.
|
|
52
|
+
The pipeline resolves `src:auto` before compilation: runs the CLI command, gets the output path, and replaces `src:auto` with the actual generated file path.
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@lalalic/markcut",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Markdown-to-video engine. Describe scenes in markdown, get a rendered video.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"markcut": "./src/render/cli.mjs"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./src/entry.tsx",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/entry.tsx",
|
|
12
|
+
"./schema": "./src/schema/index.ts",
|
|
13
|
+
"./themes": "./src/themes/index.tsx",
|
|
14
|
+
"./descriptive": "./src/descriptive/compiler.ts",
|
|
15
|
+
"./descriptive-markdown": "./src/descriptive/markdown.ts",
|
|
16
|
+
"./descriptive-resolve": "./src/descriptive/resolve.ts"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"render": "node src/render/cli.mjs render",
|
|
20
|
+
"render:all": "node src/render/cli.mjs render --aspect all",
|
|
21
|
+
"render:demo": "node src/render/cli.mjs render src/demo-components.json --aspect all",
|
|
22
|
+
"templates": "node src/render/cli.mjs templates",
|
|
23
|
+
"preview": "node src/render/cli.mjs preview",
|
|
24
|
+
"verify": "node src/render/cli.mjs verify",
|
|
25
|
+
"resolve": "node src/render/cli.mjs resolve",
|
|
26
|
+
"render:pixelle": "remotion render examples/pixelle/index.tsx Root out/pixelle-asset-default.mp4 --props=./examples/pixelle/asset-default.json",
|
|
27
|
+
"render:gallery": "remotion render examples/pixelle/index.tsx Root out/pixelle-gallery.mp4 --props=./examples/pixelle/gallery.json",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"test": "vitest run --config tests/vitest.config.ts",
|
|
30
|
+
"test:integration": "vitest run --config tests/vitest.config.ts tests/render.test.ts",
|
|
31
|
+
"test:watch": "vitest --config tests/vitest.config.ts",
|
|
32
|
+
"build": "scripts/build-pipeline.sh && scripts/build-player.sh",
|
|
33
|
+
"vision": "node src/render/cli.mjs vision",
|
|
34
|
+
"push": "GIT_SSH_COMMAND=\"ssh -i ~/.ssh/id_ed25519_1 -o IdentitiesOnly=yes\" git push"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@babel/standalone": "^8.0.3",
|
|
38
|
+
"@remotion/cli": "4.0.469",
|
|
39
|
+
"@remotion/media-parser": "4.0.469",
|
|
40
|
+
"@remotion/media-utils": "4.0.469",
|
|
41
|
+
"@remotion/player": "^4.0.469",
|
|
42
|
+
"@remotion/transitions": "4.0.469",
|
|
43
|
+
"@vis.gl/react-google-maps": "^1.8.3",
|
|
44
|
+
"immer": "^10.1.1",
|
|
45
|
+
"js-yaml": "^5.2.1",
|
|
46
|
+
"react": "^19.0.0",
|
|
47
|
+
"react-dom": "^19.0.0",
|
|
48
|
+
"react-jsx-parser": "^2.4.1",
|
|
49
|
+
"react-markdown": "^10.1.0",
|
|
50
|
+
"remark-frontmatter": "^5.0.0",
|
|
51
|
+
"remark-parse": "^11.0.0",
|
|
52
|
+
"remotion": "4.0.469",
|
|
53
|
+
"systemjs": "^6.15.1",
|
|
54
|
+
"unified": "^11.0.5",
|
|
55
|
+
"zod": "4.3.6"
|
|
56
|
+
},
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@types/node": "^22.0.0",
|
|
59
|
+
"@types/react": "^19.0.0",
|
|
60
|
+
"@types/react-dom": "^19.0.0",
|
|
61
|
+
"typescript": "^5.6.0",
|
|
62
|
+
"vitest": "^2.1.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* artlist-dl — Download BGM/SFX from artlist.io CDN URLs or track pages.
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node scripts/artlist-dl.mjs bgm <cdnUrl> <name> → public/assets/bgm/<name>.mp3
|
|
7
|
+
* node scripts/artlist-dl.mjs sfx <cdnUrl> <name> [s] → public/assets/sfx/<name>.mp3 (trimmed to s seconds)
|
|
8
|
+
* node scripts/artlist-dl.mjs grab <trackPageUrl> <name> [bgm|sfx] [s] → auto-extract CDN URL from track page
|
|
9
|
+
* node scripts/artlist-dl.mjs list → show current assets
|
|
10
|
+
*
|
|
11
|
+
* CDN URLs are captured from artlist.io when playing a track (see SKILL.md).
|
|
12
|
+
* Pattern: https://cms-public-artifacts.artlist.io/content/...
|
|
13
|
+
*
|
|
14
|
+
* The `grab` command scrapes the track page HTML for base64-encoded CDN paths,
|
|
15
|
+
* no browser or login needed — works with just curl.
|
|
16
|
+
*/
|
|
17
|
+
import { execSync } from "node:child_process";
|
|
18
|
+
import { existsSync, mkdirSync, readdirSync, statSync } from "node:fs";
|
|
19
|
+
import { resolve, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const ROOT = resolve(__dirname, "..");
|
|
24
|
+
|
|
25
|
+
const BGM_DIR = resolve(ROOT, "public/assets/bgm");
|
|
26
|
+
const SFX_DIR = resolve(ROOT, "public/assets/sfx");
|
|
27
|
+
|
|
28
|
+
function ensureDir(dir) {
|
|
29
|
+
mkdirSync(dir, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function download(url, outPath, trimSeconds) {
|
|
33
|
+
const tmpPath = resolve(ROOT, ".tmp", `dl-${Date.now()}.aac`);
|
|
34
|
+
ensureDir(dirname(tmpPath));
|
|
35
|
+
ensureDir(dirname(outPath));
|
|
36
|
+
|
|
37
|
+
console.log(`⬇ Downloading: ${url.substring(0, 80)}...`);
|
|
38
|
+
execSync(`curl -sL "${url}" -o "${tmpPath}"`, { stdio: "inherit" });
|
|
39
|
+
|
|
40
|
+
const trimFlag = trimSeconds ? `-t ${trimSeconds}` : "";
|
|
41
|
+
console.log(`🔄 Converting to MP3${trimSeconds ? ` (${trimSeconds}s)` : ""}...`);
|
|
42
|
+
execSync(`ffmpeg -y -i "${tmpPath}" ${trimFlag} "${outPath}" 2>/dev/null`, { stdio: "inherit" });
|
|
43
|
+
|
|
44
|
+
execSync(`rm "${tmpPath}"`);
|
|
45
|
+
|
|
46
|
+
const size = statSync(outPath).size;
|
|
47
|
+
console.log(`✅ ${outPath} (${(size / 1024).toFixed(1)} KB)`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function list() {
|
|
51
|
+
console.log("\n📁 BGM (public/assets/bgm/):");
|
|
52
|
+
if (existsSync(BGM_DIR)) {
|
|
53
|
+
for (const f of readdirSync(BGM_DIR)) {
|
|
54
|
+
const size = statSync(resolve(BGM_DIR, f)).size;
|
|
55
|
+
console.log(` 🎵 ${f} (${(size / 1024).toFixed(1)} KB)`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
console.log("\n📁 SFX (public/assets/sfx/):");
|
|
59
|
+
if (existsSync(SFX_DIR)) {
|
|
60
|
+
for (const f of readdirSync(SFX_DIR)) {
|
|
61
|
+
const size = statSync(resolve(SFX_DIR, f)).size;
|
|
62
|
+
console.log(` 🔊 ${f} (${(size / 1024).toFixed(1)} KB)`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function search(term, type = "sfx", count = 10) {
|
|
68
|
+
const GRAPHQL = "https://search-api.artlist.io/v1/graphql";
|
|
69
|
+
let query, variables;
|
|
70
|
+
|
|
71
|
+
if (type === "bgm" || type === "music") {
|
|
72
|
+
query = `query SongList($page: Int!, $songSortType: Int!, $take: Int!, $vocalMenuId: Int!, $searchTerm: String) {
|
|
73
|
+
songList(page: $page, songSortType: $songSortType, take: $take, vocalMenuId: $vocalMenuId, searchTerm: $searchTerm) {
|
|
74
|
+
songs { songId songName artistName duration sitePlayableFilePath nameForURL }
|
|
75
|
+
}
|
|
76
|
+
}`;
|
|
77
|
+
variables = { page: 1, songSortType: 0, take: count, vocalMenuId: 0, searchTerm: term };
|
|
78
|
+
} else {
|
|
79
|
+
query = `query SfxList($categoryIds: String!, $page: Float!, $tags: String!, $term: String!, $sortBy: SfxListRequestSortByOptions!) {
|
|
80
|
+
sfxList(categoryIds: $categoryIds, page: $page, tags: $tags, term: $term, sortBy: $sortBy) {
|
|
81
|
+
songs { songId songName artistName durationTime sitePlayableFilePath nameForURL }
|
|
82
|
+
}
|
|
83
|
+
}`;
|
|
84
|
+
variables = { categoryIds: "", page: 1, tags: "", term, sortBy: "STAFF_PICKS" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const body = JSON.stringify({ query, variables });
|
|
88
|
+
const result = execSync(
|
|
89
|
+
`curl -s '${GRAPHQL}' -H 'Content-Type: application/json' -d '${body.replace(/'/g, "'\\''")}'`,
|
|
90
|
+
{ encoding: "utf-8" }
|
|
91
|
+
);
|
|
92
|
+
const data = JSON.parse(result);
|
|
93
|
+
const songs = type === "bgm" || type === "music"
|
|
94
|
+
? data.data?.songList?.songs || []
|
|
95
|
+
: data.data?.sfxList?.songs || [];
|
|
96
|
+
|
|
97
|
+
if (!songs.length) {
|
|
98
|
+
console.log(`No results for "${term}"`);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const typeLabel = type === "bgm" || type === "music" ? "Music" : "SFX";
|
|
103
|
+
console.log(`\n🔍 ${typeLabel} results for "${term}" (${songs.length} tracks):\n`);
|
|
104
|
+
for (const s of songs) {
|
|
105
|
+
const dur = s.duration || `${s.durationTime}s`;
|
|
106
|
+
const url = type === "bgm" || type === "music"
|
|
107
|
+
? `https://artlist.io/royalty-free-music/song/${s.nameForURL}/${s.songId}`
|
|
108
|
+
: `https://artlist.io/sfx/track/${s.nameForURL}/${s.songId}`;
|
|
109
|
+
console.log(` ${s.songId.padEnd(8)} ${s.songName} — ${s.artistName} (${dur})`);
|
|
110
|
+
console.log(` ${url}`);
|
|
111
|
+
}
|
|
112
|
+
console.log(`\nTo download: node scripts/artlist-dl.mjs grab <url> <name> [bgm|sfx]`);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// CLI
|
|
116
|
+
const [,, cmd, ...args] = process.argv;
|
|
117
|
+
|
|
118
|
+
if (cmd === "list") {
|
|
119
|
+
list();
|
|
120
|
+
} else if (cmd === "search" && args.length >= 1) {
|
|
121
|
+
const [term, type = "sfx", count] = args;
|
|
122
|
+
search(term, type, count ? Number(count) : 10);
|
|
123
|
+
} else if (cmd === "grab" && args.length >= 2) {
|
|
124
|
+
const [trackUrl, name, type = "sfx", trim] = args;
|
|
125
|
+
// Parse track ID and type from URL
|
|
126
|
+
// SFX: artlist.io/sfx/track/.../81139 | Music: artlist.io/royalty-free-music/song/.../6000953
|
|
127
|
+
const sfxMatch = trackUrl.match(/\/sfx\/track\/[^/]+\/(\d+)/);
|
|
128
|
+
const musicMatch = trackUrl.match(/\/(?:royalty-free-music|song)\/[^/]+\/[^/]+\/(\d+)/);
|
|
129
|
+
const trackId = sfxMatch?.[1] || musicMatch?.[1];
|
|
130
|
+
const isSfx = !!sfxMatch || type === "sfx";
|
|
131
|
+
const isMusic = !!musicMatch || type === "bgm";
|
|
132
|
+
|
|
133
|
+
if (!trackId) {
|
|
134
|
+
console.error("❌ Could not parse track ID from URL. Expected format:");
|
|
135
|
+
console.error(" SFX: artlist.io/sfx/track/<name>/<id>");
|
|
136
|
+
console.error(" Music: artlist.io/royalty-free-music/song/<name>/<id>");
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
console.log(`🔍 Fetching ${isMusic ? "music" : "sfx"} track #${trackId} via GraphQL API...`);
|
|
141
|
+
try {
|
|
142
|
+
const query = isMusic
|
|
143
|
+
? `query Songs($ids: [String!]!) { songs(ids: $ids) { songId songName sitePlayableFilePath duration artistName } }`
|
|
144
|
+
: `query Sfxs($ids: [Int!]!) { sfxs(ids: $ids) { songId songName sitePlayableFilePath duration artistName } }`;
|
|
145
|
+
const variables = isMusic ? { ids: [trackId] } : { ids: [Number(trackId)] };
|
|
146
|
+
const body = JSON.stringify({ query, variables });
|
|
147
|
+
|
|
148
|
+
const result = execSync(
|
|
149
|
+
`curl -s 'https://search-api.artlist.io/v1/graphql' -H 'Content-Type: application/json' -d '${body.replace(/'/g, "'\\''")}'`,
|
|
150
|
+
{ encoding: "utf-8" }
|
|
151
|
+
);
|
|
152
|
+
const data = JSON.parse(result);
|
|
153
|
+
const track = isMusic ? data.data?.songs?.[0] : data.data?.sfxs?.[0];
|
|
154
|
+
|
|
155
|
+
if (!track?.sitePlayableFilePath) {
|
|
156
|
+
console.error("❌ Track not found or no audio URL available.");
|
|
157
|
+
process.exit(1);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
console.log(`✅ "${track.songName}" by ${track.artistName} (${track.duration})`);
|
|
161
|
+
const cdnUrl = track.sitePlayableFilePath;
|
|
162
|
+
const outDir = isMusic ? BGM_DIR : SFX_DIR;
|
|
163
|
+
const trimSec = trim ? Number(trim) : (isSfx && !isMusic ? 3 : undefined);
|
|
164
|
+
download(cdnUrl, resolve(outDir, `${name}.mp3`), trimSec);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
console.error(`❌ Failed: ${e.message}`);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
}
|
|
169
|
+
} else if (cmd === "bgm" && args.length >= 2) {
|
|
170
|
+
const [url, name] = args;
|
|
171
|
+
download(url, resolve(BGM_DIR, `${name}.mp3`));
|
|
172
|
+
} else if (cmd === "sfx" && args.length >= 2) {
|
|
173
|
+
const [url, name, trim] = args;
|
|
174
|
+
download(url, resolve(SFX_DIR, `${name}.mp3`), trim ? Number(trim) : 3);
|
|
175
|
+
} else {
|
|
176
|
+
console.log(`
|
|
177
|
+
Usage:
|
|
178
|
+
node scripts/artlist-dl.mjs search <term> [sfx|bgm] [count] Search artlist.io
|
|
179
|
+
node scripts/artlist-dl.mjs grab <trackPageUrl> <name> [bgm|sfx] Download from track page URL
|
|
180
|
+
node scripts/artlist-dl.mjs bgm <cdnUrl> <name> Download BGM from CDN URL
|
|
181
|
+
node scripts/artlist-dl.mjs sfx <cdnUrl> <name> [sec] Download SFX from CDN URL (trim to sec)
|
|
182
|
+
node scripts/artlist-dl.mjs list List current audio assets
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
node scripts/artlist-dl.mjs search "whoosh" sfx
|
|
186
|
+
node scripts/artlist-dl.mjs search "cinematic epic" bgm
|
|
187
|
+
node scripts/artlist-dl.mjs grab https://artlist.io/sfx/track/sci-fi-transitions---powerful-whoosh/81139 whoosh sfx
|
|
188
|
+
node scripts/artlist-dl.mjs grab https://artlist.io/royalty-free-music/song/dominion/6000953 dominion bgm
|
|
189
|
+
`);
|
|
190
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Build the server-side pipeline bundle (descriptive resolve + compile).
|
|
3
|
+
# Run from project root: bash scripts/build-pipeline.sh
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
cd "$(dirname "$0")/.."
|
|
7
|
+
OUT_DIR="src/player"
|
|
8
|
+
OUT_FILE="$OUT_DIR/pipeline.mjs"
|
|
9
|
+
|
|
10
|
+
echo "Building pipeline bundle..."
|
|
11
|
+
npx esbuild src/player/pipeline.ts \
|
|
12
|
+
--bundle \
|
|
13
|
+
--outfile="$OUT_FILE" \
|
|
14
|
+
--format=esm \
|
|
15
|
+
--platform=node \
|
|
16
|
+
--target=node18 \
|
|
17
|
+
2>&1
|
|
18
|
+
|
|
19
|
+
echo "Done → $OUT_FILE ($(wc -c < "$OUT_FILE") bytes)"
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Build the browser player bundle.
|
|
3
|
+
# Run from project root: bash scripts/build-player.sh
|
|
4
|
+
set -e
|
|
5
|
+
|
|
6
|
+
cd "$(dirname "$0")/.."
|
|
7
|
+
OUT_DIR="src/player/bundle"
|
|
8
|
+
mkdir -p "$OUT_DIR"
|
|
9
|
+
|
|
10
|
+
echo "Building browser player..."
|
|
11
|
+
npx esbuild src/player/browser.tsx \
|
|
12
|
+
--bundle \
|
|
13
|
+
--outfile="$OUT_DIR/player.js" \
|
|
14
|
+
--format=esm \
|
|
15
|
+
--platform=browser \
|
|
16
|
+
--target=es2020 \
|
|
17
|
+
--define:process.env.NODE_ENV='"production"' \
|
|
18
|
+
2>&1
|
|
19
|
+
|
|
20
|
+
echo "Done → $OUT_DIR/player.js ($(wc -c < "$OUT_DIR/player.js") bytes)"
|
package/src/Root.tsx
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { Composition, getInputProps } from "remotion";
|
|
2
|
+
import { MarkCut } from "./entry";
|
|
3
|
+
import { getDurationInSeconds } from "./utils";
|
|
4
|
+
import { root as rootSchema } from "./schema";
|
|
5
|
+
|
|
6
|
+
const RootComposition = (props: any) => (
|
|
7
|
+
<MarkCut
|
|
8
|
+
{...props}
|
|
9
|
+
compose={{
|
|
10
|
+
...(props.compose ?? {}),
|
|
11
|
+
components: props.compose?.components ?? {},
|
|
12
|
+
}}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
export const RemotionRoot: React.FC = () => {
|
|
17
|
+
const inputProps = getInputProps() as { root?: unknown };
|
|
18
|
+
const data = inputProps.root as any;
|
|
19
|
+
if (data) {
|
|
20
|
+
// Dynamic composition from --props
|
|
21
|
+
const parsed = rootSchema.parse(data);
|
|
22
|
+
const fps = parsed.fps;
|
|
23
|
+
const width = parsed.width;
|
|
24
|
+
const height = parsed.height;
|
|
25
|
+
const durationInSeconds = getDurationInSeconds(parsed as any, true) || 1;
|
|
26
|
+
const durationInFrames = Math.max(1, Math.ceil(durationInSeconds * fps));
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<Composition
|
|
30
|
+
id="Root"
|
|
31
|
+
component={RootComposition as any}
|
|
32
|
+
durationInFrames={durationInFrames}
|
|
33
|
+
fps={fps}
|
|
34
|
+
width={width}
|
|
35
|
+
height={height}
|
|
36
|
+
defaultProps={{
|
|
37
|
+
root: data,
|
|
38
|
+
} as any}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Static 10s placeholder composition for the studio UI
|
|
44
|
+
return (
|
|
45
|
+
<Composition
|
|
46
|
+
id="Root"
|
|
47
|
+
component={RootComposition as any}
|
|
48
|
+
durationInFrames={300}
|
|
49
|
+
fps={30}
|
|
50
|
+
width={1080}
|
|
51
|
+
height={1920}
|
|
52
|
+
defaultProps={{}}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
};
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default CLI templates and constants for the markcut pipeline.
|
|
3
|
+
*
|
|
4
|
+
* All CLIs support {input} / {prompt} / {output} placeholders as documented.
|
|
5
|
+
* These can be overridden at runtime via --itt, --vtt, --stt, --tts, --agent flags,
|
|
6
|
+
* or via MARKCUT_*_CLI environment variables.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// ── Media type sets ───────────────────────────────────────────────────────
|
|
12
|
+
|
|
13
|
+
export const IMAGE_EXTS = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".tiff", ".heic", ".avif"]);
|
|
14
|
+
export const VIDEO_EXTS = new Set([".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".wmv"]);
|
|
15
|
+
|
|
16
|
+
// ── Normalization limits ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
/** Longest side in px for normalized images. */
|
|
19
|
+
export const MAX_IMAGE_DIMENSION = Number(process.env.MARKCUT_MAX_IMAGE_DIMENSION) || 384;
|
|
20
|
+
/** Max seconds for normalized video clip. */
|
|
21
|
+
export const MAX_VIDEO_DURATION = Number(process.env.MARKCUT_MAX_VIDEO_DURATION) || 60;
|
|
22
|
+
/** Max height/width for normalized video. */
|
|
23
|
+
export const MAX_VIDEO_DIMENSION = Number(process.env.MARKCUT_MAX_VIDEO_DIMENSION) || 360;
|
|
24
|
+
|
|
25
|
+
// ── Vision pipeline CLI templates ─────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Image-to-text (ITT) CLI.
|
|
29
|
+
* Reads an image file and returns a text description.
|
|
30
|
+
* Placeholders: {input}=image path(s), {prompt}=description prompt.
|
|
31
|
+
* Override with MARKCUT_ITT_CLI env var.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_ITT_CLI =
|
|
34
|
+
process.env.MARKCUT_ITT_CLI ||
|
|
35
|
+
'uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --image {input} --temperature 0.0 --thinking-mode disabled';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Seconds between sampled video frames when using the default ITT-based VTT.
|
|
39
|
+
*/
|
|
40
|
+
export const DEFAULT_VTT_SAMPLE_INTERVAL = Number(process.env.MARKCUT_VTT_SAMPLE_INTERVAL) || 5;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Video-to-text (VTT) CLI — direct video model.
|
|
44
|
+
* When set to null/empty, falls back to ITT via frame extraction.
|
|
45
|
+
* Placeholders: {input}=video path, {prompt}=description prompt.
|
|
46
|
+
* Override with MARKCUT_VTT_CLI env var.
|
|
47
|
+
*/
|
|
48
|
+
export const DEFAULT_VTT_CLI =
|
|
49
|
+
process.env.MARKCUT_VTT_CLI ||
|
|
50
|
+
'uvx --from mlx-vlm mlx_vlm.generate --model mlx-community/MiniCPM-V-4.6-bf16 --max-tokens 2048 --prompt "{prompt}" --video {input} --temperature 0.0 --processor-kwargs \'{"max_num_frames": 32, "stack_frames": 1, "max_slice_nums": 1, "use_image_id": false}\'';
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Speech-to-text (STT) CLI.
|
|
54
|
+
* Extracts audio and generates VTT subtitles.
|
|
55
|
+
* Placeholders: {input}=audio path, {output}=output directory.
|
|
56
|
+
* Override with MARKCUT_STT_CLI env var.
|
|
57
|
+
* Used by both vision and render pipelines.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_STT_CLI =
|
|
60
|
+
process.env.MARKCUT_STT_CLI ||
|
|
61
|
+
'uvx --from openai-whisper whisper "{input}" --output_format vtt --output_dir "{output}"';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Text-to-speech (TTS) CLI.
|
|
65
|
+
* Generates audio from text.
|
|
66
|
+
* Placeholders: {input}=text, {output}=audio file path.
|
|
67
|
+
* Override with MARKCUT_TTS_CLI env var.
|
|
68
|
+
* Used by both vision and render pipelines.
|
|
69
|
+
*/
|
|
70
|
+
export const DEFAULT_TTS_CLI =
|
|
71
|
+
process.env.MARKCUT_TTS_CLI ||
|
|
72
|
+
'uvx edge-tts --voice "en-US-GuyNeural" --text "{input}" --write-media "{output}"';
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Default agent CLI — general-purpose text LLM.
|
|
76
|
+
* Used for text-only tasks like detect-scenes.
|
|
77
|
+
* Placeholders: {prompt}=the prompt text.
|
|
78
|
+
* Override with MARKCUT_AGENT_CLI env var.
|
|
79
|
+
*/
|
|
80
|
+
export const DEFAULT_AGENT_CLI = process.env.MARKCUT_AGENT_CLI || 'npx pi -p {prompt}';
|
|
81
|
+
|
|
82
|
+
// ── Render-only pipeline CLI templates ─────────────────────────────────────
|
|
83
|
+
// These are specific to the render pipeline (no vision pipeline equivalent).
|
|
84
|
+
|
|
85
|
+
export const DEFAULT_TTI_CLI =
|
|
86
|
+
process.env.MARKCUT_TTI_CLI ||
|
|
87
|
+
'uvx --from mflux mflux-generate-flux2 --model flux2-klein-4b --steps 5 --prompt "{input}" --output "{output}"';
|
|
88
|
+
export const DEFAULT_TTV_CLI = process.env.MARKCUT_TTV_CLI || '';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Event System — cross-node state event context.
|
|
3
|
+
*
|
|
4
|
+
* Lets any node in the stream tree mutate a named component's state at runtime
|
|
5
|
+
* via `on` event specs. Events fire at specific frames (start, end, 50%, 2.5s)
|
|
6
|
+
* and evaluate JS expressions with registered component proxies in scope.
|
|
7
|
+
*
|
|
8
|
+
* Usage in markdown:
|
|
9
|
+
* - component id:slide1 jsx:"<Slide current={current}>{source}</Slide>"
|
|
10
|
+
* - script "Narration 1" on:[{when:start, state:"slide1.current=0"}]
|
|
11
|
+
* - script "Narration 2" on:[{when:start, state:"slide1.current++"}]
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as React from "react";
|
|
15
|
+
import { useCurrentFrame, useVideoConfig } from "remotion";
|
|
16
|
+
import type { EventSpec } from "../schema/index";
|
|
17
|
+
|
|
18
|
+
interface EventContextValue {
|
|
19
|
+
register: (id: string, setState: (partial: Record<string, any>) => void) => void;
|
|
20
|
+
unregister: (id: string) => void;
|
|
21
|
+
evaluate: (code: string) => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const EventContext = React.createContext<EventContextValue | null>(null);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates a Proxy wrapper around a target object.
|
|
28
|
+
* The `set` trap calls `setState` so React re-renders when the proxy is mutated.
|
|
29
|
+
*/
|
|
30
|
+
function createComponentProxy(
|
|
31
|
+
target: Record<string, any>,
|
|
32
|
+
setState: (partial: Record<string, any>) => void,
|
|
33
|
+
): Record<string, any> {
|
|
34
|
+
return new Proxy(target, {
|
|
35
|
+
set(obj, prop, value) {
|
|
36
|
+
obj[prop] = value;
|
|
37
|
+
setState({ [prop]: value });
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
get(obj, prop) {
|
|
41
|
+
if (typeof prop === "string" && prop in obj) return obj[prop];
|
|
42
|
+
return undefined;
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Wraps the render tree, maintaining a registry of named component proxies.
|
|
49
|
+
* Each registered component gets a Proxy whose setter triggers React re-render.
|
|
50
|
+
*/
|
|
51
|
+
export function EventProvider({ children }: { children: React.ReactNode }) {
|
|
52
|
+
const registryRef = React.useRef<
|
|
53
|
+
Map<string, { proxy: Record<string, any>; setState: (partial: Record<string, any>) => void }>
|
|
54
|
+
>(new Map());
|
|
55
|
+
|
|
56
|
+
const register = React.useCallback(
|
|
57
|
+
(id: string, setState: (partial: Record<string, any>) => void) => {
|
|
58
|
+
const target: Record<string, any> = {};
|
|
59
|
+
const proxy = createComponentProxy(target, setState);
|
|
60
|
+
registryRef.current.set(id, { proxy, setState });
|
|
61
|
+
},
|
|
62
|
+
[],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const unregister = React.useCallback((id: string) => {
|
|
66
|
+
registryRef.current.delete(id);
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const evaluate = React.useCallback((code: string) => {
|
|
70
|
+
const scope: Record<string, any> = {};
|
|
71
|
+
for (const [id, reg] of registryRef.current) {
|
|
72
|
+
scope[id] = reg.proxy;
|
|
73
|
+
}
|
|
74
|
+
const keys = Object.keys(scope);
|
|
75
|
+
const vals = Object.values(scope);
|
|
76
|
+
try {
|
|
77
|
+
const fn = new Function(...keys, code);
|
|
78
|
+
fn(...vals);
|
|
79
|
+
console.info(`Event evaluation succeeded: "${code}"`);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn(`Event evaluation failed: "${code}"`, err);
|
|
82
|
+
}
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
85
|
+
const value = React.useMemo(
|
|
86
|
+
() => ({ register, unregister, evaluate }),
|
|
87
|
+
[register, unregister, evaluate],
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<EventContext.Provider value={value}>
|
|
92
|
+
{children}
|
|
93
|
+
</EventContext.Provider>
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Hook to access the event context.
|
|
99
|
+
*/
|
|
100
|
+
export function useEventContext(): EventContextValue {
|
|
101
|
+
const ctx = React.useContext(EventContext);
|
|
102
|
+
if (!ctx) {
|
|
103
|
+
throw new Error("useEventContext must be used within an EventProvider");
|
|
104
|
+
}
|
|
105
|
+
return ctx;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Convert a `when` string to an absolute frame number.
|
|
110
|
+
*
|
|
111
|
+
* Supported formats:
|
|
112
|
+
* 'start' → 0
|
|
113
|
+
* 'end' → durationInFrames
|
|
114
|
+
* '50%' → 50% of durationInFrames
|
|
115
|
+
* '2.5s' → 2.5 * fps
|
|
116
|
+
*/
|
|
117
|
+
export function resolveWhenToFrame(
|
|
118
|
+
when: string,
|
|
119
|
+
durationInFrames: number,
|
|
120
|
+
fps: number,
|
|
121
|
+
): number {
|
|
122
|
+
if (when === "start") return 0;
|
|
123
|
+
if (when === "end") return durationInFrames;
|
|
124
|
+
|
|
125
|
+
// Percentage: "50%", "100%"
|
|
126
|
+
const pct = /^(\d+(?:\.\d+)?)%$/.exec(when);
|
|
127
|
+
if (pct) {
|
|
128
|
+
return Math.floor((Number(pct[1]) / 100) * durationInFrames);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Seconds: "2.5s", "0s"
|
|
132
|
+
const sec = /^(\d+(?:\.\d+)?)s$/.exec(when);
|
|
133
|
+
if (sec) {
|
|
134
|
+
return Math.floor(Number(sec[1]) * fps);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
console.warn(`Unknown when format: "${when}", defaulting to start`);
|
|
138
|
+
return 0;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Hook that fires an event at the correct frame.
|
|
143
|
+
*
|
|
144
|
+
* @param on - A single event spec from the stream node
|
|
145
|
+
* @param durationInFrames - Duration of the node in frames (used to compute target frame)
|
|
146
|
+
*/
|
|
147
|
+
export function useFrameEvents(
|
|
148
|
+
on: EventSpec | undefined,
|
|
149
|
+
durationInFrames: number,
|
|
150
|
+
): void {
|
|
151
|
+
const frame = useCurrentFrame();
|
|
152
|
+
const { fps } = useVideoConfig();
|
|
153
|
+
const { evaluate } = useEventContext();
|
|
154
|
+
const handledRef = React.useRef(false);
|
|
155
|
+
|
|
156
|
+
React.useEffect(() => {
|
|
157
|
+
if (!on || durationInFrames <= 0) return;
|
|
158
|
+
|
|
159
|
+
const targetFrame = resolveWhenToFrame(on.when, durationInFrames, fps);
|
|
160
|
+
if (frame === targetFrame && !handledRef.current) {
|
|
161
|
+
handledRef.current = true;
|
|
162
|
+
evaluate(on.state);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Reset so event can fire again if component re-mounts
|
|
166
|
+
if (frame > targetFrame) handledRef.current = false;
|
|
167
|
+
}, [frame, on, durationInFrames, fps, evaluate]);
|
|
168
|
+
}
|