@marcuth/movie.js 0.1.2 → 0.2.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/README.md +164 -0
- package/dist/clips/image-clip.d.ts +3 -1
- package/dist/clips/image-clip.js +23 -6
- package/dist/clips/video-clip.d.ts +3 -1
- package/dist/clips/video-clip.js +8 -2
- package/dist/template.js +0 -12
- package/dist/utils/easing-expr.js +3 -3
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# Movie.js
|
|
2
|
+
|
|
3
|
+
**Movie.js** is a library built on top of `fluent-ffmpeg` designed to create templates for generating standardized videos with data—which can be static or dynamic. It simplifies the process of sequencing video, image, and audio clips into a final composition.
|
|
4
|
+
|
|
5
|
+
## 📦 Installation
|
|
6
|
+
|
|
7
|
+
Installation is straightforward; just use your preferred package manager. Here is an example using NPM:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm i @marcuth/movie.js
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
> **Note:** You must have FFMPEG installed on your system for this library to work.
|
|
14
|
+
|
|
15
|
+
## 🚀 Usage
|
|
16
|
+
|
|
17
|
+
<a href="https://www.buymeacoffee.com/marcuth">
|
|
18
|
+
<img src="https://cdn.buymeacoffee.com/buttons/v2/default-yellow.png" alt="Buy Me A Coffee" width="200">
|
|
19
|
+
</a>
|
|
20
|
+
|
|
21
|
+
### Template
|
|
22
|
+
|
|
23
|
+
The foundation for creating videos in Movie.js is the `Template`. This is where you define your clips and configuration.
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import movie from "@marcuth/movie.js"
|
|
27
|
+
|
|
28
|
+
type RenderData = {
|
|
29
|
+
heroImage: string
|
|
30
|
+
backgroundVideo: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const template = movie.template<RenderData>({
|
|
34
|
+
config: {
|
|
35
|
+
format: "mp4",
|
|
36
|
+
fps: 30,
|
|
37
|
+
outputOptions: ["-preset ultrafast"] // optional ffmpeg output options
|
|
38
|
+
},
|
|
39
|
+
clips: [
|
|
40
|
+
// ... your clips here
|
|
41
|
+
]
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// Render the template with data
|
|
45
|
+
const result = await template.render({
|
|
46
|
+
heroImage: "/path/to/image.png",
|
|
47
|
+
backgroundVideo: "/path/to/video.mp4"
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Save the result
|
|
51
|
+
result.save("output.mp4")
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
---
|
|
55
|
+
|
|
56
|
+
### Clips
|
|
57
|
+
|
|
58
|
+
Clips are the building blocks of your video. They represent individual media segments like videos, images, or audio tracks.
|
|
59
|
+
|
|
60
|
+
Most file paths in clips support dynamic resolution:
|
|
61
|
+
```ts
|
|
62
|
+
path: ({ data, index }) => data.myPath
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
#### VideoClip
|
|
66
|
+
|
|
67
|
+
To insert a video segment:
|
|
68
|
+
|
|
69
|
+
```ts
|
|
70
|
+
movie.video({
|
|
71
|
+
path: "assets/intro.mp4",
|
|
72
|
+
fadeIn: 1, // seconds
|
|
73
|
+
fadeOut: 1, // seconds
|
|
74
|
+
subClip: [0, 5] // [start, duration] - Clip from 0s to 5s
|
|
75
|
+
})
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
#### ImageClip
|
|
79
|
+
|
|
80
|
+
To insert an image with optional scrolling/Ken Burns effect:
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
movie.image({
|
|
84
|
+
path: ({ data }) => data.heroImage,
|
|
85
|
+
duration: 5, // seconds
|
|
86
|
+
width: 1920, // force resize width
|
|
87
|
+
height: 1080, // force resize height
|
|
88
|
+
scroll: {
|
|
89
|
+
axis: "y", // "x", "y", or "auto"
|
|
90
|
+
direction: "forward",
|
|
91
|
+
easing: "easeInOut"
|
|
92
|
+
},
|
|
93
|
+
fadeIn: 0.5
|
|
94
|
+
})
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
#### AudioClip
|
|
98
|
+
|
|
99
|
+
To add audio (background music, sound effects):
|
|
100
|
+
|
|
101
|
+
```ts
|
|
102
|
+
movie.audio({
|
|
103
|
+
path: "assets/music.mp3",
|
|
104
|
+
volume: 0.5,
|
|
105
|
+
fadeIn: 2,
|
|
106
|
+
fadeOut: 2
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
### Structural Clips
|
|
113
|
+
|
|
114
|
+
#### RepeatClip
|
|
115
|
+
|
|
116
|
+
If you need to loop over an array of data to generate clips:
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
movie.repeat({
|
|
120
|
+
each: ({ data }) => data.items, // Array of items
|
|
121
|
+
clip: (item, index) => movie.video({
|
|
122
|
+
path: item.videoPath
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
#### CompositionClip
|
|
128
|
+
|
|
129
|
+
Group multiple clips together. Useful for organizing sequences:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
movie.composition({
|
|
133
|
+
clips: [
|
|
134
|
+
movie.video({ ... }),
|
|
135
|
+
movie.video({ ... })
|
|
136
|
+
]
|
|
137
|
+
})
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
#### ConcatenationClip
|
|
141
|
+
|
|
142
|
+
Explicitly concatenate a list of clips:
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
movie.concatenation({
|
|
146
|
+
clips: [ ... ]
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 🤝 Contributing
|
|
153
|
+
|
|
154
|
+
Want to contribute? Follow these steps:
|
|
155
|
+
|
|
156
|
+
1. Fork the repository.
|
|
157
|
+
2. Create a new branch (`git checkout -b feature-new`).
|
|
158
|
+
3. Commit your changes (`git commit -m 'Add new feature'`).
|
|
159
|
+
4. Push to the branch (`git push origin feature-new`).
|
|
160
|
+
5. Open a Pull Request.
|
|
161
|
+
|
|
162
|
+
## 📝 License
|
|
163
|
+
|
|
164
|
+
This project is licensed under the MIT License.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import ffmpeg from "fluent-ffmpeg";
|
|
1
2
|
import { Path } from "../utils/resolve-path";
|
|
2
3
|
import { RenderContext } from "../render-context";
|
|
3
4
|
import { FFmpegInput } from "../ffmpeg-input";
|
|
@@ -29,5 +30,6 @@ export declare class ImageClip<RenderData> extends Clip<RenderData> {
|
|
|
29
30
|
};
|
|
30
31
|
constructor({ duration, path, width, height, fadeIn, fadeOut, scroll }: ImageClipOptions<RenderData>);
|
|
31
32
|
protected getInput(path: string, inputIndex: number, fps: number): FFmpegInput;
|
|
32
|
-
|
|
33
|
+
protected getMetadata(path: string): Promise<ffmpeg.FfprobeData>;
|
|
34
|
+
build(data: RenderData, context: RenderContext): Promise<void>;
|
|
33
35
|
}
|
package/dist/clips/image-clip.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ImageClip = void 0;
|
|
4
|
+
const fluent_ffmpeg_1 = require("fluent-ffmpeg");
|
|
4
5
|
const resolve_path_1 = require("../utils/resolve-path");
|
|
5
|
-
const clip_1 = require("./clip");
|
|
6
6
|
const easing_expr_1 = require("../utils/easing-expr");
|
|
7
|
+
const clip_1 = require("./clip");
|
|
7
8
|
class ImageClip extends clip_1.Clip {
|
|
8
9
|
constructor({ duration, path, width, height, fadeIn, fadeOut, scroll }) {
|
|
9
10
|
super();
|
|
@@ -31,11 +32,17 @@ class ImageClip extends clip_1.Clip {
|
|
|
31
32
|
]
|
|
32
33
|
};
|
|
33
34
|
}
|
|
34
|
-
|
|
35
|
+
getMetadata(path) {
|
|
36
|
+
return new Promise((resolve, reject) => {
|
|
37
|
+
(0, fluent_ffmpeg_1.ffprobe)(path, (err, data) => err ? reject(err) : resolve(data));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
async build(data, context) {
|
|
35
41
|
const path = (0, resolve_path_1.resolvePath)({ path: this.path, data: data, index: context.clipIndex });
|
|
36
42
|
const input = this.getInput(path, context.inputIndex, context.fps);
|
|
37
43
|
let currentVideoOutput = input.aliases.video;
|
|
38
44
|
const currentAudioOutput = input.aliases.audio;
|
|
45
|
+
const metadata = await this.getMetadata(path);
|
|
39
46
|
context.command
|
|
40
47
|
.input(input.path)
|
|
41
48
|
.inputOptions(input.options);
|
|
@@ -54,9 +61,12 @@ class ImageClip extends clip_1.Clip {
|
|
|
54
61
|
const hasCanvas = this.width !== -1 || this.height !== -1;
|
|
55
62
|
if (hasCanvas && this.scroll) {
|
|
56
63
|
const scaleOutput = `scale${context.inputIndex}`;
|
|
57
|
-
|
|
64
|
+
let { axis = "auto" } = this.scroll;
|
|
58
65
|
let scaleOptions = null;
|
|
59
|
-
if (axis === "
|
|
66
|
+
if (axis === "auto") {
|
|
67
|
+
axis = metadata.format.width > metadata.format.height ? "x" : "y";
|
|
68
|
+
}
|
|
69
|
+
if (axis === "y") {
|
|
60
70
|
scaleOptions = {
|
|
61
71
|
w: this.width,
|
|
62
72
|
h: -1,
|
|
@@ -69,7 +79,7 @@ class ImageClip extends clip_1.Clip {
|
|
|
69
79
|
};
|
|
70
80
|
}
|
|
71
81
|
if (!scaleOptions) {
|
|
72
|
-
throw new Error("Invalid
|
|
82
|
+
throw new Error("Invalid scroll axis");
|
|
73
83
|
}
|
|
74
84
|
context.filters.push({
|
|
75
85
|
filter: "scale",
|
|
@@ -77,7 +87,14 @@ class ImageClip extends clip_1.Clip {
|
|
|
77
87
|
inputs: currentVideoOutput,
|
|
78
88
|
outputs: scaleOutput,
|
|
79
89
|
});
|
|
80
|
-
|
|
90
|
+
const setsarOutput = `setsar${context.inputIndex}`;
|
|
91
|
+
context.filters.push({
|
|
92
|
+
filter: "setsar",
|
|
93
|
+
options: { sar: "1/1" },
|
|
94
|
+
inputs: scaleOutput,
|
|
95
|
+
outputs: setsarOutput,
|
|
96
|
+
});
|
|
97
|
+
currentVideoOutput = setsarOutput;
|
|
81
98
|
}
|
|
82
99
|
else if (hasCanvas && !this.scroll) {
|
|
83
100
|
const scaleOutput = `scale${context.inputIndex}`;
|
|
@@ -6,12 +6,14 @@ export type VideoClipOptions<RenderData> = {
|
|
|
6
6
|
path: Path<RenderData>;
|
|
7
7
|
fadeIn?: number;
|
|
8
8
|
fadeOut?: number;
|
|
9
|
+
subClip?: [number, number];
|
|
9
10
|
};
|
|
10
11
|
export declare class VideoClip<RenderData> extends Clip<RenderData> {
|
|
11
12
|
readonly path: Path<RenderData>;
|
|
12
13
|
readonly fadeIn?: number;
|
|
13
14
|
readonly fadeOut?: number;
|
|
14
|
-
|
|
15
|
+
readonly subClip?: [number, number];
|
|
16
|
+
constructor({ path, fadeIn, fadeOut, subClip }: VideoClipOptions<RenderData>);
|
|
15
17
|
protected getInput(path: string, inputIndex: number): FFmpegInput;
|
|
16
18
|
getDuration(path: string): Promise<number>;
|
|
17
19
|
build(data: RenderData, context: RenderContext): Promise<void>;
|
package/dist/clips/video-clip.js
CHANGED
|
@@ -5,11 +5,12 @@ const fluent_ffmpeg_1 = require("fluent-ffmpeg");
|
|
|
5
5
|
const resolve_path_1 = require("../utils/resolve-path");
|
|
6
6
|
const clip_1 = require("./clip");
|
|
7
7
|
class VideoClip extends clip_1.Clip {
|
|
8
|
-
constructor({ path, fadeIn, fadeOut }) {
|
|
8
|
+
constructor({ path, fadeIn, fadeOut, subClip }) {
|
|
9
9
|
super();
|
|
10
10
|
this.path = path;
|
|
11
11
|
this.fadeIn = fadeIn;
|
|
12
12
|
this.fadeOut = fadeOut;
|
|
13
|
+
this.subClip = subClip;
|
|
13
14
|
}
|
|
14
15
|
getInput(path, inputIndex) {
|
|
15
16
|
return {
|
|
@@ -37,8 +38,13 @@ class VideoClip extends clip_1.Clip {
|
|
|
37
38
|
const input = this.getInput(path, context.inputIndex);
|
|
38
39
|
let currentVideoOutput = input.aliases.video;
|
|
39
40
|
let currentAudioOutput = input.aliases.audio;
|
|
40
|
-
|
|
41
|
+
let duration = await this.getDuration(path);
|
|
41
42
|
context.command.input(path);
|
|
43
|
+
if (this.subClip) {
|
|
44
|
+
const [start, subDuration] = this.subClip;
|
|
45
|
+
context.command.inputOptions([`-ss ${start}`, `-t ${subDuration}`]);
|
|
46
|
+
duration = subDuration;
|
|
47
|
+
}
|
|
42
48
|
if (this.fadeIn !== undefined && this.fadeIn > 0) {
|
|
43
49
|
const fadeInOutput = `[fadeIn${context.inputIndex}]`;
|
|
44
50
|
const fadeInAudioOutput = `[fadeInAudio${context.inputIndex}]`;
|
package/dist/template.js
CHANGED
|
@@ -53,18 +53,6 @@ class Template {
|
|
|
53
53
|
mixFilter,
|
|
54
54
|
finalAudioFilter
|
|
55
55
|
].filter((filter) => filter !== null);
|
|
56
|
-
// console.dir({
|
|
57
|
-
// concatFilter,
|
|
58
|
-
// finalAudioFilter,
|
|
59
|
-
// mixFilter,
|
|
60
|
-
// filterComplex,
|
|
61
|
-
// context: {
|
|
62
|
-
// ...context,
|
|
63
|
-
// command: "<command>"
|
|
64
|
-
// }
|
|
65
|
-
// }, {
|
|
66
|
-
// depth: null
|
|
67
|
-
// })
|
|
68
56
|
command.complexFilter(filterComplex);
|
|
69
57
|
command.outputOptions([
|
|
70
58
|
"-map [outv]",
|
|
@@ -4,13 +4,13 @@ exports.easingExpr = easingExpr;
|
|
|
4
4
|
function easingExpr(easing, t) {
|
|
5
5
|
switch (easing) {
|
|
6
6
|
case "linear":
|
|
7
|
-
return `(${t})`;
|
|
7
|
+
return `(${t})`;
|
|
8
8
|
case "easeIn":
|
|
9
9
|
return `pow(${t},2)`;
|
|
10
10
|
case "easeOut":
|
|
11
|
-
return `1-pow(1-(${t}),2)`;
|
|
11
|
+
return `1-pow(1-(${t}),2)`;
|
|
12
12
|
case "easeInOut":
|
|
13
|
-
return `if(lt(${t},0.5),2*pow(${t},2),1-2*pow(1-(${t}),2))`;
|
|
13
|
+
return `if(lt(${t},0.5),2*pow(${t},2),1-2*pow(1-(${t}),2))`;
|
|
14
14
|
default:
|
|
15
15
|
return `(${t})`;
|
|
16
16
|
}
|