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: 'Optional ffmpeg executable path. When empty, ffmpeg is resolved from PATH.',
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 match = input.match(/https?:\/\/[^\s]+/i);
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].replace(/[),.;!?]+$/, '');
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
- async function downloadDouyinVideoBuffer(context, videoUrl) {
548
- var _a;
549
- const response = (await context.helpers.httpRequest({
550
- url: videoUrl,
551
- method: 'GET',
552
- headers: {
553
- Referer: 'https://www.douyin.com/',
554
- 'User-Agent': DOUYIN_MOBILE_USER_AGENT,
555
- },
556
- json: false,
557
- encoding: 'arraybuffer',
558
- returnFullResponse: true,
559
- }));
560
- const buffer = response.body;
561
- if (!buffer || !(buffer instanceof Buffer)) {
562
- throw new n8n_workflow_1.ApplicationError('Douyin video download did not return binary content.');
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() || process.env.FFMPEG_PATH || 'ffmpeg';
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 { buffer, mimeType } = await downloadDouyinVideoBuffer(this, videoInfo.videoUrl);
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(videoInfo.awemeId || videoInfo.title || 'douyin_video')}.mp4`;
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,