n8n-nodes-vidflow 0.1.2 → 0.1.6
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
CHANGED
|
@@ -22,6 +22,16 @@ npm run release
|
|
|
22
22
|
git status --short
|
|
23
23
|
git add .
|
|
24
24
|
git commit -m "chore: prepare release"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
本地发布:
|
|
28
|
+
|
|
29
|
+
npm version patch
|
|
30
|
+
npm run release:check
|
|
31
|
+
|
|
32
|
+
npm publish --access public
|
|
33
|
+
|
|
34
|
+
|
|
25
35
|
```
|
|
26
36
|
|
|
27
37
|
然后再执行:
|
|
@@ -31,3 +41,18 @@ npm version patch
|
|
|
31
41
|
npm run release:check
|
|
32
42
|
npm run release
|
|
33
43
|
```
|
|
44
|
+
|
|
45
|
+
## 节点运行说明
|
|
46
|
+
|
|
47
|
+
- `Douyin -> Download Video` 支持直接粘贴抖音分享文案,节点会优先从整段文本里提取抖音链接再下载视频。
|
|
48
|
+
- `Douyin -> Download Video` 默认会优先尝试分块并发下载;当源站支持 `Range` 时可断点续传,不支持时会自动降级为单连接流式下载。
|
|
49
|
+
- `Douyin -> Download Video` 新增 `Chunk Size (MB)` 和 `Worker Count` 两个调优参数,默认分别为 `5` 和 `8`,运行时会限制在 `1-64 MB` 与 `1-16` 的安全范围内。
|
|
50
|
+
- `Audio -> Extract From Video` 依赖本机 `ffmpeg`,默认路径是 `/usr/bin/ffmpeg`。
|
|
51
|
+
- `Transcription -> Transcribe` 会调用 BCut 转录接口,适合接在音频提取之后使用。
|
|
52
|
+
|
|
53
|
+
## 前端进度展示
|
|
54
|
+
|
|
55
|
+
- 抖音下载和 BCut 转录都会通过 `sendMessageToUI` 向 n8n 前端发送进度消息。
|
|
56
|
+
- 进度消息统一包含 `resource`、`operation`、`stage`、`progressPercent`,下载场景还会附带 `downloadedBytes` 和 `totalBytes`。
|
|
57
|
+
- 抖音下载会根据能力检测展示 `chunked_start`、`resuming`、`downloading`、`stream_start`、`completed` 等阶段,并在输出 JSON 里返回 `downloadMode`、`chunkSizeMb`、`workerCount`。
|
|
58
|
+
- BCut 转录展示的是阶段性进度,不是官方返回的真实百分比;下载进度则基于实际已下载字节数计算。
|
|
@@ -1,8 +1,43 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
2
35
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
36
|
exports.Vidflow = void 0;
|
|
4
37
|
const node_child_process_1 = require("node:child_process");
|
|
5
38
|
const node_fs_1 = require("node:fs");
|
|
39
|
+
const http = __importStar(require("node:http"));
|
|
40
|
+
const https = __importStar(require("node:https"));
|
|
6
41
|
const node_os_1 = require("node:os");
|
|
7
42
|
const node_path_1 = require("node:path");
|
|
8
43
|
const node_util_1 = require("node:util");
|
|
@@ -12,7 +47,14 @@ const BCUT_MODEL_ID = '8';
|
|
|
12
47
|
const BCUT_QUERY_MODEL_ID = 7;
|
|
13
48
|
const BCUT_POLL_STATE_FAILED = 3;
|
|
14
49
|
const BCUT_POLL_STATE_COMPLETED = 4;
|
|
50
|
+
const DEFAULT_FFMPEG_PATH = '/usr/bin/ffmpeg';
|
|
15
51
|
const DOUYIN_MOBILE_USER_AGENT = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/121.0.2277.107 Version/17.0 Mobile/15E148 Safari/604.1';
|
|
52
|
+
const DOUYIN_DOWNLOAD_CHUNK_SIZE = 5 * 1024 * 1024;
|
|
53
|
+
const DOUYIN_DOWNLOAD_WORKERS = 8;
|
|
54
|
+
const DOUYIN_DOWNLOAD_MAX_CHUNK_SIZE_MB = 64;
|
|
55
|
+
const DOUYIN_DOWNLOAD_MAX_WORKERS = 16;
|
|
56
|
+
const DOUYIN_DOWNLOAD_MAX_REDIRECTS = 5;
|
|
57
|
+
const DOUYIN_DOWNLOAD_MAX_RETRIES = 3;
|
|
16
58
|
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
|
17
59
|
const audioExtractDisplayOptions = {
|
|
18
60
|
resource: ['audio'],
|
|
@@ -198,11 +240,11 @@ const audioDescription = [
|
|
|
198
240
|
displayName: 'FFmpeg Path',
|
|
199
241
|
name: 'ffmpegPath',
|
|
200
242
|
type: 'string',
|
|
201
|
-
default:
|
|
243
|
+
default: DEFAULT_FFMPEG_PATH,
|
|
202
244
|
displayOptions: {
|
|
203
245
|
show: audioExtractDisplayOptions,
|
|
204
246
|
},
|
|
205
|
-
description: '
|
|
247
|
+
description: 'FFmpeg executable path used for audio extraction',
|
|
206
248
|
},
|
|
207
249
|
];
|
|
208
250
|
const douyinDescription = [
|
|
@@ -261,6 +303,36 @@ const douyinDescription = [
|
|
|
261
303
|
},
|
|
262
304
|
description: 'Optional output file name override',
|
|
263
305
|
},
|
|
306
|
+
{
|
|
307
|
+
displayName: 'Chunk Size (MB)',
|
|
308
|
+
name: 'chunkSizeMb',
|
|
309
|
+
type: 'number',
|
|
310
|
+
typeOptions: {
|
|
311
|
+
minValue: 1,
|
|
312
|
+
maxValue: DOUYIN_DOWNLOAD_MAX_CHUNK_SIZE_MB,
|
|
313
|
+
numberPrecision: 0,
|
|
314
|
+
},
|
|
315
|
+
default: 5,
|
|
316
|
+
displayOptions: {
|
|
317
|
+
show: douyinDownloadDisplayOptions,
|
|
318
|
+
},
|
|
319
|
+
description: 'Chunk size used for resumable downloads when the server supports range requests. Keep it between 1 and 64 MB.',
|
|
320
|
+
},
|
|
321
|
+
{
|
|
322
|
+
displayName: 'Worker Count',
|
|
323
|
+
name: 'workerCount',
|
|
324
|
+
type: 'number',
|
|
325
|
+
typeOptions: {
|
|
326
|
+
minValue: 1,
|
|
327
|
+
maxValue: DOUYIN_DOWNLOAD_MAX_WORKERS,
|
|
328
|
+
numberPrecision: 0,
|
|
329
|
+
},
|
|
330
|
+
default: 8,
|
|
331
|
+
displayOptions: {
|
|
332
|
+
show: douyinDownloadDisplayOptions,
|
|
333
|
+
},
|
|
334
|
+
description: 'Number of concurrent chunk workers used for resumable downloads. Keep it between 1 and 16.',
|
|
335
|
+
},
|
|
264
336
|
];
|
|
265
337
|
function getBcutHeaders() {
|
|
266
338
|
return {
|
|
@@ -394,11 +466,68 @@ function getHeaderValue(headers, name) {
|
|
|
394
466
|
return headerValue;
|
|
395
467
|
}
|
|
396
468
|
function extractFirstUrl(input) {
|
|
397
|
-
const
|
|
469
|
+
const douyinMatch = input.match(/https?:\/\/(?:v\.douyin\.com|www\.douyin\.com|iesdouyin\.com)\/[^\s]+/i);
|
|
470
|
+
const match = douyinMatch !== null && douyinMatch !== void 0 ? douyinMatch : input.match(/https?:\/\/[^\s]+/i);
|
|
398
471
|
if (!match) {
|
|
399
472
|
throw new n8n_workflow_1.ApplicationError('No URL was found in Share Text Or Link.');
|
|
400
473
|
}
|
|
401
|
-
return match[0]
|
|
474
|
+
return sanitizeSharedLink(match[0]);
|
|
475
|
+
}
|
|
476
|
+
function sanitizeSharedLink(input) {
|
|
477
|
+
return input
|
|
478
|
+
.trim()
|
|
479
|
+
.replace(/[),.;!?]+$/g, '')
|
|
480
|
+
.replace(/[,。;:!?]+$/g, '');
|
|
481
|
+
}
|
|
482
|
+
function getDouyinDownloadDirectory(downloadId) {
|
|
483
|
+
return (0, node_path_1.join)((0, node_os_1.tmpdir)(), 'vidflow-douyin', sanitizeFileName(downloadId));
|
|
484
|
+
}
|
|
485
|
+
function getDouyinChunkCount(totalSize, chunkSize) {
|
|
486
|
+
return Math.ceil(totalSize / chunkSize);
|
|
487
|
+
}
|
|
488
|
+
function getDouyinPartFilePath(outputFile, index) {
|
|
489
|
+
return `${outputFile}.part${String(index)}`;
|
|
490
|
+
}
|
|
491
|
+
function getDouyinStateFilePath(outputFile) {
|
|
492
|
+
return `${outputFile}.dl.json`;
|
|
493
|
+
}
|
|
494
|
+
function getDouyinMimeTypeFromHeaders(headers) {
|
|
495
|
+
const contentType = headers['content-type'];
|
|
496
|
+
const rawValue = Array.isArray(contentType) ? contentType[0] : contentType;
|
|
497
|
+
return (rawValue === null || rawValue === void 0 ? void 0 : rawValue.split(';')[0]) || 'video/mp4';
|
|
498
|
+
}
|
|
499
|
+
function getDouyinContentLength(headers) {
|
|
500
|
+
const contentLength = headers['content-length'];
|
|
501
|
+
const rawValue = Array.isArray(contentLength) ? contentLength[0] : contentLength;
|
|
502
|
+
return Number(rawValue);
|
|
503
|
+
}
|
|
504
|
+
function supportsDouyinRange(headers) {
|
|
505
|
+
var _a;
|
|
506
|
+
const acceptRanges = headers['accept-ranges'];
|
|
507
|
+
const rawValue = Array.isArray(acceptRanges) ? acceptRanges[0] : acceptRanges;
|
|
508
|
+
return (_a = rawValue === null || rawValue === void 0 ? void 0 : rawValue.toLowerCase().includes('bytes')) !== null && _a !== void 0 ? _a : false;
|
|
509
|
+
}
|
|
510
|
+
function emitDouyinDownloadProgress(context, itemIndex, percent, downloadedBytes, totalBytes, stage = 'downloading', extra) {
|
|
511
|
+
context.sendMessageToUI({
|
|
512
|
+
itemIndex,
|
|
513
|
+
resource: 'douyin',
|
|
514
|
+
operation: 'downloadVideo',
|
|
515
|
+
stage,
|
|
516
|
+
progressPercent: percent,
|
|
517
|
+
downloadedBytes,
|
|
518
|
+
totalBytes,
|
|
519
|
+
...extra,
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
function emitBcutTranscriptionProgress(context, itemIndex, percent, stage, extra) {
|
|
523
|
+
context.sendMessageToUI({
|
|
524
|
+
itemIndex,
|
|
525
|
+
operation: 'transcribe',
|
|
526
|
+
progressPercent: percent,
|
|
527
|
+
resource: 'transcription',
|
|
528
|
+
stage,
|
|
529
|
+
...extra,
|
|
530
|
+
});
|
|
402
531
|
}
|
|
403
532
|
function extractDouyinVideoId(urlString) {
|
|
404
533
|
var _a;
|
|
@@ -510,7 +639,7 @@ async function douyinRequestText(context, url, allowRedirect = true) {
|
|
|
510
639
|
};
|
|
511
640
|
}
|
|
512
641
|
async function resolveDouyinUrl(context, shareText) {
|
|
513
|
-
const originalUrl = extractFirstUrl(shareText.trim());
|
|
642
|
+
const originalUrl = sanitizeSharedLink(extractFirstUrl(shareText.trim()));
|
|
514
643
|
if (!originalUrl.includes('v.douyin.com')) {
|
|
515
644
|
return {
|
|
516
645
|
originalUrl,
|
|
@@ -544,30 +673,242 @@ async function getDouyinVideoInfo(context, shareText) {
|
|
|
544
673
|
}
|
|
545
674
|
}
|
|
546
675
|
}
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
},
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
676
|
+
function getRequestModule(urlString) {
|
|
677
|
+
return urlString.startsWith('https:') ? https : http;
|
|
678
|
+
}
|
|
679
|
+
async function requestDouyinResponse(url, method, headers, redirectCount = 0) {
|
|
680
|
+
if (redirectCount > DOUYIN_DOWNLOAD_MAX_REDIRECTS) {
|
|
681
|
+
throw new n8n_workflow_1.ApplicationError('Douyin download exceeded the redirect limit.');
|
|
682
|
+
}
|
|
683
|
+
return await new Promise((resolve, reject) => {
|
|
684
|
+
const request = getRequestModule(url).request(url, { headers, method }, (response) => {
|
|
685
|
+
var _a;
|
|
686
|
+
const statusCode = (_a = response.statusCode) !== null && _a !== void 0 ? _a : 0;
|
|
687
|
+
const redirectLocation = response.headers.location;
|
|
688
|
+
if (statusCode >= 300 && statusCode < 400 && redirectLocation) {
|
|
689
|
+
response.resume();
|
|
690
|
+
const nextUrl = new URL(redirectLocation, url).toString();
|
|
691
|
+
void requestDouyinResponse(nextUrl, method, headers, redirectCount + 1)
|
|
692
|
+
.then(resolve)
|
|
693
|
+
.catch(reject);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (statusCode < 200 || statusCode >= 300) {
|
|
697
|
+
response.resume();
|
|
698
|
+
reject(new n8n_workflow_1.ApplicationError(`Douyin request failed with HTTP status ${String(statusCode)}.`));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
resolve({ response, resolvedUrl: url });
|
|
702
|
+
});
|
|
703
|
+
request.on('error', reject);
|
|
704
|
+
request.end();
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
async function getDouyinDownloadMetadata(videoUrl) {
|
|
708
|
+
const headers = {
|
|
709
|
+
Referer: 'https://www.douyin.com/',
|
|
710
|
+
'User-Agent': DOUYIN_MOBILE_USER_AGENT,
|
|
711
|
+
};
|
|
712
|
+
const { response, resolvedUrl } = await requestDouyinResponse(videoUrl, 'HEAD', headers);
|
|
713
|
+
response.resume();
|
|
714
|
+
return {
|
|
715
|
+
acceptRanges: supportsDouyinRange(response.headers),
|
|
716
|
+
mimeType: getDouyinMimeTypeFromHeaders(response.headers),
|
|
717
|
+
resolvedUrl,
|
|
718
|
+
totalBytes: getDouyinContentLength(response.headers),
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
async function loadDouyinDownloadState(stateFilePath) {
|
|
722
|
+
try {
|
|
723
|
+
const fileContents = await node_fs_1.promises.readFile(stateFilePath, 'utf8');
|
|
724
|
+
return JSON.parse(fileContents);
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
if (error.code === 'ENOENT') {
|
|
728
|
+
return undefined;
|
|
729
|
+
}
|
|
730
|
+
await node_fs_1.promises.rm(stateFilePath, { force: true });
|
|
731
|
+
return undefined;
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
async function saveDouyinDownloadState(stateFilePath, state) {
|
|
735
|
+
await node_fs_1.promises.writeFile(stateFilePath, JSON.stringify(state, null, 2), 'utf8');
|
|
736
|
+
}
|
|
737
|
+
async function mergeDouyinChunks(outputFile, chunkCount) {
|
|
738
|
+
const fileHandle = await node_fs_1.promises.open(outputFile, 'w');
|
|
739
|
+
try {
|
|
740
|
+
for (let index = 0; index < chunkCount; index++) {
|
|
741
|
+
const chunkBuffer = await node_fs_1.promises.readFile(getDouyinPartFilePath(outputFile, index));
|
|
742
|
+
await fileHandle.writeFile(chunkBuffer);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
finally {
|
|
746
|
+
await fileHandle.close();
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
async function cleanupDouyinChunkArtifacts(outputFile, chunkCount) {
|
|
750
|
+
await Promise.allSettled(Array.from({ length: chunkCount }, (_, index) => node_fs_1.promises.rm(getDouyinPartFilePath(outputFile, index), { force: true })));
|
|
751
|
+
await node_fs_1.promises.rm(getDouyinStateFilePath(outputFile), { force: true });
|
|
752
|
+
}
|
|
753
|
+
async function downloadDouyinChunk(resolvedUrl, headers, start, end) {
|
|
754
|
+
const { response } = await requestDouyinResponse(resolvedUrl, 'GET', {
|
|
755
|
+
...headers,
|
|
756
|
+
Range: `bytes=${String(start)}-${String(end)}`,
|
|
757
|
+
});
|
|
758
|
+
return await new Promise((resolve, reject) => {
|
|
759
|
+
const chunks = [];
|
|
760
|
+
response.on('data', (chunk) => {
|
|
761
|
+
chunks.push(chunk);
|
|
762
|
+
});
|
|
763
|
+
response.on('end', () => {
|
|
764
|
+
resolve(Buffer.concat(chunks));
|
|
765
|
+
});
|
|
766
|
+
response.on('error', reject);
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
async function streamDouyinDownloadToFile(context, itemIndex, resolvedUrl, outputFile, headers, totalBytes) {
|
|
770
|
+
const { response } = await requestDouyinResponse(resolvedUrl, 'GET', headers);
|
|
771
|
+
const chunks = [];
|
|
772
|
+
let downloadedBytes = 0;
|
|
773
|
+
let lastProgressPercent = -1;
|
|
774
|
+
emitDouyinDownloadProgress(context, itemIndex, 0, 0, totalBytes || undefined, 'stream_start');
|
|
775
|
+
const buffer = await new Promise((resolve, reject) => {
|
|
776
|
+
response.on('data', (chunk) => {
|
|
777
|
+
chunks.push(chunk);
|
|
778
|
+
downloadedBytes += chunk.length;
|
|
779
|
+
if (totalBytes > 0) {
|
|
780
|
+
const progressPercent = Math.min(100, Math.floor((downloadedBytes / totalBytes) * 100));
|
|
781
|
+
if (progressPercent >= lastProgressPercent + 5 || progressPercent === 100) {
|
|
782
|
+
lastProgressPercent = progressPercent;
|
|
783
|
+
emitDouyinDownloadProgress(context, itemIndex, progressPercent, downloadedBytes, totalBytes, 'downloading');
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
response.on('end', () => {
|
|
788
|
+
resolve(Buffer.concat(chunks));
|
|
789
|
+
});
|
|
790
|
+
response.on('error', reject);
|
|
791
|
+
});
|
|
792
|
+
await node_fs_1.promises.writeFile(outputFile, buffer);
|
|
793
|
+
emitDouyinDownloadProgress(context, itemIndex, 100, buffer.length, totalBytes || buffer.length, 'completed');
|
|
794
|
+
return {
|
|
795
|
+
buffer,
|
|
796
|
+
downloadMode: 'stream',
|
|
797
|
+
mimeType: getDouyinMimeTypeFromHeaders(response.headers),
|
|
798
|
+
progressPercent: 100,
|
|
799
|
+
resolvedDownloadUrl: resolvedUrl,
|
|
800
|
+
totalBytes: totalBytes > 0 ? totalBytes : buffer.length,
|
|
801
|
+
};
|
|
802
|
+
}
|
|
803
|
+
async function chunkedDouyinDownloadToFile(context, itemIndex, resolvedUrl, outputFile, headers, metadata, chunkSize, workerCount) {
|
|
804
|
+
const stateFilePath = getDouyinStateFilePath(outputFile);
|
|
805
|
+
const chunkCount = getDouyinChunkCount(metadata.totalBytes, chunkSize);
|
|
806
|
+
const existingState = await loadDouyinDownloadState(stateFilePath);
|
|
807
|
+
const state = (existingState === null || existingState === void 0 ? void 0 : existingState.outputFile) === outputFile &&
|
|
808
|
+
existingState.chunkSize === chunkSize &&
|
|
809
|
+
existingState.totalSize === metadata.totalBytes
|
|
810
|
+
? existingState
|
|
811
|
+
: {
|
|
812
|
+
chunkSize,
|
|
813
|
+
completedChunks: {},
|
|
814
|
+
outputFile,
|
|
815
|
+
totalSize: metadata.totalBytes,
|
|
816
|
+
};
|
|
817
|
+
let downloadedBytes = 0;
|
|
818
|
+
for (const [chunkIndex, completed] of Object.entries(state.completedChunks)) {
|
|
819
|
+
if (!completed) {
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
const index = Number(chunkIndex);
|
|
823
|
+
const start = index * state.chunkSize;
|
|
824
|
+
const end = Math.min(start + state.chunkSize, state.totalSize) - 1;
|
|
825
|
+
downloadedBytes += end - start + 1;
|
|
826
|
+
}
|
|
827
|
+
let lastProgressPercent = metadata.totalBytes > 0 ? Math.floor((downloadedBytes / metadata.totalBytes) * 100) : 0;
|
|
828
|
+
if (downloadedBytes > 0) {
|
|
829
|
+
emitDouyinDownloadProgress(context, itemIndex, lastProgressPercent, downloadedBytes, metadata.totalBytes, 'resuming', { completedChunks: Object.keys(state.completedChunks).length, chunkCount });
|
|
830
|
+
await saveDouyinDownloadState(stateFilePath, state);
|
|
831
|
+
}
|
|
832
|
+
else {
|
|
833
|
+
emitDouyinDownloadProgress(context, itemIndex, 0, 0, metadata.totalBytes, 'chunked_start', {
|
|
834
|
+
chunkCount,
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
let chunkCursor = 0;
|
|
838
|
+
const workers = Array.from({ length: Math.min(workerCount, chunkCount) }, async () => {
|
|
839
|
+
while (chunkCursor < chunkCount) {
|
|
840
|
+
const currentOffset = chunkCursor;
|
|
841
|
+
chunkCursor += 1;
|
|
842
|
+
const index = currentOffset;
|
|
843
|
+
if (state.completedChunks[String(index)]) {
|
|
844
|
+
continue;
|
|
845
|
+
}
|
|
846
|
+
const start = index * state.chunkSize;
|
|
847
|
+
const end = Math.min(start + state.chunkSize, state.totalSize) - 1;
|
|
848
|
+
let lastError;
|
|
849
|
+
for (let attempt = 0; attempt < DOUYIN_DOWNLOAD_MAX_RETRIES; attempt++) {
|
|
850
|
+
try {
|
|
851
|
+
const chunkBuffer = await downloadDouyinChunk(resolvedUrl, headers, start, end);
|
|
852
|
+
await node_fs_1.promises.writeFile(getDouyinPartFilePath(outputFile, index), chunkBuffer);
|
|
853
|
+
state.completedChunks[String(index)] = true;
|
|
854
|
+
downloadedBytes += chunkBuffer.length;
|
|
855
|
+
await saveDouyinDownloadState(stateFilePath, state);
|
|
856
|
+
const progressPercent = Math.min(100, Math.floor((downloadedBytes / metadata.totalBytes) * 100));
|
|
857
|
+
if (progressPercent >= lastProgressPercent + 5 || progressPercent === 100) {
|
|
858
|
+
lastProgressPercent = progressPercent;
|
|
859
|
+
emitDouyinDownloadProgress(context, itemIndex, progressPercent, downloadedBytes, metadata.totalBytes, 'downloading', { completedChunks: Object.keys(state.completedChunks).length, chunkCount });
|
|
860
|
+
}
|
|
861
|
+
lastError = undefined;
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
catch (error) {
|
|
865
|
+
lastError = error;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (lastError) {
|
|
869
|
+
throw lastError;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
});
|
|
873
|
+
await Promise.all(workers);
|
|
874
|
+
await mergeDouyinChunks(outputFile, chunkCount);
|
|
875
|
+
const buffer = await node_fs_1.promises.readFile(outputFile);
|
|
876
|
+
await cleanupDouyinChunkArtifacts(outputFile, chunkCount);
|
|
877
|
+
emitDouyinDownloadProgress(context, itemIndex, 100, buffer.length, metadata.totalBytes, 'completed', {
|
|
878
|
+
chunkCount,
|
|
879
|
+
});
|
|
880
|
+
return {
|
|
881
|
+
buffer,
|
|
882
|
+
downloadMode: 'chunked',
|
|
883
|
+
mimeType: metadata.mimeType,
|
|
884
|
+
progressPercent: 100,
|
|
885
|
+
resolvedDownloadUrl: resolvedUrl,
|
|
886
|
+
totalBytes: metadata.totalBytes,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
async function streamDownloadWithProgress(context, itemIndex, videoUrl, downloadId, chunkSize, workerCount) {
|
|
890
|
+
const requestHeaders = {
|
|
891
|
+
Referer: 'https://www.douyin.com/',
|
|
892
|
+
'User-Agent': DOUYIN_MOBILE_USER_AGENT,
|
|
893
|
+
};
|
|
894
|
+
const downloadDirectory = getDouyinDownloadDirectory(downloadId);
|
|
895
|
+
const outputFile = (0, node_path_1.join)(downloadDirectory, 'video.mp4');
|
|
896
|
+
await node_fs_1.promises.mkdir(downloadDirectory, { recursive: true });
|
|
897
|
+
const metadata = await getDouyinDownloadMetadata(videoUrl);
|
|
898
|
+
try {
|
|
899
|
+
if (metadata.acceptRanges && metadata.totalBytes > 0) {
|
|
900
|
+
return await chunkedDouyinDownloadToFile(context, itemIndex, metadata.resolvedUrl, outputFile, requestHeaders, metadata, chunkSize, workerCount);
|
|
901
|
+
}
|
|
902
|
+
return await streamDouyinDownloadToFile(context, itemIndex, metadata.resolvedUrl, outputFile, requestHeaders, metadata.totalBytes);
|
|
903
|
+
}
|
|
904
|
+
finally {
|
|
905
|
+
await node_fs_1.promises.rm(outputFile, { force: true });
|
|
906
|
+
await node_fs_1.promises.rm(downloadDirectory, { force: true, recursive: false }).catch(() => undefined);
|
|
563
907
|
}
|
|
564
|
-
const mimeTypeHeader = (_a = getHeaderValue(response.headers, 'content-type')) !== null && _a !== void 0 ? _a : 'video/mp4';
|
|
565
|
-
const mimeType = mimeTypeHeader.split(';')[0] || 'video/mp4';
|
|
566
|
-
return { buffer, mimeType };
|
|
567
908
|
}
|
|
568
909
|
async function extractAudioFromVideo(context, buffer, inputFileName, outputFileName, format, ffmpegPath, mimeType) {
|
|
569
910
|
var _a;
|
|
570
|
-
const resolvedFfmpegPath = ffmpegPath.trim() ||
|
|
911
|
+
const resolvedFfmpegPath = ffmpegPath.trim() || DEFAULT_FFMPEG_PATH;
|
|
571
912
|
const tempDirectory = await node_fs_1.promises.mkdtemp((0, node_path_1.join)((0, node_os_1.tmpdir)(), 'vidflow-audio-'));
|
|
572
913
|
const inputExtension = (_a = getFileExtension(inputFileName)) !== null && _a !== void 0 ? _a : inferVideoExtension(mimeType);
|
|
573
914
|
const tempInputPath = (0, node_path_1.join)(tempDirectory, `input.${inputExtension}`);
|
|
@@ -598,8 +939,12 @@ async function extractAudioFromVideo(context, buffer, inputFileName, outputFileN
|
|
|
598
939
|
await node_fs_1.promises.rm(tempDirectory, { force: true, recursive: true });
|
|
599
940
|
}
|
|
600
941
|
}
|
|
601
|
-
async function uploadAudioToBcut(context, buffer, fileName, resourceFileType) {
|
|
942
|
+
async function uploadAudioToBcut(context, itemIndex, buffer, fileName, resourceFileType) {
|
|
602
943
|
var _a, _b, _c, _d, _e, _f, _g, _h;
|
|
944
|
+
emitBcutTranscriptionProgress(context, itemIndex, 10, 'upload_started', {
|
|
945
|
+
fileName,
|
|
946
|
+
fileSize: buffer.length,
|
|
947
|
+
});
|
|
603
948
|
const createPayload = {
|
|
604
949
|
type: 2,
|
|
605
950
|
name: fileName,
|
|
@@ -646,6 +991,11 @@ async function uploadAudioToBcut(context, buffer, fileName, resourceFileType) {
|
|
|
646
991
|
throw new n8n_workflow_1.ApplicationError(`BCut upload part ${index + 1} did not return an ETag header.`);
|
|
647
992
|
}
|
|
648
993
|
etags.push(etag.replace(/^"|"$/g, ''));
|
|
994
|
+
const uploadProgressPercent = Math.min(30, 10 + Math.floor(((index + 1) / uploadUrls.length) * 20));
|
|
995
|
+
emitBcutTranscriptionProgress(context, itemIndex, uploadProgressPercent, 'uploading', {
|
|
996
|
+
uploadedParts: index + 1,
|
|
997
|
+
totalParts: uploadUrls.length,
|
|
998
|
+
});
|
|
649
999
|
}
|
|
650
1000
|
const commitResponse = await bcutRequest(context, {
|
|
651
1001
|
url: `${BCUT_API_BASE_URL}/resource/create/complete`,
|
|
@@ -663,13 +1013,17 @@ async function uploadAudioToBcut(context, buffer, fileName, resourceFileType) {
|
|
|
663
1013
|
if (commitResponse.code !== 0 || !((_g = commitResponse.data) === null || _g === void 0 ? void 0 : _g.download_url)) {
|
|
664
1014
|
throw new n8n_workflow_1.ApplicationError(`BCut upload commit failed: ${(_h = commitResponse.message) !== null && _h !== void 0 ? _h : 'Unknown error'}`);
|
|
665
1015
|
}
|
|
1016
|
+
emitBcutTranscriptionProgress(context, itemIndex, 30, 'upload_completed', {
|
|
1017
|
+
resourceId,
|
|
1018
|
+
});
|
|
666
1019
|
return {
|
|
667
1020
|
resourceId,
|
|
668
1021
|
downloadUrl: commitResponse.data.download_url,
|
|
669
1022
|
};
|
|
670
1023
|
}
|
|
671
|
-
async function createBcutTask(context, downloadUrl) {
|
|
1024
|
+
async function createBcutTask(context, itemIndex, downloadUrl) {
|
|
672
1025
|
var _a, _b;
|
|
1026
|
+
emitBcutTranscriptionProgress(context, itemIndex, 35, 'task_creating');
|
|
673
1027
|
const taskResponse = await bcutRequest(context, {
|
|
674
1028
|
url: `${BCUT_API_BASE_URL}/task`,
|
|
675
1029
|
method: 'POST',
|
|
@@ -683,6 +1037,9 @@ async function createBcutTask(context, downloadUrl) {
|
|
|
683
1037
|
if (taskResponse.code !== 0 || !((_a = taskResponse.data) === null || _a === void 0 ? void 0 : _a.task_id)) {
|
|
684
1038
|
throw new n8n_workflow_1.ApplicationError(`BCut task creation failed: ${(_b = taskResponse.message) !== null && _b !== void 0 ? _b : 'Unknown error'}`);
|
|
685
1039
|
}
|
|
1040
|
+
emitBcutTranscriptionProgress(context, itemIndex, 40, 'task_created', {
|
|
1041
|
+
taskId: taskResponse.data.task_id,
|
|
1042
|
+
});
|
|
686
1043
|
return taskResponse.data.task_id;
|
|
687
1044
|
}
|
|
688
1045
|
async function queryBcutTask(context, taskId) {
|
|
@@ -702,23 +1059,43 @@ async function queryBcutTask(context, taskId) {
|
|
|
702
1059
|
}
|
|
703
1060
|
return taskResultResponse.data;
|
|
704
1061
|
}
|
|
705
|
-
async function transcribeWithBcut(context, buffer, fileName, resourceFileType, pollingIntervalSeconds, timeoutSeconds) {
|
|
1062
|
+
async function transcribeWithBcut(context, itemIndex, buffer, fileName, resourceFileType, pollingIntervalSeconds, timeoutSeconds) {
|
|
706
1063
|
var _a, _b, _c;
|
|
707
|
-
const { resourceId, downloadUrl } = await uploadAudioToBcut(context, buffer, fileName, resourceFileType);
|
|
708
|
-
const taskId = await createBcutTask(context, downloadUrl);
|
|
1064
|
+
const { resourceId, downloadUrl } = await uploadAudioToBcut(context, itemIndex, buffer, fileName, resourceFileType);
|
|
1065
|
+
const taskId = await createBcutTask(context, itemIndex, downloadUrl);
|
|
709
1066
|
const maxAttempts = Math.max(1, Math.ceil(timeoutSeconds / pollingIntervalSeconds));
|
|
710
1067
|
let taskResult;
|
|
1068
|
+
let progressPercent = 40;
|
|
711
1069
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
712
1070
|
taskResult = await queryBcutTask(context, taskId);
|
|
713
1071
|
if (taskResult.state === BCUT_POLL_STATE_COMPLETED) {
|
|
1072
|
+
progressPercent = 100;
|
|
1073
|
+
emitBcutTranscriptionProgress(context, itemIndex, progressPercent, 'completed', {
|
|
1074
|
+
taskId,
|
|
1075
|
+
});
|
|
714
1076
|
break;
|
|
715
1077
|
}
|
|
716
1078
|
if (taskResult.state === BCUT_POLL_STATE_FAILED) {
|
|
1079
|
+
emitBcutTranscriptionProgress(context, itemIndex, progressPercent, 'failed', {
|
|
1080
|
+
taskId,
|
|
1081
|
+
state: taskResult.state,
|
|
1082
|
+
});
|
|
717
1083
|
throw new n8n_workflow_1.ApplicationError(`BCut transcription failed with state ${String(taskResult.state)}.`);
|
|
718
1084
|
}
|
|
1085
|
+
progressPercent = Math.min(95, 40 + Math.floor(((attempt + 1) / maxAttempts) * 55));
|
|
1086
|
+
emitBcutTranscriptionProgress(context, itemIndex, progressPercent, 'polling', {
|
|
1087
|
+
taskId,
|
|
1088
|
+
pollAttempt: attempt + 1,
|
|
1089
|
+
pollLimit: maxAttempts,
|
|
1090
|
+
state: taskResult.state,
|
|
1091
|
+
});
|
|
719
1092
|
await (0, n8n_workflow_1.sleep)(pollingIntervalSeconds * 1000);
|
|
720
1093
|
}
|
|
721
1094
|
if ((taskResult === null || taskResult === void 0 ? void 0 : taskResult.state) !== BCUT_POLL_STATE_COMPLETED || !taskResult.result) {
|
|
1095
|
+
emitBcutTranscriptionProgress(context, itemIndex, progressPercent, 'timeout', {
|
|
1096
|
+
taskId,
|
|
1097
|
+
state: taskResult === null || taskResult === void 0 ? void 0 : taskResult.state,
|
|
1098
|
+
});
|
|
722
1099
|
throw new n8n_workflow_1.ApplicationError(`BCut transcription timed out with state ${String((_a = taskResult === null || taskResult === void 0 ? void 0 : taskResult.state) !== null && _a !== void 0 ? _a : 'unknown')}.`);
|
|
723
1100
|
}
|
|
724
1101
|
const rawResult = JSON.parse(taskResult.result);
|
|
@@ -739,6 +1116,7 @@ async function transcribeWithBcut(context, buffer, fileName, resourceFileType, p
|
|
|
739
1116
|
resourceId,
|
|
740
1117
|
downloadUrl,
|
|
741
1118
|
language: (_c = rawResult.language) !== null && _c !== void 0 ? _c : 'zh',
|
|
1119
|
+
progressPercent,
|
|
742
1120
|
text,
|
|
743
1121
|
segments,
|
|
744
1122
|
raw: rawResult,
|
|
@@ -845,12 +1223,13 @@ class Vidflow {
|
|
|
845
1223
|
const buffer = await this.helpers.getBinaryDataBuffer(itemIndex, binaryPropertyName);
|
|
846
1224
|
const fileName = resolveFileName(binaryData, fileNameOverride);
|
|
847
1225
|
const resourceFileType = resolveResourceFileType(fileName, binaryData.mimeType);
|
|
848
|
-
const transcript = await transcribeWithBcut(this, buffer, fileName, resourceFileType, pollingIntervalSeconds, timeoutSeconds);
|
|
1226
|
+
const transcript = await transcribeWithBcut(this, itemIndex, buffer, fileName, resourceFileType, pollingIntervalSeconds, timeoutSeconds);
|
|
849
1227
|
const result = {
|
|
850
1228
|
taskId: transcript.taskId,
|
|
851
1229
|
resourceId: transcript.resourceId,
|
|
852
1230
|
downloadUrl: transcript.downloadUrl,
|
|
853
1231
|
language: transcript.language,
|
|
1232
|
+
progressPercent: transcript.progressPercent,
|
|
854
1233
|
text: transcript.text,
|
|
855
1234
|
segments: transcript.segments,
|
|
856
1235
|
};
|
|
@@ -870,11 +1249,17 @@ class Vidflow {
|
|
|
870
1249
|
const shareText = this.getNodeParameter('shareText', itemIndex);
|
|
871
1250
|
const outputBinaryPropertyName = this.getNodeParameter('outputBinaryPropertyName', itemIndex);
|
|
872
1251
|
const outputFileName = this.getNodeParameter('outputFileName', itemIndex, '');
|
|
1252
|
+
const chunkSizeMb = this.getNodeParameter('chunkSizeMb', itemIndex);
|
|
1253
|
+
const workerCount = this.getNodeParameter('workerCount', itemIndex);
|
|
873
1254
|
const videoInfo = await getDouyinVideoInfo(this, shareText);
|
|
874
|
-
const
|
|
1255
|
+
const downloadId = videoInfo.awemeId || videoInfo.title || 'douyin_video';
|
|
1256
|
+
const normalizedChunkSizeMb = Math.min(DOUYIN_DOWNLOAD_MAX_CHUNK_SIZE_MB, Math.max(1, Math.floor(chunkSizeMb || DOUYIN_DOWNLOAD_CHUNK_SIZE / (1024 * 1024))));
|
|
1257
|
+
const chunkSize = normalizedChunkSizeMb * 1024 * 1024;
|
|
1258
|
+
const normalizedWorkerCount = Math.min(DOUYIN_DOWNLOAD_MAX_WORKERS, Math.max(1, Math.floor(workerCount || DOUYIN_DOWNLOAD_WORKERS)));
|
|
1259
|
+
const { buffer, downloadMode, mimeType, progressPercent, resolvedDownloadUrl, totalBytes, } = await streamDownloadWithProgress(this, itemIndex, videoInfo.videoUrl, downloadId, chunkSize, normalizedWorkerCount);
|
|
875
1260
|
const finalFileName = outputFileName.trim() !== ''
|
|
876
1261
|
? sanitizeFileName(outputFileName.trim())
|
|
877
|
-
: `${sanitizeFileName(
|
|
1262
|
+
: `${sanitizeFileName(downloadId)}.mp4`;
|
|
878
1263
|
const binaryData = await this.helpers.prepareBinaryData(buffer, finalFileName, mimeType);
|
|
879
1264
|
returnData.push({
|
|
880
1265
|
json: {
|
|
@@ -886,9 +1271,15 @@ class Vidflow {
|
|
|
886
1271
|
createTime: videoInfo.createTime,
|
|
887
1272
|
originalUrl: videoInfo.originalUrl,
|
|
888
1273
|
resolvedUrl: videoInfo.resolvedUrl,
|
|
1274
|
+
resolvedDownloadUrl,
|
|
1275
|
+
downloadMode,
|
|
889
1276
|
fileName: finalFileName,
|
|
890
1277
|
mimeType,
|
|
891
1278
|
size: buffer.length,
|
|
1279
|
+
totalBytes,
|
|
1280
|
+
downloadProgressPercent: progressPercent,
|
|
1281
|
+
chunkSizeMb: normalizedChunkSizeMb,
|
|
1282
|
+
workerCount: normalizedWorkerCount,
|
|
892
1283
|
},
|
|
893
1284
|
binary: {
|
|
894
1285
|
[outputBinaryPropertyName]: binaryData,
|