Skip to content

Commit 6f8b09d

Browse files
committed
feat: file copy support overwrite
1 parent 8787fd0 commit 6f8b09d

File tree

20 files changed

+584
-156
lines changed

20 files changed

+584
-156
lines changed

src/lib/ffmpeg.ts

Lines changed: 237 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,185 @@
11
import {ffprobeGetMediaDuration, ffprobeVideoInfo} from "./ffprobe";
22

3+
let hardwareEncodersCache: { [key: string]: boolean } | null = null;
4+
5+
const detectHardwareEncoders = async (): Promise<{ [key: string]: boolean }> => {
6+
if (hardwareEncodersCache) return hardwareEncodersCache;
7+
8+
const platform = $mapi.app.platformName();
9+
const encoders: { [key: string]: boolean } = {};
10+
11+
try {
12+
const output = await new Promise<string>((resolve, reject) => {
13+
let buffer = "";
14+
$mapi.app.spawnBinary("ffmpeg", ["-encoders"], {
15+
shell: false,
16+
stdout: (data: string) => buffer += data,
17+
stderr: (data: string) => buffer += data,
18+
success: () => resolve(buffer),
19+
error: (msg: string) => reject(msg),
20+
});
21+
});
22+
// Check for common hardware encoders based on platform
23+
if (platform === "osx") {
24+
// macOS VideoToolbox
25+
if (output.includes("h264_videotoolbox")) encoders.h264_videotoolbox = true;
26+
if (output.includes("hevc_videotoolbox")) encoders.hevc_videotoolbox = true;
27+
} else if (platform === "win") {
28+
// Windows
29+
if (output.includes("h264_nvenc")) encoders.h264_nvenc = true;
30+
if (output.includes("hevc_nvenc")) encoders.hevc_nvenc = true;
31+
if (output.includes("h264_amf")) encoders.h264_amf = true;
32+
if (output.includes("hevc_amf")) encoders.hevc_amf = true;
33+
if (output.includes("h264_qsv")) encoders.h264_qsv = true;
34+
if (output.includes("hevc_qsv")) encoders.hevc_qsv = true;
35+
} else if (platform === "linux") {
36+
// Linux
37+
if (output.includes("h264_nvenc")) encoders.h264_nvenc = true;
38+
if (output.includes("hevc_nvenc")) encoders.hevc_nvenc = true;
39+
if (output.includes("h264_amf")) encoders.h264_amf = true;
40+
if (output.includes("hevc_amf")) encoders.hevc_amf = true;
41+
if (output.includes("h264_qsv")) encoders.h264_qsv = true;
42+
if (output.includes("hevc_qsv")) encoders.hevc_qsv = true;
43+
if (output.includes("h264_vaapi")) encoders.h264_vaapi = true;
44+
if (output.includes("hevc_vaapi")) encoders.hevc_vaapi = true;
45+
}
46+
} catch (e) {
47+
// If detection fails, assume no hardware acceleration
48+
}
49+
hardwareEncodersCache = encoders;
50+
return encoders;
51+
};
52+
53+
const optimizeArgs = (args: string[], encoders: { [key: string]: boolean }): string[] => {
54+
const optimizedArgs = [...args];
55+
// Replace software encoders with hardware ones if available
56+
for (let i = 0; i < optimizedArgs.length; i++) {
57+
if (optimizedArgs[i] === "-c:v") {
58+
const encoder = optimizedArgs[i + 1];
59+
if (encoder === "libx264" && encoders.h264_nvenc) {
60+
optimizedArgs[i + 1] = "h264_nvenc";
61+
} else if (encoder === "libx264" && encoders.h264_amf) {
62+
optimizedArgs[i + 1] = "h264_amf";
63+
} else if (encoder === "libx264" && encoders.h264_qsv) {
64+
optimizedArgs[i + 1] = "h264_qsv";
65+
} else if (encoder === "libx264" && encoders.h264_videotoolbox) {
66+
optimizedArgs[i + 1] = "h264_videotoolbox";
67+
} else if (encoder === "libx265" && encoders.hevc_nvenc) {
68+
optimizedArgs[i + 1] = "hevc_nvenc";
69+
} else if (encoder === "libx265" && encoders.hevc_amf) {
70+
optimizedArgs[i + 1] = "hevc_amf";
71+
} else if (encoder === "libx265" && encoders.hevc_qsv) {
72+
optimizedArgs[i + 1] = "hevc_qsv";
73+
} else if (encoder === "libx265" && encoders.hevc_videotoolbox) {
74+
optimizedArgs[i + 1] = "hevc_videotoolbox";
75+
}
76+
}
77+
}
78+
79+
return optimizedArgs;
80+
};
81+
82+
const extractInputFile = (args: string[]): string | null => {
83+
for (let i = 0; i < args.length; i++) {
84+
if (args[i] === "-i" && i + 1 < args.length) {
85+
return args[i + 1];
86+
}
87+
}
88+
return null;
89+
};
90+
91+
export const ffmpegOptimized = async (
92+
args: string[],
93+
option?: {
94+
successFileCheck?: string,
95+
onProgress?: (progress: number) => void;
96+
codesOptimized?: boolean,
97+
}
98+
): Promise<void> => {
99+
100+
option = Object.assign({
101+
successFileCheck: '',
102+
codesOptimized: false,
103+
onProgress: undefined,
104+
}, option)
105+
106+
// add hide banner and loglevel error
107+
if (!args.includes("-hide_banner")) {
108+
args.unshift("-hide_banner");
109+
}
110+
// if (!args.includes("-loglevel")) {
111+
// args.unshift("-loglevel", "info");
112+
// }
113+
114+
let optimizedArgs = args;
115+
if (option!.codesOptimized) {
116+
const encoders = await detectHardwareEncoders();
117+
const optimizedArgs = optimizeArgs(args, encoders);
118+
if (optimizedArgs.join(' ') !== args.join(' ')) {
119+
$mapi.log.info('FfmpegCommandOptimized', {
120+
original: 'ffmpeg ' + args.join(' '),
121+
optimized: 'ffmpeg ' + optimizedArgs.join(' ')
122+
});
123+
}
124+
}
125+
126+
let totalDuration = 0;
127+
const inputFile = extractInputFile(optimizedArgs);
128+
if (inputFile && option?.onProgress) {
129+
try {
130+
totalDuration = await ffprobeGetMediaDuration(inputFile, false);
131+
} catch (e) {
132+
// If can't get duration, progress won't work
133+
}
134+
}
135+
136+
return new Promise<void>((resolve, reject) => {
137+
let lastProgress = 0;
138+
const controller = $mapi.app.spawnBinary("ffmpeg", optimizedArgs, {
139+
shell: false,
140+
stdout: (data: string) => {
141+
// console.log("FFmpeg stdout:", data);
142+
},
143+
stderr: (data: string) => {
144+
// console.log("FFmpeg stderr:", data);
145+
if (option?.onProgress && totalDuration > 0) {
146+
const timeMatch = data.match(/time=(\d+):(\d+):(\d+\.\d+)/);
147+
if (timeMatch) {
148+
const hours = parseInt(timeMatch[1]);
149+
const minutes = parseInt(timeMatch[2]);
150+
const seconds = parseFloat(timeMatch[3]);
151+
const currentTime = hours * 3600 + minutes * 60 + seconds;
152+
const progress = Math.min(currentTime / totalDuration, 1);
153+
if (progress > lastProgress) {
154+
option.onProgress(progress);
155+
lastProgress = progress;
156+
}
157+
}
158+
}
159+
},
160+
success: () => {
161+
if (option?.onProgress) {
162+
option.onProgress(1);
163+
}
164+
if (option?.successFileCheck) {
165+
$mapi.file.exists(option.successFileCheck).then(exists => {
166+
if (exists) {
167+
resolve();
168+
} else {
169+
reject(`FFmpeg completed but output file not found: ${option?.successFileCheck}`);
170+
}
171+
}).catch(reject);
172+
} else {
173+
resolve();
174+
}
175+
},
176+
error: (msg: string, exitCode: number) => {
177+
reject(`FFmpeg error (code ${exitCode}): ${msg}`);
178+
},
179+
});
180+
});
181+
};
182+
3183
export const ffmpegSetMediaRatio = async (
4184
input: string,
5185
output: string,
@@ -45,7 +225,7 @@ export const ffmpegSetMediaRatio = async (
45225
"-map",
46226
"[a]",
47227
"-preset",
48-
"fast",
228+
"ultrafast",
49229
"-y",
50230
output,
51231
];
@@ -238,14 +418,12 @@ export const ffmpegCombineVideoAudio = async (video: string, audio: string) => {
238418
video,
239419
"-i",
240420
audio,
241-
"-c:v",
242-
"copy",
243-
"-c:a",
244-
"aac",
245-
"-map",
246-
"0:v:0",
247-
"-map",
248-
"1:a:0",
421+
"-c:v", "libx264",
422+
"-preset", "ultrafast",
423+
"-crf", "0",
424+
"-c:a", "aac",
425+
"-map", "0:v:0",
426+
"-map", "1:a:0",
249427
"-y",
250428
output,
251429
]);
@@ -360,7 +538,7 @@ export const ffmpegVideoNormal = async (input: string, option: {
360538
"-r",
361539
targetFps.toString(),
362540
"-preset",
363-
"fast",
541+
"ultrafast",
364542
"-y",
365543
output,
366544
];
@@ -372,6 +550,55 @@ export const ffmpegVideoNormal = async (input: string, option: {
372550
return output;
373551
}
374552

553+
export async function ffmpegCutVideo(input: string, startMs: number, endMs: number): Promise<string> {
554+
const output = await $mapi.file.temp('mp4');
555+
const startSeconds = startMs / 1000;
556+
const durationSeconds = (endMs - startMs) / 1000;
557+
const args = [
558+
'-i', input,
559+
'-ss', startSeconds.toString(),
560+
'-t', durationSeconds.toString(),
561+
'-c:v', 'libx264',
562+
'-preset', 'ultrafast',
563+
'-crf', '0',
564+
'-c:a', 'aac',
565+
'-avoid_negative_ts', 'make_zero',
566+
'-y', output
567+
];
568+
await ffmpegOptimized(args, {
569+
successFileCheck: output
570+
});
571+
return output;
572+
}
573+
574+
// FFmpeg 工具函数:合并多个视频
575+
export async function ffmpegConcatVideos(videos: string[]): Promise<string> {
576+
if (videos.length === 0) {
577+
throw new Error('No videos to concat');
578+
}
579+
if (videos.length === 1) {
580+
return videos[0];
581+
}
582+
const output = await $mapi.file.temp('mp4');
583+
const txtFile = await $mapi.file.temp('txt');
584+
// 创建 concat 文件列表
585+
const lines = videos.map(video => `file '${video.replace(/'/g, "'\\''")}'`);
586+
await $mapi.file.write(txtFile, lines.join('\n'));
587+
const args = [
588+
'-f', 'concat',
589+
'-safe', '0',
590+
'-i', txtFile,
591+
'-c:v', 'libx264',
592+
'-preset', 'ultrafast',
593+
'-crf', '0',
594+
'-c:a', 'aac',
595+
'-y', output
596+
];
597+
await ffmpegOptimized(args, {
598+
successFileCheck: output
599+
});
600+
return output;
601+
}
375602

376603
export const ffmpegVideoNormal = async (input: string, option: {
377604
widthMax?: number;

src/pages/Apps/LongTextTts/components/LongTextTtsParamDialog.vue

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
<script setup lang="ts">
2-
import {ref} from "vue";
2+
import { nextTick, ref } from "vue";
33
import SoundGenerateForm from "../../../Sound/components/SoundGenerateForm.vue";
44
55
const soundGenerateForm = ref<InstanceType<typeof SoundGenerateForm> | null>(null);
66
7+
const props = defineProps<{
8+
}>();
9+
710
const visible = ref(false);
811
const emit = defineEmits<{
912
update: [
@@ -24,8 +27,13 @@ const doSubmit = async () => {
2427
};
2528
2629
defineExpose({
27-
show: () => {
30+
show: (data?: any) => {
2831
visible.value = true;
32+
nextTick(() => {
33+
if (data?.soundGenerate) {
34+
soundGenerateForm.value?.setValue(data.soundGenerate);
35+
}
36+
});
2937
},
3038
});
3139

src/pages/Apps/LongTextTts/task.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,7 @@ export const LongTextTts: TaskBiz = {
121121
if (ret.type === "retry") {
122122
return ret.type;
123123
}
124-
record.audio = await $mapi.file.hubSave(ret.url, {
125-
saveGroup: "part",
126-
127-
});
124+
record.audio = await $mapi.file.hubSave(ret.url);
128125
await TaskService.update(bizId, {jobResult});
129126
}
130127
jobResult.step = "Combine";
@@ -145,10 +142,7 @@ export const LongTextTts: TaskBiz = {
145142
const filesToClean: string[] = [];
146143
try {
147144
const audio = await ffmpegConcatAudio(jobResult.SoundGenerate.records!.map(r => r.audio));
148-
jobResult.Combine.audio = await $mapi.file.hubSave(audio, {
149-
saveGroup: "part",
150-
151-
});
145+
jobResult.Combine.audio = await $mapi.file.hubSave(audio);
152146
jobResult.step = "End";
153147
jobResult.Combine.status = "success";
154148
await TaskService.update(bizId, {

src/pages/Apps/LongTextTts/workflow/LongTextTtsNode.vue

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import {ref} from "vue";
2+
import { ref } from "vue";
33
import TaskDialogViewButton from "../../../../components/common/TaskDialogViewButton.vue";
44
import {
55
FunctionCallNodeEmits,
@@ -17,18 +17,16 @@ const paramDialog = ref<InstanceType<typeof LongTextTtsParamDialog>>();
1717

1818
<template>
1919
<div class="p-2 relative">
20-
<div class="-mb-4">
20+
<div>
2121
<SoundGenerateFormView v-if="nodeData.soundGenerate" :data="nodeData.soundGenerate"/>
22-
<a-button v-if="(!nodeData.soundGenerate) || (props.source==='config')"
23-
:class="props.source==='config'?'cursor-pointer':''"
24-
@click="props.source==='config'&&paramDialog.show()"
25-
size="small" class="w-full mb-4">
26-
{{ props.source === 'config' ? $t("修改配置") : $t("没有配置") }}
27-
</a-button>
28-
<div v-if="nodeRunData" class="mb-4">
29-
<div class="">
30-
<TaskDialogViewButton :task-id="nodeRunData.taskId"/>
31-
</div>
22+
<div class="flex gap-2 items-center">
23+
<a-button v-if="props.source==='config'" @click="paramDialog?.show(nodeData)" size="small">
24+
<template #icon>
25+
<icon-settings/>
26+
</template>
27+
{{ $t('设置')}}
28+
</a-button>
29+
<TaskDialogViewButton :task-id="nodeRunData.taskId"/>
3230
</div>
3331
</div>
3432
</div>

src/pages/Apps/LongTextTts/workflow/node.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
1-
import {defineAsyncComponent} from "vue";
2-
import {t} from "../../../../lang";
3-
import {NodeFunctionCall, NodeRunController, NodeRunParam, NodeRunResult} from "../../../../module/Workflow/core/type";
4-
import {workflowRun} from "../../common/workflow";
5-
import {LongTextTtsRun} from "../task";
1+
import { defineAsyncComponent } from "vue";
2+
import { t } from "../../../../lang";
3+
import { NodeFunctionCall, NodeRunController, NodeRunParam, NodeRunResult } from "../../../../module/Workflow/core/type";
4+
import { workflowRun } from "../../common/workflow";
5+
import { LongTextTtsRun } from "../task";
66
import LongTextTtsIcon from "./../assets/icon.svg";
77

88
export default <NodeFunctionCall>{
99
name: "LongTextTts",
1010
title: t("长文本转音频"),
11+
description: "将长文本转换为音频",
1112
icon: LongTextTtsIcon,
1213
comp: defineAsyncComponent(() => import("./LongTextTtsNode.vue")),
1314
inputFields: [

0 commit comments

Comments
 (0)