@lalalic/markcut 1.1.1 ā 2.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/AGENTS.md +39 -15
- package/README.md +7 -3
- package/SKILL.md +26 -172
- package/docs/edit-mode.md +1 -1
- package/docs/label-mode.md +6 -4
- package/docs/markdown-descriptive.md +46 -1
- package/docs/system-prompt-edit.md +16 -0
- package/package.json +1 -1
- package/src/config.mjs +8 -3
- package/src/descriptive/compiler.test.ts +42 -42
- package/src/descriptive/compiler.ts +41 -52
- package/src/descriptive/markdown.ts +8 -4
- package/src/descriptive/resolve.test.ts +14 -7
- package/src/descriptive/resolve.ts +148 -20
- package/src/entry.tsx +8 -2
- package/src/player/browser.tsx +178 -54
- package/src/player/bundle/player.js +1168 -566
- package/src/player/components/EditControls.tsx +92 -0
- package/src/player/components/HeaderBar.tsx +60 -0
- package/src/player/components/LabelControls.tsx +367 -0
- package/src/player/components/SceneThumbnails.tsx +87 -0
- package/src/player/components/VariantBar.tsx +39 -0
- package/src/player/components/index.ts +5 -0
- package/src/player/pipeline.mjs +130 -66
- package/src/player/pipeline.ts +3 -1
- package/src/player/server-shared.mjs +5 -7
- package/src/player/server.mjs +194 -187
- package/src/render/cli.mjs +66 -13
- package/src/schema/index.ts +20 -23
- package/src/types/Audio.tsx +25 -33
- package/src/types/Component.tsx +18 -24
- package/src/types/Effect.tsx +31 -39
- package/src/types/Image.tsx +23 -30
- package/src/types/Include.tsx +70 -76
- package/src/types/Map.tsx +48 -44
- package/src/types/Rhythm.tsx +19 -27
- package/src/types/Video.tsx +40 -47
- package/src/utils/component-import-map.ts +93 -0
- package/src/utils/index.ts +23 -10
- package/src/vision/cli.mjs +6 -6
- package/templates/courseware/TEMPLATE.md +317 -0
- package/templates/courseware/agents/reviewer.md +84 -0
- package/templates/courseware/prompts/fix.md +30 -0
- package/templates/courseware/prompts/outline.md +53 -0
- package/templates/courseware/prompts/scene.md +64 -0
- package/tests/fixtures/audio.json +4 -2
- package/tests/fixtures/basic.json +2 -1
- package/tests/fixtures/component-all.json +4 -2
- package/tests/fixtures/components.json +4 -2
- package/tests/fixtures/effects.json +12 -6
- package/tests/fixtures/full.json +8 -4
- package/tests/fixtures/map.json +2 -1
- package/tests/fixtures/md/dialogue.md +12 -0
- package/tests/fixtures/md/edge-cases.md +9 -0
- package/tests/fixtures/scenes.json +4 -2
- package/tests/fixtures/subtitle.json +6 -3
- package/tests/fixtures/subvideo.json +11 -6
- package/tests/fixtures/templates/courseware.md +61 -4
- package/tests/fixtures/tween-visual.json +2 -6
- package/tests/fixtures/video-series.json +6 -14
- package/tests/md-descriptive.test.ts +170 -0
- package/tests/render.test.ts +32 -16
- package/tests/schema.test.ts +6 -3
- package/tests/server.test.ts +9 -6
- package/tests/utils.ts +4 -4
- package/docs/dynamic-components.md +0 -191
- package/docs/templates.md +0 -52
- package/scripts/artlist-dl.mjs +0 -190
- package/src/player/label-server.mjs +0 -599
package/tests/server.test.ts
CHANGED
|
@@ -48,7 +48,8 @@ function makeFixture(name: string): string {
|
|
|
48
48
|
type: "component",
|
|
49
49
|
id: `comp-${name}`,
|
|
50
50
|
jsx: `<div>${name}</div>`,
|
|
51
|
-
|
|
51
|
+
start: 0,
|
|
52
|
+
end: 3,
|
|
52
53
|
},
|
|
53
54
|
],
|
|
54
55
|
},
|
|
@@ -274,16 +275,18 @@ describe("HTML page variant injection", () => {
|
|
|
274
275
|
}
|
|
275
276
|
});
|
|
276
277
|
|
|
277
|
-
it("
|
|
278
|
-
const fixture = makeFixture("html-
|
|
278
|
+
it("sets mode and variant in globals for client-side rendering", async () => {
|
|
279
|
+
const fixture = makeFixture("html-mode");
|
|
279
280
|
const port = nextPort();
|
|
280
281
|
const stop = await startServer(fixture, ["default", "zh-tiktok", "youtube"], port);
|
|
281
282
|
try {
|
|
282
283
|
const res = await fetchUrl(`http://localhost:${port}/`);
|
|
283
284
|
expect(res.status).toBe(200);
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
expect(res.text).toContain('
|
|
285
|
+
// Variant bar is now rendered client-side by React (VariantBar.tsx).
|
|
286
|
+
// Server only provides the config globals and loads player.js.
|
|
287
|
+
expect(res.text).toContain('window.VARIANT = "default"');
|
|
288
|
+
expect(res.text).toContain('window.MODE = "preview"');
|
|
289
|
+
expect(res.text).toContain('src="/player.js"');
|
|
287
290
|
} finally {
|
|
288
291
|
stop();
|
|
289
292
|
}
|
package/tests/utils.ts
CHANGED
|
@@ -376,10 +376,10 @@ export function getExpectedDuration(fixtureData: any): number {
|
|
|
376
376
|
}
|
|
377
377
|
}
|
|
378
378
|
|
|
379
|
-
// Leaf
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
379
|
+
// Leaf duration from base timing fields (end is source of truth)
|
|
380
|
+
const leafStart = fixtureData.start ?? 0;
|
|
381
|
+
if (typeof fixtureData.end === "number") return fixtureData.end;
|
|
382
|
+
if (typeof fixtureData.duration === "number") return leafStart + fixtureData.duration;
|
|
383
383
|
|
|
384
384
|
return 1;
|
|
385
385
|
}
|
|
@@ -1,191 +0,0 @@
|
|
|
1
|
-
# Dynamic Components
|
|
2
|
-
|
|
3
|
-
Three ways to add custom visual content without rebuilding the engine.
|
|
4
|
-
|
|
5
|
-
## 1. Effects on Any Node (CSS Keyframes)
|
|
6
|
-
|
|
7
|
-
Add the `effects` key to any node to apply CSS keyframe animations. No wrapper node needed.
|
|
8
|
-
|
|
9
|
-
```json
|
|
10
|
-
{
|
|
11
|
-
"id": "animated-scene",
|
|
12
|
-
"type": "image",
|
|
13
|
-
"src": "photo.jpg",
|
|
14
|
-
"duration": 3,
|
|
15
|
-
"effects": ["bounceIn(1, ease-out, 2)"]
|
|
16
|
-
}
|
|
17
|
-
```
|
|
18
|
-
|
|
19
|
-
### 25+ Built-in Animation Names
|
|
20
|
-
|
|
21
|
-
| Fades | Slides | Zooms | Attention | Bounce | Rotations |
|
|
22
|
-
|-------|--------|-------|-----------|--------|-----------|
|
|
23
|
-
| `fadeIn` | `slideInDown` | `zoomIn` | `pulse` | `bounceIn` | `rotateIn` |
|
|
24
|
-
| `fadeOut` | `slideInUp` | `zoomOut` | `flash` | | `rotateOut` |
|
|
25
|
-
| `fadeInDown` | `slideInLeft` | | `bounce` | | |
|
|
26
|
-
| `fadeInUp` | `slideInRight` | | `heartBeat` | | |
|
|
27
|
-
| `fadeInLeft` | | | `rubberBand` | | |
|
|
28
|
-
| `fadeInRight` | | | `shakeX` | | |
|
|
29
|
-
| (8 total) | | | | | |
|
|
30
|
-
|
|
31
|
-
### Custom Keyframes
|
|
32
|
-
|
|
33
|
-
```json
|
|
34
|
-
{
|
|
35
|
-
"type": "effect",
|
|
36
|
-
"animation": "custom",
|
|
37
|
-
"customKeyframes": {
|
|
38
|
-
"0": { "opacity": "0", "transform": "scale3d(0,0,0) rotate(0deg)" },
|
|
39
|
-
"50": { "opacity": "0.5", "transform": "scale3d(1.2,1.2,1.2) rotate(180deg)" },
|
|
40
|
-
"100": { "opacity": "1", "transform": "scale3d(1,1,1) rotate(360deg)" }
|
|
41
|
-
},
|
|
42
|
-
"children": [...],
|
|
43
|
-
"actions": [{ "start": 0, "end": 2 }]
|
|
44
|
-
}
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Percentages `"0"`ā`"100"` map to action duration. Any numeric CSS property works.
|
|
48
|
-
|
|
49
|
-
## 2. Remote Components (bundler-based)
|
|
50
|
-
|
|
51
|
-
Register remote React components via the `~~~js imports` code fence (markdown) or `root.imports` array (JSON descriptive). The player server bundles all registered components into a single ESM module at startup.
|
|
52
|
-
|
|
53
|
-
```markdown
|
|
54
|
-
~~~js imports
|
|
55
|
-
export { BarChart } from "recharts"
|
|
56
|
-
export { Hello } from "git:user/repo/path/to/Hello.tsx"
|
|
57
|
-
export { Badge } from "https://cdn.example.com/components/badge.js"
|
|
58
|
-
|
|
59
|
-
export function Slide(props) {
|
|
60
|
-
return <div style={{color: '#fff'}}>{props.children}</div>;
|
|
61
|
-
}
|
|
62
|
-
~~~
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
Then use them in component JSX:
|
|
66
|
-
|
|
67
|
-
```json
|
|
68
|
-
{
|
|
69
|
-
"type": "component",
|
|
70
|
-
"jsx": "<Badge text='LIVE' color='#ff0000' />",
|
|
71
|
-
"actions": [{ "start": 0, "end": 3 }]
|
|
72
|
-
}
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
### How it works
|
|
76
|
-
|
|
77
|
-
1. The server extracts the imports block from the source file
|
|
78
|
-
2. `parseImportsBlock` resolves `export { X } from "spec"` and `export function X() {}` patterns
|
|
79
|
-
3. Inline functions' source code is scanned for `import X from "pkg"` statements ā those packages are added to dependencies
|
|
80
|
-
4. `bundleFromEntries` creates a temp npm project, installs all packages, and runs `esbuild` to produce a single ESM file
|
|
81
|
-
5. The bundled file URL is set on `root.imports` and served from `/.component-cache/<hash>.js`
|
|
82
|
-
6. At runtime, `MarkCut.useComponentRegistry` does a dynamic `import(url)` to load all named exports
|
|
83
|
-
7. react-jsx-parser resolves component tags from the loaded registry
|
|
84
|
-
|
|
85
|
-
### Import spec forms
|
|
86
|
-
|
|
87
|
-
| Pattern | Resolves to | Example |
|
|
88
|
-
|---|---|---|
|
|
89
|
-
| `pkg` or `npm:pkg` | npm package (installed + bundled) | `export { BarChart } from "recharts"` |
|
|
90
|
-
| `git:user/repo` | GitHub repo source | `export { Comp } from "git:user/repo/src/Comp.tsx"` |
|
|
91
|
-
| `https://...` | Raw URL (used as-is) | `export { X } from "https://cdn.example.com/x.js"` |
|
|
92
|
-
| local path | Filesystem path | `export { X } from "./local/Component.tsx"` |
|
|
93
|
-
|
|
94
|
-
### Inline functions
|
|
95
|
-
|
|
96
|
-
Define components directly in the imports block. Dependencies used inside the function body are automatically detected and installed:
|
|
97
|
-
|
|
98
|
-
```markdown
|
|
99
|
-
~~~js imports
|
|
100
|
-
import ReactMarkdown from "react-markdown"
|
|
101
|
-
|
|
102
|
-
export function Slide({ children }) {
|
|
103
|
-
return <div className="slide"><ReactMarkdown>{children}</ReactMarkdown></div>;
|
|
104
|
-
}
|
|
105
|
-
~~~
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
The `import` statements at the top serve dual purpose: they bring packages into scope inside inline functions AND tell the bundler what to install. The packages are added to `package.json` during bundling.
|
|
109
|
-
|
|
110
|
-
### Component Contract
|
|
111
|
-
|
|
112
|
-
Props injected automatically:
|
|
113
|
-
- Nothing special required ā just standard React props. The engine passes any `data` bindings from the component node as JSX variables.
|
|
114
|
-
|
|
115
|
-
For frame-accurate animation, use standard Remotion hooks inside inline functions:
|
|
116
|
-
|
|
117
|
-
```markdown
|
|
118
|
-
~~~js imports
|
|
119
|
-
import { useCurrentFrame } from "remotion"
|
|
120
|
-
|
|
121
|
-
export function FadeIn({ children }) {
|
|
122
|
-
const frame = useCurrentFrame();
|
|
123
|
-
const opacity = Math.min(1, frame / 15);
|
|
124
|
-
return <div style={{ opacity }}>{children}</div>;
|
|
125
|
-
}
|
|
126
|
-
~~~
|
|
127
|
-
|
|
128
|
-
Note: `remotion` and `react` are automatically available in the bundle's external scope ā they do NOT need to be imported. However, adding `import { useCurrentFrame } from "remotion"` in the imports block is harmless and makes the dependency explicit.
|
|
129
|
-
|
|
130
|
-
## 3. Custom Components
|
|
131
|
-
|
|
132
|
-
Create a React component file in `src/components/`, register in `builtinComponents`.
|
|
133
|
-
|
|
134
|
-
### Component Contract
|
|
135
|
-
|
|
136
|
-
Props the engine injects automatically:
|
|
137
|
-
- `action: { start: number; end: number }` ā action timing from the stream node
|
|
138
|
-
|
|
139
|
-
Use `useCurrentFrame()` for frame-accurate animation, `useVideoConfig()` for canvas size.
|
|
140
|
-
|
|
141
|
-
### Example
|
|
142
|
-
|
|
143
|
-
```tsx
|
|
144
|
-
// src/components/my/Badge.tsx
|
|
145
|
-
import React from "react";
|
|
146
|
-
import { useCurrentFrame, interpolate } from "remotion";
|
|
147
|
-
|
|
148
|
-
export const Badge: React.FC<{text: string; color?: string; action: any}> =
|
|
149
|
-
({ text, color, action }) => {
|
|
150
|
-
const frame = useCurrentFrame();
|
|
151
|
-
const local = frame - action.start * 30;
|
|
152
|
-
const opacity = interpolate(local, [0, 15], [0, 1]);
|
|
153
|
-
return (
|
|
154
|
-
<div style={{ opacity, padding: 12, background: color || theme.colors.primary,
|
|
155
|
-
color: "white", borderRadius: 8, fontSize: 48 }}>
|
|
156
|
-
{text}
|
|
157
|
-
</div>
|
|
158
|
-
);
|
|
159
|
-
};
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
### Register
|
|
163
|
-
|
|
164
|
-
```tsx
|
|
165
|
-
// src/components/index.ts
|
|
166
|
-
import { Badge } from "./my/Badge";
|
|
167
|
-
export const builtinComponents = { ..., Badge };
|
|
168
|
-
```
|
|
169
|
-
|
|
170
|
-
### Reference in stream tree
|
|
171
|
-
|
|
172
|
-
```json
|
|
173
|
-
{
|
|
174
|
-
"type": "component",
|
|
175
|
-
"jsx": "<Badge text='NEW' color='#ff6b35' />",
|
|
176
|
-
"actions": [{ "start": 1, "end": 4 }]
|
|
177
|
-
}
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
# common used components
|
|
182
|
-
- `react-markdown` ā render markdown content, use plugins to extend functionality
|
|
183
|
-
- `remark-gfm` ā support GitHub Flavored Markdown (tables, strikethrough, task lists)
|
|
184
|
-
- `remark-toc` ā generate table of contents
|
|
185
|
-
- `remark-math` ā support math formulas
|
|
186
|
-
- `rehype-katex` ā render math formulas with KaTeX
|
|
187
|
-
- `remark-mermaidjs` ā render mermaid diagrams in
|
|
188
|
-
- `react-markdown-mermaid` ā render mermaid diagrams in standalone mode (no need to install mermaid separately)
|
|
189
|
-
|
|
190
|
-
- `@remotion/shapes` ā render shapes like arrows, circles, rectangles, etc
|
|
191
|
-
- `@remotion/starburst` ā render starburst animations
|
package/docs/templates.md
DELETED
|
@@ -1,52 +0,0 @@
|
|
|
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/scripts/artlist-dl.mjs
DELETED
|
@@ -1,190 +0,0 @@
|
|
|
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
|
-
}
|