@editframe/vite-plugin 0.5.0-beta.2

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.
@@ -0,0 +1,176 @@
1
+ import { spawn } from "child_process";
2
+ import { execPromise } from "../util/execPromise.mjs";
3
+ import * as z from "zod";
4
+ import { createReadStream } from "fs";
5
+ const AudioStreamSchema = z.object({
6
+ index: z.number(),
7
+ codec_name: z.string(),
8
+ codec_long_name: z.string(),
9
+ codec_type: z.literal("audio"),
10
+ codec_tag_string: z.string(),
11
+ codec_tag: z.string(),
12
+ sample_fmt: z.string(),
13
+ sample_rate: z.string(),
14
+ channels: z.number(),
15
+ channel_layout: z.string(),
16
+ bits_per_sample: z.number(),
17
+ initial_padding: z.number().optional(),
18
+ r_frame_rate: z.string(),
19
+ avg_frame_rate: z.string(),
20
+ time_base: z.string(),
21
+ start_pts: z.number(),
22
+ start_time: z.coerce.number(),
23
+ duration_ts: z.number(),
24
+ duration: z.coerce.number(),
25
+ bit_rate: z.string(),
26
+ disposition: z.record(z.unknown())
27
+ });
28
+ const VideoStreamSchema = z.object({
29
+ index: z.number(),
30
+ codec_name: z.string(),
31
+ codec_long_name: z.string(),
32
+ codec_type: z.literal("video"),
33
+ codec_tag_string: z.string(),
34
+ codec_tag: z.string(),
35
+ width: z.number(),
36
+ height: z.number(),
37
+ coded_width: z.number(),
38
+ coded_height: z.number(),
39
+ r_frame_rate: z.string(),
40
+ avg_frame_rate: z.string(),
41
+ time_base: z.string(),
42
+ start_pts: z.number().optional(),
43
+ start_time: z.coerce.number().optional(),
44
+ duration_ts: z.number().optional(),
45
+ duration: z.coerce.number().optional(),
46
+ bit_rate: z.string().optional(),
47
+ disposition: z.record(z.unknown())
48
+ });
49
+ const ProbeFormatSchema = z.object({
50
+ filename: z.string(),
51
+ nb_streams: z.number(),
52
+ nb_programs: z.number(),
53
+ format_name: z.string(),
54
+ format_long_name: z.string(),
55
+ start_time: z.string().optional(),
56
+ duration: z.string().optional(),
57
+ size: z.string(),
58
+ bit_rate: z.string().optional(),
59
+ probe_score: z.number()
60
+ });
61
+ const StreamSchema = z.discriminatedUnion("codec_type", [
62
+ AudioStreamSchema,
63
+ VideoStreamSchema
64
+ ]);
65
+ const ProbeSchema = z.object({
66
+ streams: z.array(StreamSchema),
67
+ format: ProbeFormatSchema
68
+ });
69
+ class Probe {
70
+ constructor(absolutePath, rawData) {
71
+ this.absolutePath = absolutePath;
72
+ this.data = ProbeSchema.parse(rawData);
73
+ }
74
+ static async probePath(absolutePath) {
75
+ const probeResult = await execPromise(
76
+ `ffprobe -v error -show_format -show_streams -of json ${absolutePath}`
77
+ );
78
+ const json = JSON.parse(probeResult.stdout);
79
+ return new Probe(absolutePath, json);
80
+ }
81
+ get audioStreams() {
82
+ return this.data.streams.filter(
83
+ (stream) => stream.codec_type === "audio"
84
+ );
85
+ }
86
+ get videoStreams() {
87
+ return this.data.streams.filter(
88
+ (stream) => stream.codec_type === "video"
89
+ );
90
+ }
91
+ get streams() {
92
+ return this.data.streams;
93
+ }
94
+ get format() {
95
+ return this.data.format;
96
+ }
97
+ get mustReencodeAudio() {
98
+ return this.audioStreams.some((stream) => stream.codec_name !== "aac");
99
+ }
100
+ get mustReencodeVideo() {
101
+ return false;
102
+ }
103
+ get mustRemux() {
104
+ return this.format.format_name !== "mp4";
105
+ }
106
+ get hasAudio() {
107
+ return this.audioStreams.length > 0;
108
+ }
109
+ get hasVideo() {
110
+ return this.videoStreams.length > 0;
111
+ }
112
+ get isAudioOnly() {
113
+ return this.audioStreams.length > 0 && this.videoStreams.length === 0;
114
+ }
115
+ get isVideoOnly() {
116
+ return this.audioStreams.length === 0 && this.videoStreams.length > 0;
117
+ }
118
+ get mustProcess() {
119
+ return this.mustReencodeAudio || this.mustReencodeVideo || this.mustRemux;
120
+ }
121
+ get ffmpegAudioOptions() {
122
+ if (!this.hasAudio) {
123
+ return [];
124
+ }
125
+ if (this.mustReencodeAudio) {
126
+ return [
127
+ "-c:a",
128
+ "aac",
129
+ "-b:a",
130
+ "192k",
131
+ "-ar",
132
+ "48000"
133
+ ];
134
+ }
135
+ return ["-c:a", "copy"];
136
+ }
137
+ get ffmpegVideoOptions() {
138
+ if (!this.hasVideo) {
139
+ return [];
140
+ }
141
+ if (this.mustReencodeVideo) {
142
+ return [
143
+ "-c:v",
144
+ "h264",
145
+ "-pix_fmt",
146
+ "yuv420p"
147
+ ];
148
+ }
149
+ return ["-c:v", "copy"];
150
+ }
151
+ createConformingReadstream() {
152
+ if (!this.mustProcess) {
153
+ return createReadStream(this.absolutePath);
154
+ }
155
+ return spawn("ffmpeg", [
156
+ "-i",
157
+ this.absolutePath,
158
+ ...this.ffmpegAudioOptions,
159
+ ...this.ffmpegVideoOptions,
160
+ "-f",
161
+ "mp4",
162
+ "-frag_duration",
163
+ "8000000",
164
+ "-movflags",
165
+ "frag_keyframe+empty_moov",
166
+ "pipe:1"
167
+ ], {
168
+ stdio: ["ignore", "pipe", "inherit"]
169
+ }).stdout;
170
+ }
171
+ }
172
+ export {
173
+ AudioStreamSchema,
174
+ Probe,
175
+ VideoStreamSchema
176
+ };
@@ -0,0 +1,57 @@
1
+ import { existsSync, createWriteStream } from "fs";
2
+ import path from "path";
3
+ import { md5FilePath } from "./md5.mjs";
4
+ import debug from "debug";
5
+ import { mkdir, writeFile } from "fs/promises";
6
+ import { Readable } from "stream";
7
+ const idempotentTask = ({
8
+ label,
9
+ filename,
10
+ runner
11
+ }) => {
12
+ const tasks = {};
13
+ return async (rootDir, absolutePath, ...args) => {
14
+ const log = debug(`@ef:${label}`);
15
+ const md5 = await md5FilePath(absolutePath);
16
+ const cacheDir = path.join(rootDir, ".cache", md5);
17
+ log(`Cache dir: ${cacheDir}`);
18
+ await mkdir(cacheDir, { recursive: true });
19
+ const cachePath = path.join(cacheDir, filename(absolutePath, ...args));
20
+ const key = cachePath;
21
+ if (existsSync(cachePath)) {
22
+ log(`Returning cached @ef:${label} task for ${key}`);
23
+ return { cachePath, md5Sum: md5 };
24
+ }
25
+ const maybeTask = tasks[key];
26
+ if (maybeTask) {
27
+ log(`Returning existing @ef:${label} task for ${key}`);
28
+ await maybeTask;
29
+ return { cachePath, md5Sum: md5 };
30
+ }
31
+ log(`Creating new @ef:${label} task for ${key}`);
32
+ const task = runner(absolutePath, ...args);
33
+ tasks[key] = task;
34
+ log(`Awaiting task for ${key}`);
35
+ const result = await task;
36
+ if (result instanceof Readable) {
37
+ log(`Piping task for ${key} to cache`);
38
+ const writeStream = createWriteStream(cachePath);
39
+ result.pipe(writeStream);
40
+ await new Promise((resolve, reject) => {
41
+ result.on("error", reject);
42
+ writeStream.on("error", reject);
43
+ writeStream.on("finish", resolve);
44
+ });
45
+ return { cachePath, md5Sum: md5 };
46
+ }
47
+ log(`Writing to ${cachePath}`);
48
+ await writeFile(cachePath, result);
49
+ return {
50
+ md5Sum: md5,
51
+ cachePath
52
+ };
53
+ };
54
+ };
55
+ export {
56
+ idempotentTask
57
+ };
@@ -0,0 +1,28 @@
1
+ import { createReadStream } from "fs";
2
+ import crypto from "crypto";
3
+ async function md5FilePath(filePath) {
4
+ const readStream = createReadStream(filePath);
5
+ return md5ReadStream(readStream);
6
+ }
7
+ function md5ReadStream(readStream) {
8
+ return new Promise((resolve, reject) => {
9
+ const hash = crypto.createHash("md5");
10
+ readStream.on("data", (data) => {
11
+ hash.update(data);
12
+ });
13
+ readStream.on("error", reject);
14
+ readStream.on("end", () => {
15
+ resolve(addDashesToUUID(hash.digest("hex")));
16
+ });
17
+ });
18
+ }
19
+ function addDashesToUUID(uuidWithoutDashes) {
20
+ if (uuidWithoutDashes.length !== 32) {
21
+ throw new Error("Invalid UUID without dashes. Expected 32 characters.");
22
+ }
23
+ return uuidWithoutDashes.slice(0, 8) + "-" + uuidWithoutDashes.slice(8, 12) + "-" + uuidWithoutDashes.slice(12, 16) + "-" + uuidWithoutDashes.slice(16, 20) + "-" + uuidWithoutDashes.slice(20, 32);
24
+ }
25
+ export {
26
+ md5FilePath,
27
+ md5ReadStream
28
+ };
@@ -0,0 +1,21 @@
1
+ import { Writable } from "stream";
2
+ const mp4FileWritable = (mp4File) => {
3
+ let arrayBufferStart = 0;
4
+ return new Writable({
5
+ write: (chunk, _encoding, callback) => {
6
+ const mp4BoxBuffer = chunk.buffer;
7
+ mp4BoxBuffer.fileStart = arrayBufferStart;
8
+ arrayBufferStart += chunk.length;
9
+ mp4File.appendBuffer(mp4BoxBuffer, false);
10
+ callback();
11
+ },
12
+ final: (callback) => {
13
+ mp4File.flush();
14
+ mp4File.processSamples(true);
15
+ callback();
16
+ }
17
+ });
18
+ };
19
+ export {
20
+ mp4FileWritable
21
+ };
@@ -0,0 +1,22 @@
1
+ import { idempotentTask } from "../idempotentTask.mjs";
2
+ import { createReadStream } from "fs";
3
+ import path from "path";
4
+ const cacheImageTask = idempotentTask({
5
+ label: "image",
6
+ filename: (absolutePath) => path.basename(absolutePath),
7
+ runner: async (absolutePath) => {
8
+ return createReadStream(absolutePath);
9
+ }
10
+ });
11
+ const cacheImage = async (cacheRoot, absolutePath) => {
12
+ try {
13
+ return await cacheImageTask(cacheRoot, absolutePath);
14
+ } catch (error) {
15
+ console.error(error);
16
+ console.trace("Error generating track fragment index", error);
17
+ throw error;
18
+ }
19
+ };
20
+ export {
21
+ cacheImage
22
+ };
@@ -0,0 +1,26 @@
1
+ import { execPromise } from "../../util/execPromise.mjs";
2
+ import { idempotentTask } from "../idempotentTask.mjs";
3
+ import debug from "debug";
4
+ import { basename } from "path";
5
+ const log = debug("@ef:generateCaptions");
6
+ const generateCaptionData = idempotentTask({
7
+ label: "captions",
8
+ filename: (absolutePath) => `${basename(absolutePath)}.captions.json`,
9
+ runner: async (absolutePath) => {
10
+ const command = `whisper_timestamped --language en --efficient --output_format vtt ${absolutePath}`;
11
+ log(`Running command: ${command}`);
12
+ const { stdout } = await execPromise(command);
13
+ return stdout;
14
+ }
15
+ });
16
+ const findOrCreateCaptions = async (cacheRoot, absolutePath) => {
17
+ try {
18
+ return await generateCaptionData(cacheRoot, absolutePath);
19
+ } catch (error) {
20
+ console.trace("Error finding or creating captions", error);
21
+ throw error;
22
+ }
23
+ };
24
+ export {
25
+ findOrCreateCaptions
26
+ };
@@ -0,0 +1,52 @@
1
+ import { idempotentTask } from "../idempotentTask.mjs";
2
+ import { MP4File } from "../../editor/util/MP4File.mjs";
3
+ import debug from "debug";
4
+ import { mp4FileWritable } from "../mp4FileWritable.mjs";
5
+ import { PassThrough } from "stream";
6
+ import { basename } from "path";
7
+ import { Probe } from "../Probe.mjs";
8
+ const generateTrackTask = idempotentTask({
9
+ label: "track",
10
+ filename: (absolutePath, trackId) => `${basename(absolutePath)}.track-${trackId}.mp4`,
11
+ runner: async (absolutePath, trackId) => {
12
+ const log = debug("@ef:generateTrackFragment");
13
+ const probe = await Probe.probePath(absolutePath);
14
+ const readStream = probe.createConformingReadstream();
15
+ const mp4File = new MP4File();
16
+ log(`Generating track fragment index for ${absolutePath}`);
17
+ readStream.pipe(mp4FileWritable(mp4File));
18
+ await new Promise((resolve, reject) => {
19
+ readStream.on("end", resolve);
20
+ readStream.on("error", reject);
21
+ });
22
+ const trackStream = new PassThrough();
23
+ for await (const fragment of mp4File.fragmentIterator()) {
24
+ if (fragment.track !== trackId) {
25
+ continue;
26
+ }
27
+ trackStream.write(Buffer.from(fragment.data), "binary");
28
+ }
29
+ trackStream.end();
30
+ return trackStream;
31
+ }
32
+ });
33
+ const generateTrack = async (cacheRoot, absolutePath, url) => {
34
+ try {
35
+ const trackId = new URL(
36
+ `http://localhost${url}` ?? "bad-url"
37
+ ).searchParams.get("trackId");
38
+ if (trackId === null) {
39
+ throw new Error(
40
+ "No trackId provided. IT must be specified in the query string: ?trackId=0"
41
+ );
42
+ }
43
+ return await generateTrackTask(cacheRoot, absolutePath, Number(trackId));
44
+ } catch (error) {
45
+ console.error(error);
46
+ console.trace("Error generating track fragment index", error);
47
+ throw error;
48
+ }
49
+ };
50
+ export {
51
+ generateTrack
52
+ };
@@ -0,0 +1,71 @@
1
+ import { idempotentTask } from "../idempotentTask.mjs";
2
+ import { MP4File } from "../../editor/util/MP4File.mjs";
3
+ import debug from "debug";
4
+ import { mp4FileWritable } from "../mp4FileWritable.mjs";
5
+ import { basename } from "path";
6
+ import { Probe } from "../Probe.mjs";
7
+ const generateTrackFragmentIndexFromPath = async (absolutePath) => {
8
+ const log = debug("@ef:generateTrackFragment");
9
+ const probe = await Probe.probePath(absolutePath);
10
+ const readStream = probe.createConformingReadstream();
11
+ const mp4File = new MP4File();
12
+ log(`Generating track fragment index for ${absolutePath}`);
13
+ readStream.pipe(mp4FileWritable(mp4File));
14
+ await new Promise((resolve, reject) => {
15
+ readStream.on("end", resolve);
16
+ readStream.on("error", reject);
17
+ });
18
+ const trackFragmentIndexes = {};
19
+ const trackByteOffsets = {};
20
+ for await (const fragment of mp4File.fragmentIterator()) {
21
+ const track = mp4File.getInfo().tracks.find((track2) => track2.id === fragment.track);
22
+ if (!track) {
23
+ throw new Error("Track not found");
24
+ }
25
+ if (fragment.segment === "init") {
26
+ trackByteOffsets[fragment.track] = fragment.data.byteLength;
27
+ trackFragmentIndexes[fragment.track] = {
28
+ track: fragment.track,
29
+ type: track?.type ?? "video",
30
+ timescale: track.timescale,
31
+ duration: 0,
32
+ initSegment: {
33
+ offset: 0,
34
+ size: fragment.data.byteLength
35
+ },
36
+ segments: []
37
+ };
38
+ } else {
39
+ const fragmentIndex = trackFragmentIndexes[fragment.track];
40
+ if (!fragmentIndex) {
41
+ throw new Error("Fragment index not found");
42
+ }
43
+ fragmentIndex.duration += fragment.duration;
44
+ fragmentIndex.segments.push({
45
+ cts: fragment.cts,
46
+ dts: fragment.dts,
47
+ duration: fragment.duration,
48
+ offset: trackByteOffsets[fragment.track],
49
+ size: fragment.data.byteLength
50
+ });
51
+ trackByteOffsets[fragment.track] += fragment.data.byteLength;
52
+ }
53
+ }
54
+ return JSON.stringify(trackFragmentIndexes, null, 2);
55
+ };
56
+ const generateTrackFragmentIndexTask = idempotentTask({
57
+ label: "trackFragmentIndex",
58
+ filename: (absolutePath) => `${basename(absolutePath)}.tracks.json`,
59
+ runner: generateTrackFragmentIndexFromPath
60
+ });
61
+ const generateTrackFragmentIndex = async (cacheRoot, absolutePath) => {
62
+ try {
63
+ return await generateTrackFragmentIndexTask(cacheRoot, absolutePath);
64
+ } catch (error) {
65
+ console.trace("Error generating track fragment index", error);
66
+ throw error;
67
+ }
68
+ };
69
+ export {
70
+ generateTrackFragmentIndex
71
+ };
@@ -0,0 +1,161 @@
1
+ import * as MP4Box from "mp4box";
2
+ class MP4File extends MP4Box.ISOFile {
3
+ constructor() {
4
+ super(...arguments);
5
+ this.readyPromise = new Promise((resolve, reject) => {
6
+ this.onReady = () => resolve();
7
+ this.onError = reject;
8
+ });
9
+ this.waitingForSamples = [];
10
+ this._hasSeenLastSamples = false;
11
+ this._arrayBufferFileStart = 0;
12
+ }
13
+ setSegmentOptions(id, user, options) {
14
+ const trak = this.getTrackById(id);
15
+ if (trak) {
16
+ trak.nextSample = 0;
17
+ this.fragmentedTracks.push({
18
+ id,
19
+ user,
20
+ trak,
21
+ segmentStream: null,
22
+ nb_samples: "nbSamples" in options && options.nbSamples || 1e3,
23
+ rapAlignement: ("rapAlignement" in options && options.rapAlignement) ?? true
24
+ });
25
+ }
26
+ }
27
+ /**
28
+ * Fragments all tracks in a file into separate array buffers.
29
+ */
30
+ async fragmentAllTracks() {
31
+ let trackBuffers = {};
32
+ for await (const segment of this.fragmentIterator()) {
33
+ (trackBuffers[segment.track] ??= []).push(segment.data);
34
+ }
35
+ return trackBuffers;
36
+ }
37
+ async *fragmentIterator() {
38
+ await this.readyPromise;
39
+ const trackInfo = {};
40
+ for (const videoTrack of this.getInfo().videoTracks) {
41
+ trackInfo[videoTrack.id] = { index: 0, complete: false };
42
+ this.setSegmentOptions(videoTrack.id, null, {
43
+ rapAlignement: true
44
+ });
45
+ }
46
+ for (const audioTrack of this.getInfo().audioTracks) {
47
+ trackInfo[audioTrack.id] = { index: 0, complete: false };
48
+ const sampleRate = audioTrack.audio.sample_rate;
49
+ const probablePacketSize = 1024;
50
+ const probableFourSecondsOfSamples = Math.ceil(
51
+ sampleRate / probablePacketSize * 4
52
+ );
53
+ this.setSegmentOptions(audioTrack.id, null, {
54
+ nbSamples: probableFourSecondsOfSamples
55
+ });
56
+ }
57
+ const initSegments = this.initializeSegmentation();
58
+ for (const initSegment of initSegments) {
59
+ yield {
60
+ track: initSegment.id,
61
+ segment: "init",
62
+ data: initSegment.buffer,
63
+ complete: false
64
+ };
65
+ }
66
+ const fragmentStartSamples = {};
67
+ let finishedReading = false;
68
+ const allTracksFinished = () => {
69
+ for (const fragmentedTrack of this.fragmentedTracks) {
70
+ if (!trackInfo[fragmentedTrack.id]?.complete) {
71
+ return false;
72
+ }
73
+ }
74
+ return true;
75
+ };
76
+ while (!(finishedReading && allTracksFinished())) {
77
+ for (const fragTrak of this.fragmentedTracks) {
78
+ const trak = fragTrak.trak;
79
+ if (trak.nextSample === void 0) {
80
+ throw new Error("trak.nextSample is undefined");
81
+ }
82
+ if (trak.samples === void 0) {
83
+ throw new Error("trak.samples is undefined");
84
+ }
85
+ eachSample:
86
+ while (trak.nextSample < trak.samples.length) {
87
+ let result = void 0;
88
+ if (trak?.samples[trak.nextSample]) {
89
+ fragmentStartSamples[fragTrak.id] ||= trak.samples[trak.nextSample];
90
+ }
91
+ try {
92
+ result = this.createFragment(
93
+ fragTrak.id,
94
+ trak.nextSample,
95
+ fragTrak.segmentStream
96
+ );
97
+ } catch (error) {
98
+ console.log("Failed to createFragment", error);
99
+ }
100
+ if (result) {
101
+ fragTrak.segmentStream = result;
102
+ trak.nextSample++;
103
+ } else {
104
+ finishedReading = await this.waitForMoreSamples();
105
+ break eachSample;
106
+ }
107
+ const nextSample = trak.samples[trak.nextSample];
108
+ const emitSegment = (
109
+ // if rapAlignement is true, we emit a fragment when we have a rap sample coming up next
110
+ fragTrak.rapAlignement === true && nextSample?.is_sync || // if rapAlignement is false, we emit a fragment when we have the required number of samples
111
+ !fragTrak.rapAlignement && trak.nextSample % fragTrak.nb_samples === 0 || // // if this is the last sample, we emit the fragment
112
+ // finished ||
113
+ // if we have more samples than the number of samples requested, we emit the fragment
114
+ trak.nextSample >= trak.samples.length
115
+ );
116
+ if (emitSegment) {
117
+ if (trak.nextSample >= trak.samples.length) {
118
+ trackInfo[fragTrak.id].complete = true;
119
+ }
120
+ const startSample = fragmentStartSamples[fragTrak.id];
121
+ const endSample = trak.samples[trak.nextSample - 1];
122
+ if (!startSample || !endSample) {
123
+ throw new Error("startSample or endSample is undefined");
124
+ }
125
+ yield {
126
+ track: fragTrak.id,
127
+ segment: trackInfo[fragTrak.id].index,
128
+ data: fragTrak.segmentStream.buffer,
129
+ complete: trackInfo[fragTrak.id].complete,
130
+ cts: startSample.cts,
131
+ dts: startSample.dts,
132
+ duration: endSample.dts - startSample.dts + endSample.duration
133
+ };
134
+ trackInfo[fragTrak.id].index += 1;
135
+ fragTrak.segmentStream = null;
136
+ delete fragmentStartSamples[fragTrak.id];
137
+ }
138
+ }
139
+ }
140
+ finishedReading = await this.waitForMoreSamples();
141
+ }
142
+ }
143
+ waitForMoreSamples() {
144
+ if (this._hasSeenLastSamples) {
145
+ return Promise.resolve(true);
146
+ }
147
+ return new Promise((resolve) => {
148
+ this.waitingForSamples.push(resolve);
149
+ });
150
+ }
151
+ processSamples(last) {
152
+ this._hasSeenLastSamples = last;
153
+ for (const observer of this.waitingForSamples) {
154
+ observer(last);
155
+ }
156
+ this.waitingForSamples = [];
157
+ }
158
+ }
159
+ export {
160
+ MP4File
161
+ };
@@ -0,0 +1,6 @@
1
+ import { exec } from "child_process";
2
+ import { promisify } from "util";
3
+ const execPromise = promisify(exec);
4
+ export {
5
+ execPromise
6
+ };
@@ -0,0 +1,8 @@
1
+ const forbidRelativePaths = (req) => {
2
+ if (req.url?.includes("..")) {
3
+ throw new Error("Relative paths are forbidden");
4
+ }
5
+ };
6
+ export {
7
+ forbidRelativePaths
8
+ };
@@ -0,0 +1,54 @@
1
+ import { statSync, createReadStream } from "fs";
2
+ import mime from "mime";
3
+ import debug from "debug";
4
+ const sendTaskResult = (req, res, taskResult) => {
5
+ const { cachePath, md5Sum } = taskResult;
6
+ const filePath = cachePath;
7
+ const headers = {
8
+ etag: md5Sum
9
+ };
10
+ const log = debug("@ef:sendfile");
11
+ try {
12
+ log(`Sending file ${filePath}`);
13
+ const stats = statSync(filePath);
14
+ if (req.headers.range) {
15
+ let [x, y] = req.headers.range.replace("bytes=", "").split("-");
16
+ let end = parseInt(y ?? "0", 10) || stats.size - 1;
17
+ let start = parseInt(x ?? "0", 10) || 0;
18
+ if (end >= stats.size) {
19
+ end = stats.size - 1;
20
+ }
21
+ if (start >= stats.size) {
22
+ log("Range start is greater than file size");
23
+ res.setHeader("Content-Range", `bytes */${stats.size}`);
24
+ res.statusCode = 416;
25
+ return res.end();
26
+ }
27
+ res.writeHead(206, {
28
+ ...headers,
29
+ "Content-Type": mime.getType(filePath) || "text/plain",
30
+ "Content-Range": `bytes ${start}-${end}/${stats.size}`,
31
+ "Content-Length": end - start + 1,
32
+ "Accept-Ranges": "bytes"
33
+ });
34
+ log(`Sending ${filePath} range ${start}-${end}/${stats.size}`);
35
+ const readStream = createReadStream(filePath, { start, end });
36
+ readStream.pipe(res);
37
+ } else {
38
+ res.writeHead(200, {
39
+ ...headers,
40
+ "Content-Type": mime.getType(filePath) || "text/plain",
41
+ "Contetn-Length": stats.size
42
+ });
43
+ log(`Sending ${filePath}`);
44
+ const readStream = createReadStream(filePath);
45
+ readStream.pipe(res);
46
+ }
47
+ } catch (error) {
48
+ log("Error sending file", error);
49
+ console.error(error);
50
+ }
51
+ };
52
+ export {
53
+ sendTaskResult
54
+ };
@@ -0,0 +1,69 @@
1
+ import path from "path";
2
+ import debug from "debug";
3
+ import { findOrCreateCaptions } from "../../assets/tasks/findOrCreateCaptions.mjs";
4
+ import { generateTrackFragmentIndex } from "../../assets/tasks/generateTrackFragmentIndex.mjs";
5
+ import { generateTrack } from "../../assets/tasks/generateTrack.mjs";
6
+ import { cacheImage } from "../../assets/tasks/cacheImage.mjs";
7
+ import { md5FilePath } from "../../assets/md5.mjs";
8
+ import { forbidRelativePaths } from "./forbidRelativePaths.mjs";
9
+ import { sendTaskResult } from "./sendTaskResult.mjs";
10
+ const vitePluginEditframe = (options) => {
11
+ return {
12
+ name: "vite-plugin-editframe",
13
+ configureServer(server) {
14
+ server.middlewares.use(async (req, res, next) => {
15
+ const log = debug("@ef:vite-plugin");
16
+ if (req.url?.startsWith("/@ef")) {
17
+ forbidRelativePaths(req);
18
+ } else {
19
+ return next();
20
+ }
21
+ log(`Handling ${req.url}`);
22
+ const requestPath = req.url.replace(new RegExp("^/@ef-[^/]+/"), "");
23
+ const assetPath = requestPath.replace(/\?.*$/, "");
24
+ const absolutePath = path.join(options.root, assetPath).replace("dist/", "src/");
25
+ options.cacheRoot = options.cacheRoot.replace("dist/", "src/");
26
+ const efPrefix = req.url.split("/")[1];
27
+ switch (efPrefix) {
28
+ case "@ef-asset": {
29
+ if (req.method !== "HEAD") {
30
+ res.writeHead(405, { Allow: "HEAD" });
31
+ res.end();
32
+ }
33
+ md5FilePath(absolutePath).then((md5) => {
34
+ res.writeHead(200, {
35
+ etag: md5
36
+ });
37
+ res.end();
38
+ }).catch(next);
39
+ break;
40
+ }
41
+ case "@ef-track-fragment-index": {
42
+ log(`Serving track fragment index for ${absolutePath}`);
43
+ generateTrackFragmentIndex(options.cacheRoot, absolutePath).then((taskResult) => sendTaskResult(req, res, taskResult)).catch(next);
44
+ break;
45
+ }
46
+ case "@ef-track": {
47
+ log(`Serving track for ${absolutePath}`);
48
+ generateTrack(options.cacheRoot, absolutePath, req.url).then((taskResult) => sendTaskResult(req, res, taskResult)).catch(next);
49
+ break;
50
+ }
51
+ case "@ef-captions":
52
+ log(`Serving captions for ${absolutePath}`);
53
+ findOrCreateCaptions(options.cacheRoot, absolutePath).then((taskResult) => sendTaskResult(req, res, taskResult)).catch(next);
54
+ break;
55
+ case "@ef-image":
56
+ log(`Serving image file ${absolutePath}`);
57
+ cacheImage(options.cacheRoot, absolutePath).then((taskResult) => sendTaskResult(req, res, taskResult)).catch(next);
58
+ break;
59
+ default:
60
+ log(`Unknown asset type ${efPrefix}`);
61
+ break;
62
+ }
63
+ });
64
+ }
65
+ };
66
+ };
67
+ export {
68
+ vitePluginEditframe
69
+ };
@@ -0,0 +1,17 @@
1
+
2
+ services:
3
+ build-vite-plugin:
4
+ image: telecine-runner
5
+ depends_on:
6
+ - runner
7
+ working_dir: /app
8
+ env_file:
9
+ - ${PWD}/.env
10
+ volumes_from:
11
+ - runner
12
+ stop_grace_period: 1s
13
+ init: true
14
+ scale: 1
15
+ environment:
16
+ - SHELL=/bin/sh
17
+ command: npx vite build --config lib/vite-plugin/vite.config.ts lib/vite-plugin/src/vite-plugin-editframe.ts --watch
package/package.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "name": "@editframe/vite-plugin",
3
+ "version": "0.5.0-beta.2",
4
+ "description": "Editframe vite plugin",
5
+ "exports": {
6
+ ".": "./dist/vite-plugin/src/vite-plugin-editframe.mjs"
7
+ },
8
+ "scripts": {
9
+ "build": "vite build"
10
+ },
11
+ "author": "",
12
+ "license": "UNLICENSED",
13
+ "dependencies": {
14
+ "debug": "^4.3.4",
15
+ "mime": "^4.0.3",
16
+ "node-html-parser": "^6.1.13",
17
+ "vite": "^5.2.11"
18
+ },
19
+ "devDependencies": {
20
+ "connect": "^3.7.0"
21
+ }
22
+ }
@@ -0,0 +1,7 @@
1
+ import type { IncomingMessage } from "connect";
2
+
3
+ export const forbidRelativePaths = (req: IncomingMessage) => {
4
+ if (req.url?.includes("..")) {
5
+ throw new Error("Relative paths are forbidden");
6
+ }
7
+ };
@@ -0,0 +1,65 @@
1
+ import { createReadStream, statSync } from "fs";
2
+ import type { ServerResponse } from "http";
3
+
4
+ import mime from "mime";
5
+ import debug from "debug";
6
+ import type { IncomingMessage } from "connect";
7
+
8
+ import type { TaskResult } from "@/assets/idempotentTask";
9
+
10
+ export const sendTaskResult = (
11
+ req: IncomingMessage,
12
+ res: ServerResponse<IncomingMessage>,
13
+ taskResult: TaskResult,
14
+ ) => {
15
+ const { cachePath, md5Sum } = taskResult;
16
+ const filePath = cachePath;
17
+ const headers = {
18
+ etag: md5Sum,
19
+ };
20
+ const log = debug("@ef:sendfile");
21
+ try {
22
+ log(`Sending file ${filePath}`);
23
+ const stats = statSync(filePath);
24
+
25
+ if (req.headers.range) {
26
+ let [x, y] = req.headers.range.replace("bytes=", "").split("-");
27
+ let end = parseInt(y ?? "0", 10) || stats.size - 1;
28
+ let start = parseInt(x ?? "0", 10) || 0;
29
+
30
+ if (end >= stats.size) {
31
+ end = stats.size - 1;
32
+ }
33
+
34
+ if (start >= stats.size) {
35
+ log("Range start is greater than file size");
36
+ res.setHeader("Content-Range", `bytes */${stats.size}`);
37
+ res.statusCode = 416;
38
+ return res.end();
39
+ }
40
+
41
+ res.writeHead(206, {
42
+ ...headers,
43
+ "Content-Type": mime.getType(filePath) || "text/plain",
44
+ "Content-Range": `bytes ${start}-${end}/${stats.size}`,
45
+ "Content-Length": end - start + 1,
46
+ "Accept-Ranges": "bytes",
47
+ });
48
+ log(`Sending ${filePath} range ${start}-${end}/${stats.size}`);
49
+ const readStream = createReadStream(filePath, { start, end });
50
+ readStream.pipe(res);
51
+ } else {
52
+ res.writeHead(200, {
53
+ ...headers,
54
+ "Content-Type": mime.getType(filePath) || "text/plain",
55
+ "Contetn-Length": stats.size,
56
+ });
57
+ log(`Sending ${filePath}`);
58
+ const readStream = createReadStream(filePath);
59
+ readStream.pipe(res);
60
+ }
61
+ } catch (error) {
62
+ log("Error sending file", error);
63
+ console.error(error);
64
+ }
65
+ };
@@ -0,0 +1,95 @@
1
+ import path from "path";
2
+
3
+ import { Plugin } from "vite";
4
+ import debug from "debug";
5
+
6
+ import { findOrCreateCaptions } from "@/assets/tasks/findOrCreateCaptions";
7
+ import { generateTrackFragmentIndex } from "@/assets/tasks/generateTrackFragmentIndex";
8
+ import { generateTrack } from "@/assets/tasks/generateTrack";
9
+ import { cacheImage } from "@/assets/tasks/cacheImage";
10
+ import { md5FilePath } from "@/assets/md5";
11
+
12
+ import { forbidRelativePaths } from "./forbidRelativePaths";
13
+ import { sendTaskResult } from "./sendTaskResult";
14
+
15
+ interface VitePluginEditframeOptions {
16
+ root: string;
17
+ cacheRoot: string;
18
+ }
19
+
20
+ export const vitePluginEditframe = (options: VitePluginEditframeOptions) => {
21
+ return {
22
+ name: "vite-plugin-editframe",
23
+
24
+ configureServer(server) {
25
+ server.middlewares.use(async (req, res, next) => {
26
+ const log = debug("@ef:vite-plugin");
27
+ // Forbid relative paths in any request
28
+ if (req.url?.startsWith("/@ef")) {
29
+ forbidRelativePaths(req);
30
+ } else {
31
+ return next();
32
+ }
33
+
34
+ log(`Handling ${req.url}`);
35
+
36
+ const requestPath = req.url.replace(new RegExp("^/@ef-[^/]+/"), "");
37
+ const assetPath = requestPath.replace(/\?.*$/, "");
38
+ const absolutePath = path
39
+ .join(options.root, assetPath)
40
+ .replace("dist/", "src/");
41
+
42
+ options.cacheRoot = options.cacheRoot.replace("dist/", "src/");
43
+
44
+ const efPrefix = req.url.split("/")[1];
45
+
46
+ switch (efPrefix) {
47
+ case "@ef-asset": {
48
+ if (req.method !== "HEAD") {
49
+ res.writeHead(405, { Allow: "HEAD" });
50
+ res.end();
51
+ }
52
+ md5FilePath(absolutePath)
53
+ .then((md5) => {
54
+ res.writeHead(200, {
55
+ etag: md5,
56
+ });
57
+ res.end();
58
+ })
59
+ .catch(next);
60
+ break;
61
+ }
62
+ case "@ef-track-fragment-index": {
63
+ log(`Serving track fragment index for ${absolutePath}`);
64
+ generateTrackFragmentIndex(options.cacheRoot, absolutePath)
65
+ .then((taskResult) => sendTaskResult(req, res, taskResult))
66
+ .catch(next);
67
+ break;
68
+ }
69
+ case "@ef-track": {
70
+ log(`Serving track for ${absolutePath}`);
71
+ generateTrack(options.cacheRoot, absolutePath, req.url)
72
+ .then((taskResult) => sendTaskResult(req, res, taskResult))
73
+ .catch(next);
74
+ break;
75
+ }
76
+ case "@ef-captions":
77
+ log(`Serving captions for ${absolutePath}`);
78
+ findOrCreateCaptions(options.cacheRoot, absolutePath)
79
+ .then((taskResult) => sendTaskResult(req, res, taskResult))
80
+ .catch(next);
81
+ break;
82
+ case "@ef-image":
83
+ log(`Serving image file ${absolutePath}`);
84
+ cacheImage(options.cacheRoot, absolutePath)
85
+ .then((taskResult) => sendTaskResult(req, res, taskResult))
86
+ .catch(next);
87
+ break;
88
+ default:
89
+ log(`Unknown asset type ${efPrefix}`);
90
+ break;
91
+ }
92
+ });
93
+ },
94
+ } satisfies Plugin;
95
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "esnext",
4
+ "module": "ES2015",
5
+ "outDir": "./dist",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "moduleResolution": "node"
11
+ },
12
+ "include": ["src/**/*.ts"],
13
+ "exclude": ["node_modules"],
14
+ "extends": "../../tsconfig.json"
15
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,41 @@
1
+ import path from "path";
2
+ import { defineConfig } from "vite";
3
+ import tsconfigPaths from "vite-tsconfig-paths";
4
+ import rollupTsConfigPaths from "rollup-plugin-tsconfig-paths";
5
+
6
+ const __dirname = path.dirname(new URL(import.meta.url).pathname);
7
+
8
+ export default defineConfig({
9
+ esbuild: {
10
+ target: "es2022",
11
+ platform: "browser",
12
+ include: /\.(m?[jt]s|[jt]sx)$/,
13
+ exclude: [],
14
+ },
15
+ plugins: [tsconfigPaths()],
16
+ appType: "custom",
17
+ root: __dirname,
18
+ css: {
19
+ postcss: __dirname,
20
+ },
21
+ build: {
22
+ ssr: true,
23
+ target: "es2022",
24
+ rollupOptions: {
25
+ treeshake: "recommended",
26
+ output: {
27
+ inlineDynamicImports: false,
28
+ preserveModules: true,
29
+ },
30
+ plugins: [rollupTsConfigPaths({})],
31
+ },
32
+ emptyOutDir: true,
33
+ outDir: path.join(__dirname, "dist/"),
34
+ lib: {
35
+ entry: path.join(__dirname, "src", "vite-plugin-editframe.ts"),
36
+ name: "vite-plugin-editframe",
37
+ fileName: "vite-plugin-editframe",
38
+ formats: ["es"],
39
+ },
40
+ },
41
+ });