-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathserver.js
More file actions
executable file
·176 lines (151 loc) · 6.61 KB
/
server.js
File metadata and controls
executable file
·176 lines (151 loc) · 6.61 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
#!/usr/bin/env node
// SPDX-License-Identifier: AGPL-3.0-or-later
import { WebSocketServer } from 'ws';
import { spawn } from 'node:child_process';
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import os from 'node:os';
const PORT = 3859;
// (A) パッケージ内部のディレクトリ
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PACKAGE_LIB_DIR = path.join(__dirname, 'lib');
// (B) 実行時のカレントディレクトリ
const WORKING_DIR = process.env.PWD || process.env.INIT_CWD || process.cwd();
const server = http.createServer((req, res) => {
const decodedUrl = decodeURIComponent(req.url || '');
// 先頭の / を除去し、空文字の場合は index.html にする
// これにより path.join(WORKING_DIR, 'index.html') となり、確実に直下を探せる
const trimmedPath = decodedUrl.replace(/^\/+/, '') || 'index.html';
const localPath = path.join(WORKING_DIR, trimmedPath);
const packagePath = path.join(PACKAGE_LIB_DIR, trimmedPath);
/** @type {(f: string, b: string) => void} */
const serveFile = (filePath, baseDir) => {
// 1. 両方のパスを絶対パスかつ正規化された状態にする (Resolve & Realpath)
// baseDir はあらかじめ絶対パスにしておくと効率的です
const absoluteBase = path.resolve(baseDir);
const absoluteTarget = path.resolve(filePath);
// 2. ターゲットがベースディレクトリの中に収まっているかチェック
// .startsWith を使うことで、ディレクトリの外に出ることを防ぐ
const isSafe = absoluteTarget.startsWith(absoluteBase);
// FIXME: シンボリックリンクを用いた攻撃は防げないけど、どうせローカル
// FIXME: 大文字小文字混ぜられたらダメかもだけど、Windowsはサポートしないのでどうでもいい
if (!isSafe) {
res.writeHead(403);
res.end('403 Forbidden');
return;
}
fs.readFile(filePath, (err, content) => {
if (err) {
// ファイルが存在しない場合はここではなく fs.access 側で制御する
res.writeHead(500);
res.end(`Server Error: ${err.code}`);
} else {
const extname = String(path.extname(filePath)).toLowerCase();
const mimeTypes = {
'.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css',
'.png': 'image/png', '.jpg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
};
// @ts-expect-error これはundefinedでいい
res.writeHead(200, { 'Content-Type': mimeTypes[extname] ?? 'application/octet-stream' });
res.end(content, 'utf-8');
}
});
};
// 1. カレントディレクトリ (WORKING_DIR) を最優先にチェック
fs.access(localPath, fs.constants.F_OK, (err) => {
if (!err) {
serveFile(localPath, WORKING_DIR);
} else {
// 2. なければパッケージ側 (PACKAGE_LIB_DIR) をチェック
fs.access(packagePath, fs.constants.F_OK, (pkgErr) => {
if (!pkgErr) {
serveFile(packagePath, PACKAGE_LIB_DIR);
} else {
res.writeHead(404);
res.end('404 Not Found');
}
});
}
});
});
const wss = new WebSocketServer({ server });
wss.on('connection', (ws) => {
console.log('Client connected for rendering');
/** @type {import('child_process').ChildProcessWithoutNullStreams | null} */
let ffmpeg = null;
let config = null;
let isCompleted = false;
// 変更: OSの一時ディレクトリを使用し、セッション固有のファイル名を生成
const sessionId = Date.now().toString(36) + '-' + Math.random().toString(36).substring(2, 7);
const tempVideoPath = path.join(os.tmpdir(), `temp_video_${sessionId}.mp4`);
const tempAudioPath = path.join(os.tmpdir(), `temp_audio_${sessionId}.wav`);
const outputPath = path.join(WORKING_DIR, 'output.mp4'); // 出力先はカレントディレクトリのまま
// 一時ファイルを削除するヘルパー関数
const cleanupTempFiles = () => {
fs.unlink(tempVideoPath, () => {});
fs.unlink(tempAudioPath, () => {});
};
ws.on('message', (message, isBinary) => {
if (!isBinary) {
const data = JSON.parse(message.toString());
if (data.type === 'config') {
config = data;
isCompleted = false;
console.log(`Starting FFmpeg... Temp files: ${tempVideoPath}`);
ffmpeg = spawn('ffmpeg', [
'-y', '-f', 'rawvideo', '-vcodec', 'rawvideo',
'-s', `${config.width}x${config.height}`, '-pix_fmt', 'rgba', '-r', `${config.fps}`,
'-i', '-',
'-c:v', 'libx264', '-preset', 'fast', '-pix_fmt', 'yuv420p',
tempVideoPath
]);
ffmpeg.stderr.on('data', console.log);
ffmpeg.on('close', (code) => {
// 正常終了かつ音声が存在する場合のみマージを実行
if (isCompleted && code === 0 && fs.existsSync(tempAudioPath)) {
mergeAudio();
} else if (!isCompleted) {
console.log('FFmpeg stopped unexpectedly. Cleanup complete.');
}
});
} else if (data.type === 'end') {
isCompleted = true; // クライアントからの終了宣言
if (ffmpeg) ffmpeg.stdin.end();
}
} else {
const header = message.slice(0, 4).toString();
if (header === 'RIFF') {
console.log('Received mixed audio WAV from client.');
// @ts-expect-error: ws buffer type
fs.writeFileSync(tempAudioPath, message);
} else {
if (ffmpeg) ffmpeg.stdin.write(message);
}
}
});
ws.on('close', () => {
// 正常終了前に切断された場合(タブ閉じ、ネットワークエラー等)
if (!isCompleted && ffmpeg) {
console.log('Client disconnected during render. Aborting and cleaning up...');
ffmpeg.kill('SIGKILL'); // FFmpegプロセスを強制終了
cleanupTempFiles(); // ゴミファイルを削除
} else if (ffmpeg && !ffmpeg.stdin.destroyed) {
ffmpeg.stdin.end();
}
});
function mergeAudio() {
console.log('Merging audio and video...');
const mergeProc = spawn('ffmpeg', [
'-y', '-i', tempVideoPath, '-i', tempAudioPath,
'-c:v', 'copy', '-c:a', 'aac', '-shortest', outputPath
]);
mergeProc.on('close', (_code) => {
console.log(`Render complete! Result saved as ${outputPath}`);
cleanupTempFiles(); // 成功時も一時ファイルを削除
});
}
});
server.listen(PORT, () => console.log(`Server running at http://localhost:${PORT}`));