opex792 commited on
Commit
426e8cd
·
verified ·
1 Parent(s): f71110a

Upload 4 files

Browse files
Files changed (4) hide show
  1. Dockerfile +24 -2
  2. cleanup.js +55 -0
  3. index.js +255 -0
  4. package.json +28 -0
Dockerfile CHANGED
@@ -1,5 +1,27 @@
1
- FROM kazhar/ffmpeg-api:latest
 
 
2
 
3
- ENV EXTERNAL_PORT=7860
 
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  EXPOSE 7860
 
 
 
 
 
 
1
+ # Используем официальный образ Node.js 18.
2
+ # ESM-модули и fetch API стабильны в этой версии.
3
+ FROM node:18-slim
4
 
5
+ # Устанавливаем рабочую директорию внутри контейнера
6
+ WORKDIR /usr/src/app
7
 
8
+ # Копируем файлы package.json и package-lock.json
9
+ COPY package*.json ./
10
+
11
+ # Устанавливаем зависимости проекта
12
+ # --only=production гарантирует, что установятся только производственные зависимости
13
+ RUN npm install --only=production
14
+
15
+ # Копируем остальной код приложения в рабочую директорию
16
+ COPY . .
17
+
18
+ # Создаем директорию для временных файлов
19
+ RUN mkdir -p /tmp/huggingface-runner
20
+
21
+ # Открываем порт, на котором будет работать приложение (стандартный для Spaces - 7860)
22
  EXPOSE 7860
23
+
24
+ # Указываем команду для запуска приложения при старте контейнера
25
+ # Используем массив для корректной обработки сигналов
26
+ CMD [ "node", "index.js" ]
27
+
cleanup.js ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import cron from 'node-cron';
2
+ import fs from 'fs/promises';
3
+ import path from 'path';
4
+
5
+ const TEMP_DIR = '/tmp/huggingface-runner';
6
+ const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 часа в миллисекундах
7
+
8
+ /**
9
+ * Удаляет старые файлы из временной директории.
10
+ */
11
+ async function cleanupOldFiles() {
12
+ console.log('Запуск задачи по очистке старых файлов...');
13
+ try {
14
+ const files = await fs.readdir(TEMP_DIR);
15
+ const now = Date.now();
16
+
17
+ for (const file of files) {
18
+ const filePath = path.join(TEMP_DIR, file);
19
+ try {
20
+ const stats = await fs.stat(filePath);
21
+ const fileAge = now - stats.mtime.getTime();
22
+
23
+ if (fileAge > MAX_AGE_MS) {
24
+ await fs.unlink(filePath);
25
+ console.log(`Удален старый файл: ${filePath}`);
26
+ }
27
+ } catch (statError) {
28
+ // Если файл был удален между readdir и stat, просто игнорируем ошибку
29
+ if (statError.code !== 'ENOENT') {
30
+ console.error(`Не удалось получить информацию о файле ${filePath}:`, statError);
31
+ }
32
+ }
33
+ }
34
+ } catch (readDirError) {
35
+ if (readDirError.code === 'ENOENT') {
36
+ console.log('Временная директория не существует, очистка не требуется.');
37
+ } else {
38
+ console.error('Ошибка при чтении временной директории:', readDirError);
39
+ }
40
+ }
41
+ console.log('Очистка завершена.');
42
+ }
43
+
44
+ /**
45
+ * Запускает периодическую задачу (cron job) для очистки.
46
+ * Задача будет выполняться каждый час.
47
+ */
48
+ export function startCleanupJob() {
49
+ // Запускаем сразу при старте, а затем каждый час
50
+ cleanupOldFiles();
51
+
52
+ cron.schedule('0 * * * *', cleanupOldFiles); // '0 * * * *' - каждую 0-ю минуту каждого часа
53
+ console.log('Задача по очистке запланирована на запуск каждый час.');
54
+ }
55
+
index.js ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express from 'express';
2
+ import multer from 'multer';
3
+ import { spawn } from 'child_process';
4
+ import { writeFile, unlink, createReadStream } from 'fs';
5
+ import { fileURLToPath } from 'url';
6
+ import path, { dirname } from 'path';
7
+ import { v4 as uuidv4 } from 'uuid';
8
+ import fetch from 'node-fetch';
9
+ import { startCleanupJob } from './cleanup.js';
10
+
11
+ // --- НАСТРОЙКА ---
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ const TEMP_DIR = '/tmp/huggingface-runner'; // Директория для временных файлов
15
+
16
+ const app = express();
17
+ const PORT = process.env.PORT || 7860; // Hugging Face Spaces использует порт 7860
18
+
19
+ // --- MIDDLEWARE ---
20
+ app.use(express.json());
21
+ app.use(express.urlencoded({ extended: true }));
22
+
23
+ // Настройка Multer для загрузки файлов в память
24
+ const storage = multer.memoryStorage();
25
+ const upload = multer({ storage: storage });
26
+
27
+ // --- ЛОГИКА ---
28
+
29
+ /**
30
+ * Функция для выполнения внешней команды.
31
+ * @param {string} command - Команда для выполнения (например, 'ffmpeg').
32
+ * @param {Array<string>} args - Аргументы для команды.
33
+ * @param {Buffer | null} inputBuffer - Входные данные для stdin.
34
+ * @returns {Promise<{stdout: Buffer, stderr: string}>} - Промис с результатом.
35
+ */
36
+ const executeCommand = (command, args, inputBuffer = null) => {
37
+ return new Promise((resolve, reject) => {
38
+ const process = spawn(command, args);
39
+ let stdoutChunks = [];
40
+ let stderrChunks = [];
41
+
42
+ process.stdout.on('data', (data) => stdoutChunks.push(data));
43
+ process.stderr.on('data', (data) => stderrChunks.push(data));
44
+
45
+ process.on('close', (code) => {
46
+ const stdout = Buffer.concat(stdoutChunks);
47
+ const stderr = Buffer.concat(stderrChunks).toString('utf8');
48
+ if (code === 0) {
49
+ resolve({ stdout, stderr });
50
+ } else {
51
+ const error = new Error(`Процесс завершился с кодом ${code}.\nStderr: ${stderr}`);
52
+ error.code = code;
53
+ error.stderr = stderr;
54
+ reject(error);
55
+ }
56
+ });
57
+
58
+ process.on('error', (err) => reject(err));
59
+
60
+ if (inputBuffer) {
61
+ process.stdin.write(inputBuffer);
62
+ process.stdin.end();
63
+ }
64
+ });
65
+ };
66
+
67
+
68
+ /**
69
+ * Скачивает файл по URL.
70
+ * @param {string} url - URL файла.
71
+ * @returns {Promise<Buffer>} - Промис с буфером файла.
72
+ */
73
+ const downloadFile = async (url) => {
74
+ const response = await fetch(url);
75
+ if (!response.ok) {
76
+ throw new Error(`Не удалось скачать файл: ${response.statusText}`);
77
+ }
78
+ const arrayBuffer = await response.arrayBuffer();
79
+ return Buffer.from(arrayBuffer);
80
+ };
81
+
82
+ // --- МАРШРУТЫ API ---
83
+
84
+ app.get('/', (req, res) => {
85
+ res.send('Сервер удаленного выполнения команд готов к работе!');
86
+ });
87
+
88
+
89
+ /**
90
+ * МАРШРУТ 1: Потоковая обработка (без сохранения)
91
+ * Принимает файл (загрузка или URL) и команду, возвращает результат потоком.
92
+ * POST /api/run/stream
93
+ * Body: { command: "ваша команда", args: ["аргумент1", "аргумент2"], file_url?: "URL файла" }
94
+ * или multipart/form-data с полями command, args и file.
95
+ */
96
+ app.post('/api/run/stream', upload.single('file'), async (req, res) => {
97
+ try {
98
+ const { command, args: argsJson, file_url } = req.body;
99
+ const file = req.file;
100
+
101
+ if (!command) {
102
+ return res.status(400).send({ error: 'Параметр "command" обязателен.' });
103
+ }
104
+
105
+ let args;
106
+ try {
107
+ args = argsJson ? JSON.parse(argsJson) : [];
108
+ } catch(e) {
109
+ return res.status(400).send({ error: 'Параметр "args" должен быть валидным JSON массивом.' });
110
+ }
111
+
112
+ let inputBuffer;
113
+ if (file) {
114
+ inputBuffer = file.buffer;
115
+ } else if (file_url) {
116
+ inputBuffer = await downloadFile(file_url);
117
+ } else {
118
+ return res.status(400).send({ error: 'Необходимо предоставить файл через "file" или "file_url".' });
119
+ }
120
+
121
+ const { stdout, stderr } = await executeCommand(command, args, inputBuffer);
122
+
123
+ console.log(`Stderr для ${command}: ${stderr}`);
124
+
125
+ // Отправляем результат как бинарные данные
126
+ res.setHeader('Content-Type', 'application/octet-stream');
127
+ res.send(stdout);
128
+
129
+ } catch (error) {
130
+ console.error('Ошибка в /api/run/stream:', error);
131
+ res.status(500).send({
132
+ error: 'Ошибка выполнения команды.',
133
+ message: error.message,
134
+ stderr: error.stderr || 'N/A'
135
+ });
136
+ }
137
+ });
138
+
139
+
140
+ /**
141
+ * МАРШРУТ 2: Обработка с сохранением файла
142
+ * Сохраняет файл, выполняет команду, сохраняет результат и отдает ссылку.
143
+ * POST /api/run/file
144
+ */
145
+ app.post('/api/run/file', upload.single('file'), async (req, res) => {
146
+ const tempFiles = []; // Массив для отслеживания созданных файлов
147
+
148
+ try {
149
+ const { command, args: argsJson, file_url, output_filename } = req.body;
150
+ const file = req.file;
151
+
152
+ if (!command) {
153
+ return res.status(400).send({ error: 'Параметр "command" обязателен.' });
154
+ }
155
+
156
+ let args;
157
+ try {
158
+ args = argsJson ? JSON.parse(argsJson) : [];
159
+ } catch(e) {
160
+ return res.status(400).send({ error: 'Параметр "args" должен быть валидным JSON массивом.' });
161
+ }
162
+
163
+ let inputBuffer;
164
+ if (file) {
165
+ inputBuffer = file.buffer;
166
+ } else if (file_url) {
167
+ inputBuffer = await downloadFile(file_url);
168
+ } else {
169
+ return res.status(400).send({ error: 'Необходимо предоставить файл через "file" или "file_url".' });
170
+ }
171
+
172
+ // Заменяем плейсхолдеры в аргументах
173
+ const inputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${file?.originalname || 'input'}`);
174
+ const outputFilePath = path.join(TEMP_DIR, `${uuidv4()}-${output_filename || 'output'}`);
175
+
176
+ tempFiles.push(inputFilePath, outputFilePath);
177
+
178
+ const processedArgs = args.map(arg =>
179
+ arg.replace('{INPUT_FILE}', inputFilePath)
180
+ .replace('{OUTPUT_FILE}', outputFilePath)
181
+ );
182
+
183
+ // Сохраняем входной файл
184
+ await new Promise((resolve, reject) => {
185
+ writeFile(inputFilePath, inputBuffer, (err) => err ? reject(err) : resolve());
186
+ });
187
+
188
+ // Выполняем команду
189
+ // Здесь stdin не используется, так как команда работает с файлами на диске
190
+ const { stderr } = await executeCommand(command, processedArgs);
191
+ console.log(`Stderr для ${command}: ${stderr}`);
192
+
193
+ // Отправляем ссылку на скачивание файла
194
+ const fileId = path.basename(outputFilePath);
195
+ res.status(200).json({
196
+ message: 'Команда выполнена успешно.',
197
+ download_url: `/api/download/${fileId}`,
198
+ stderr: stderr
199
+ });
200
+
201
+ } catch (error) {
202
+ console.error('Ошибка в /api/run/file:', error);
203
+
204
+ // Удаляем временные файлы в случае ошибки
205
+ for (const filePath of tempFiles) {
206
+ unlink(filePath, (err) => {
207
+ if (err) console.error(`Не удалось удалить временный файл ${filePath}:`, err);
208
+ });
209
+ }
210
+
211
+ res.status(500).send({
212
+ error: 'Ошибка выполнения команды.',
213
+ message: error.message,
214
+ stderr: error.stderr || 'N/A'
215
+ });
216
+ }
217
+ });
218
+
219
+
220
+ /**
221
+ * МАРШРУТ 3: Скачивание файла по ID
222
+ * GET /api/download/:fileId
223
+ */
224
+ app.get('/api/download/:fileId', (req, res) => {
225
+ const { fileId } = req.params;
226
+ // Важно: проверяем, что fileId не содержит ".." для предотвращения выхода из директории
227
+ if (fileId.includes('..')) {
228
+ return res.status(400).send('Неверный ID файла.');
229
+ }
230
+
231
+ const filePath = path.join(TEMP_DIR, fileId);
232
+
233
+ // Проверяем, существует ли файл, и отправляем его
234
+ const stream = createReadStream(filePath);
235
+ stream.on('error', (err) => {
236
+ if (err.code === 'ENOENT') {
237
+ res.status(404).send('Файл не найден или был удален.');
238
+ } else {
239
+ res.status(500).send('Ошибка сервера.');
240
+ }
241
+ });
242
+
243
+ res.setHeader('Content-Type', 'application/octet-stream');
244
+ stream.pipe(res);
245
+ });
246
+
247
+
248
+ // --- ЗАПУСК СЕРВЕРА И ОЧИСТКИ ---
249
+
250
+ app.listen(PORT, () => {
251
+ console.log(`Сервер запущен на порту ${PORT}`);
252
+ // Запускаем задачу по очистке старых файлов
253
+ startCleanupJob();
254
+ });
255
+
package.json ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "huggingface-remote-runner",
3
+ "version": "1.0.0",
4
+ "description": "API для удаленного выполнения команд в Hugging Face Space",
5
+ "main": "index.js",
6
+ "type": "module",
7
+ "scripts": {
8
+ "start": "node index.js",
9
+ "test": "echo \"Error: no test specified\" && exit 1"
10
+ },
11
+ "keywords": [
12
+ "huggingface",
13
+ "api",
14
+ "remote-execution",
15
+ "nodejs",
16
+ "esm"
17
+ ],
18
+ "author": "",
19
+ "license": "ISC",
20
+ "dependencies": {
21
+ "express": "^4.18.2",
22
+ "multer": "^1.4.5-lts.1",
23
+ "node-cron": "^3.0.3",
24
+ "node-fetch": "^3.3.2",
25
+ "uuid": "^9.0.1"
26
+ }
27
+ }
28
+