Spaces:
Running
Running
fix: 导出页面 UI 修复 2
Browse files- docs/流程文档_AI用.md +5 -3
- src/export_plugins/simple_export.py +98 -10
- src/export_plugins/utau_oto_export.py +14 -0
- src/gui_cloud.py +257 -135
- tools/README.md +1 -1
- tools/mfa_engine.7z +3 -0
docs/流程文档_AI用.md
CHANGED
|
@@ -391,9 +391,11 @@ MFA 环境:
|
|
| 391 |
- 音频未上传完成时禁用「开始制作」按钮,防止误操作
|
| 392 |
- 导出页面提供「使用刚制作的音源」按钮,避免重复上传
|
| 393 |
- Whisper 模型选项标注速度参考:small 约 4 秒/句,medium 约 12 秒/句(慢 2-3 倍但更准确)
|
| 394 |
-
- **导出插件动态选项**:
|
| 395 |
-
-
|
| 396 |
-
-
|
|
|
|
|
|
|
| 397 |
|
| 398 |
### 平台差异
|
| 399 |
|
|
|
|
| 391 |
- 音频未上传完成时禁用「开始制作」按钮,防止误操作
|
| 392 |
- 导出页面提供「使用刚制作的音源」按钮,避免重复上传
|
| 393 |
- Whisper 模型选项标注速度参考:small 约 4 秒/句,medium 约 12 秒/句(慢 2-3 倍但更准确)
|
| 394 |
+
- **导出插件动态选项系统**:
|
| 395 |
+
- 插件选项完全动态化,根据 `ExportPlugin.get_options()` 自动生成 UI 组件
|
| 396 |
+
- 切换插件时自动显示/隐藏对应的配置选项组
|
| 397 |
+
- 支持的选项类型: TEXT(文本)、NUMBER(数字)、SWITCH(开关)、COMBO(下拉)、MULTI_SELECT(多选)、LABEL(说明文字)
|
| 398 |
+
- 新增插件无需修改 GUI 代码,只需在插件中定义 `get_options()` 即可自动生成界面
|
| 399 |
|
| 400 |
### 平台差异
|
| 401 |
|
src/export_plugins/simple_export.py
CHANGED
|
@@ -41,6 +41,13 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 41 |
max_value=1000,
|
| 42 |
description="按时长排序,保留最长的N个"
|
| 43 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
PluginOption(
|
| 45 |
key="naming_rule",
|
| 46 |
label="命名规则",
|
|
@@ -83,6 +90,57 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 83 |
name = rule.replace("%p%", pinyin).replace("%n%", str(index))
|
| 84 |
return name
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
def export(
|
| 87 |
self,
|
| 88 |
source_name: str,
|
|
@@ -105,13 +163,19 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 105 |
temp_base = os.path.join(bank_dir, ".temp_segments")
|
| 106 |
segments_dir = os.path.join(temp_base, source_name)
|
| 107 |
|
|
|
|
|
|
|
|
|
|
| 108 |
# 步骤1: 提取分词片段
|
| 109 |
self._log("【提取分词片段】")
|
|
|
|
|
|
|
| 110 |
success, msg, pinyin_counts = self._extract_segments(
|
| 111 |
paths["slices_dir"],
|
| 112 |
paths["textgrid_dir"],
|
| 113 |
segments_dir,
|
| 114 |
-
language
|
|
|
|
| 115 |
)
|
| 116 |
if not success:
|
| 117 |
return False, msg
|
|
@@ -146,13 +210,17 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 146 |
slices_dir: str,
|
| 147 |
textgrid_dir: str,
|
| 148 |
segments_dir: str,
|
| 149 |
-
language: str
|
|
|
|
| 150 |
) -> Tuple[bool, str, Dict[str, int]]:
|
| 151 |
"""
|
| 152 |
提取分词片段
|
| 153 |
|
| 154 |
中文:使用words层按字切分,用char_to_pinyin获取拼音名称
|
| 155 |
日语:使用phones层按音素切分,合并辅音+元音为音节
|
|
|
|
|
|
|
|
|
|
| 156 |
"""
|
| 157 |
try:
|
| 158 |
import textgrid
|
|
@@ -169,11 +237,11 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 169 |
# 根据语言选择提取方法
|
| 170 |
if language in ("japanese", "ja", "jp"):
|
| 171 |
return self._extract_japanese_segments(
|
| 172 |
-
tg_files, slices_dir, segments_dir
|
| 173 |
)
|
| 174 |
else:
|
| 175 |
return self._extract_chinese_segments(
|
| 176 |
-
tg_files, slices_dir, segments_dir, language
|
| 177 |
)
|
| 178 |
|
| 179 |
except Exception as e:
|
|
@@ -185,12 +253,16 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 185 |
tg_files: List[str],
|
| 186 |
slices_dir: str,
|
| 187 |
segments_dir: str,
|
| 188 |
-
language: str
|
|
|
|
| 189 |
) -> Tuple[bool, str, Dict[str, int]]:
|
| 190 |
"""
|
| 191 |
中文音频提取
|
| 192 |
|
| 193 |
使用words层的时间边界,按字符切分,用char_to_pinyin获取拼音
|
|
|
|
|
|
|
|
|
|
| 194 |
"""
|
| 195 |
import textgrid
|
| 196 |
import soundfile as sf
|
|
@@ -208,6 +280,7 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 208 |
|
| 209 |
tg = textgrid.TextGrid.fromFile(tg_path)
|
| 210 |
audio, sr = sf.read(wav_path, dtype='float32')
|
|
|
|
| 211 |
|
| 212 |
# 使用words层(第一层)
|
| 213 |
words_tier = tg[0]
|
|
@@ -240,6 +313,11 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 240 |
char_start = start_time + i * char_duration
|
| 241 |
char_end = char_start + char_duration
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
pinyin_dir = os.path.join(segments_dir, pinyin)
|
| 244 |
os.makedirs(pinyin_dir, exist_ok=True)
|
| 245 |
|
|
@@ -247,8 +325,8 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 247 |
index = current_count + 1
|
| 248 |
pinyin_counts[pinyin] = index
|
| 249 |
|
| 250 |
-
start_sample = int(round(
|
| 251 |
-
end_sample = int(round(
|
| 252 |
segment = audio[start_sample:end_sample]
|
| 253 |
|
| 254 |
if len(segment) == 0:
|
|
@@ -267,12 +345,16 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 267 |
self,
|
| 268 |
tg_files: List[str],
|
| 269 |
slices_dir: str,
|
| 270 |
-
segments_dir: str
|
|
|
|
| 271 |
) -> Tuple[bool, str, Dict[str, int]]:
|
| 272 |
"""
|
| 273 |
日语音频提取
|
| 274 |
|
| 275 |
使用phones层,将辅音+元音合并为音节
|
|
|
|
|
|
|
|
|
|
| 276 |
"""
|
| 277 |
import textgrid
|
| 278 |
import soundfile as sf
|
|
@@ -289,6 +371,7 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 289 |
|
| 290 |
tg = textgrid.TextGrid.fromFile(tg_path)
|
| 291 |
audio, sr = sf.read(wav_path, dtype='float32')
|
|
|
|
| 292 |
|
| 293 |
# 查找phones层
|
| 294 |
phones_tier = None
|
|
@@ -316,6 +399,11 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 316 |
if not normalized:
|
| 317 |
continue
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
phone_dir = os.path.join(segments_dir, normalized)
|
| 320 |
os.makedirs(phone_dir, exist_ok=True)
|
| 321 |
|
|
@@ -323,8 +411,8 @@ class SimpleExportPlugin(ExportPlugin):
|
|
| 323 |
index = current_count + 1
|
| 324 |
phone_counts[normalized] = index
|
| 325 |
|
| 326 |
-
start_sample = int(round(
|
| 327 |
-
end_sample = int(round(
|
| 328 |
segment = audio[start_sample:end_sample]
|
| 329 |
|
| 330 |
if len(segment) == 0:
|
|
|
|
| 41 |
max_value=1000,
|
| 42 |
description="按时长排序,保留最长的N个"
|
| 43 |
),
|
| 44 |
+
PluginOption(
|
| 45 |
+
key="extend_duration",
|
| 46 |
+
label="头尾拓展(秒)",
|
| 47 |
+
option_type=OptionType.TEXT,
|
| 48 |
+
default="0",
|
| 49 |
+
description="裁剪时头尾各拓展的时长,最大1.5秒。若一边到达边界,另一边继续拓展"
|
| 50 |
+
),
|
| 51 |
PluginOption(
|
| 52 |
key="naming_rule",
|
| 53 |
label="命名规则",
|
|
|
|
| 90 |
name = rule.replace("%p%", pinyin).replace("%n%", str(index))
|
| 91 |
return name
|
| 92 |
|
| 93 |
+
def _apply_extend(
|
| 94 |
+
self,
|
| 95 |
+
start_time: float,
|
| 96 |
+
end_time: float,
|
| 97 |
+
extend_duration: float,
|
| 98 |
+
audio_duration: float
|
| 99 |
+
) -> Tuple[float, float]:
|
| 100 |
+
"""
|
| 101 |
+
应用头尾拓展
|
| 102 |
+
|
| 103 |
+
头尾各拓展 extend_duration 秒,若一边到达边界则另一边继续拓展
|
| 104 |
+
|
| 105 |
+
参数:
|
| 106 |
+
start_time: 原始开始时间
|
| 107 |
+
end_time: 原始结束时间
|
| 108 |
+
extend_duration: 单边拓展时长
|
| 109 |
+
audio_duration: 音频总时长
|
| 110 |
+
|
| 111 |
+
返回:
|
| 112 |
+
(实际开始时间, 实际结束时间)
|
| 113 |
+
"""
|
| 114 |
+
if extend_duration <= 0:
|
| 115 |
+
return start_time, end_time
|
| 116 |
+
|
| 117 |
+
total_extend = extend_duration * 2 # 总拓展量
|
| 118 |
+
|
| 119 |
+
# 先尝试头尾各拓展
|
| 120 |
+
head_extend = extend_duration
|
| 121 |
+
tail_extend = extend_duration
|
| 122 |
+
|
| 123 |
+
# 检查头部是否到达边界
|
| 124 |
+
if start_time - head_extend < 0:
|
| 125 |
+
head_actual = start_time # 头部只能拓展到0
|
| 126 |
+
head_remaining = head_extend - head_actual # 剩余量转给尾部
|
| 127 |
+
tail_extend += head_remaining
|
| 128 |
+
head_extend = head_actual
|
| 129 |
+
|
| 130 |
+
# 检查尾部是否到达边界
|
| 131 |
+
if end_time + tail_extend > audio_duration:
|
| 132 |
+
tail_actual = audio_duration - end_time # 尾部只能拓展到边界
|
| 133 |
+
tail_remaining = tail_extend - tail_actual # 剩余量转给头部
|
| 134 |
+
# 头部再次尝试拓展(如果还有空间)
|
| 135 |
+
additional_head = min(tail_remaining, start_time - (start_time - head_extend))
|
| 136 |
+
head_extend = min(start_time, head_extend + tail_remaining)
|
| 137 |
+
tail_extend = tail_actual
|
| 138 |
+
|
| 139 |
+
actual_start = max(0, start_time - head_extend)
|
| 140 |
+
actual_end = min(audio_duration, end_time + tail_extend)
|
| 141 |
+
|
| 142 |
+
return actual_start, actual_end
|
| 143 |
+
|
| 144 |
def export(
|
| 145 |
self,
|
| 146 |
source_name: str,
|
|
|
|
| 163 |
temp_base = os.path.join(bank_dir, ".temp_segments")
|
| 164 |
segments_dir = os.path.join(temp_base, source_name)
|
| 165 |
|
| 166 |
+
# 获取头尾拓展参数
|
| 167 |
+
extend_duration = min(float(options.get("extend_duration", 0)), 1.5)
|
| 168 |
+
|
| 169 |
# 步骤1: 提取分词片段
|
| 170 |
self._log("【提取分词片段】")
|
| 171 |
+
if extend_duration > 0:
|
| 172 |
+
self._log(f"头尾拓展: {extend_duration}s(单边到达边界时另一边继续拓展)")
|
| 173 |
success, msg, pinyin_counts = self._extract_segments(
|
| 174 |
paths["slices_dir"],
|
| 175 |
paths["textgrid_dir"],
|
| 176 |
segments_dir,
|
| 177 |
+
language,
|
| 178 |
+
extend_duration
|
| 179 |
)
|
| 180 |
if not success:
|
| 181 |
return False, msg
|
|
|
|
| 210 |
slices_dir: str,
|
| 211 |
textgrid_dir: str,
|
| 212 |
segments_dir: str,
|
| 213 |
+
language: str,
|
| 214 |
+
extend_duration: float = 0.0
|
| 215 |
) -> Tuple[bool, str, Dict[str, int]]:
|
| 216 |
"""
|
| 217 |
提取分词片段
|
| 218 |
|
| 219 |
中文:使用words层按字切分,用char_to_pinyin获取拼音名称
|
| 220 |
日语:使用phones层按音素切分,合并辅音+元音为音节
|
| 221 |
+
|
| 222 |
+
参数:
|
| 223 |
+
extend_duration: 头尾拓展总时长(秒),单边到达边界时另一边继续拓展
|
| 224 |
"""
|
| 225 |
try:
|
| 226 |
import textgrid
|
|
|
|
| 237 |
# 根据语言选择提取方法
|
| 238 |
if language in ("japanese", "ja", "jp"):
|
| 239 |
return self._extract_japanese_segments(
|
| 240 |
+
tg_files, slices_dir, segments_dir, extend_duration
|
| 241 |
)
|
| 242 |
else:
|
| 243 |
return self._extract_chinese_segments(
|
| 244 |
+
tg_files, slices_dir, segments_dir, language, extend_duration
|
| 245 |
)
|
| 246 |
|
| 247 |
except Exception as e:
|
|
|
|
| 253 |
tg_files: List[str],
|
| 254 |
slices_dir: str,
|
| 255 |
segments_dir: str,
|
| 256 |
+
language: str,
|
| 257 |
+
extend_duration: float = 0.0
|
| 258 |
) -> Tuple[bool, str, Dict[str, int]]:
|
| 259 |
"""
|
| 260 |
中文音频提取
|
| 261 |
|
| 262 |
使用words层的时间边界,按字符切分,用char_to_pinyin获取拼音
|
| 263 |
+
|
| 264 |
+
参数:
|
| 265 |
+
extend_duration: 头尾拓展总时长(秒),单边到达边界时另一边继续拓展
|
| 266 |
"""
|
| 267 |
import textgrid
|
| 268 |
import soundfile as sf
|
|
|
|
| 280 |
|
| 281 |
tg = textgrid.TextGrid.fromFile(tg_path)
|
| 282 |
audio, sr = sf.read(wav_path, dtype='float32')
|
| 283 |
+
audio_duration = len(audio) / sr
|
| 284 |
|
| 285 |
# 使用words层(第一层)
|
| 286 |
words_tier = tg[0]
|
|
|
|
| 313 |
char_start = start_time + i * char_duration
|
| 314 |
char_end = char_start + char_duration
|
| 315 |
|
| 316 |
+
# 应用头尾拓展,单边到达边界时另一边继续拓展
|
| 317 |
+
actual_start, actual_end = self._apply_extend(
|
| 318 |
+
char_start, char_end, extend_duration, audio_duration
|
| 319 |
+
)
|
| 320 |
+
|
| 321 |
pinyin_dir = os.path.join(segments_dir, pinyin)
|
| 322 |
os.makedirs(pinyin_dir, exist_ok=True)
|
| 323 |
|
|
|
|
| 325 |
index = current_count + 1
|
| 326 |
pinyin_counts[pinyin] = index
|
| 327 |
|
| 328 |
+
start_sample = int(round(actual_start * sr))
|
| 329 |
+
end_sample = int(round(actual_end * sr))
|
| 330 |
segment = audio[start_sample:end_sample]
|
| 331 |
|
| 332 |
if len(segment) == 0:
|
|
|
|
| 345 |
self,
|
| 346 |
tg_files: List[str],
|
| 347 |
slices_dir: str,
|
| 348 |
+
segments_dir: str,
|
| 349 |
+
extend_duration: float = 0.0
|
| 350 |
) -> Tuple[bool, str, Dict[str, int]]:
|
| 351 |
"""
|
| 352 |
日语音频提取
|
| 353 |
|
| 354 |
使用phones层,将辅音+元音合并为音节
|
| 355 |
+
|
| 356 |
+
参数:
|
| 357 |
+
extend_duration: 头尾拓展总时长(秒),单边到达边界时另一边继续拓展
|
| 358 |
"""
|
| 359 |
import textgrid
|
| 360 |
import soundfile as sf
|
|
|
|
| 371 |
|
| 372 |
tg = textgrid.TextGrid.fromFile(tg_path)
|
| 373 |
audio, sr = sf.read(wav_path, dtype='float32')
|
| 374 |
+
audio_duration = len(audio) / sr
|
| 375 |
|
| 376 |
# 查找phones层
|
| 377 |
phones_tier = None
|
|
|
|
| 399 |
if not normalized:
|
| 400 |
continue
|
| 401 |
|
| 402 |
+
# 应用头尾拓展,单边到达边界时另一边继续拓展
|
| 403 |
+
actual_start, actual_end = self._apply_extend(
|
| 404 |
+
start_time, end_time, extend_duration, audio_duration
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
phone_dir = os.path.join(segments_dir, normalized)
|
| 408 |
os.makedirs(phone_dir, exist_ok=True)
|
| 409 |
|
|
|
|
| 411 |
index = current_count + 1
|
| 412 |
phone_counts[normalized] = index
|
| 413 |
|
| 414 |
+
start_sample = int(round(actual_start * sr))
|
| 415 |
+
end_sample = int(round(actual_end * sr))
|
| 416 |
segment = audio[start_sample:end_sample]
|
| 417 |
|
| 418 |
if len(segment) == 0:
|
src/export_plugins/utau_oto_export.py
CHANGED
|
@@ -238,6 +238,13 @@ class UTAUOtoExportPlugin(ExportPlugin):
|
|
| 238 |
label="从 TextGrid phones 层提取音素,生成 oto.ini(音频不裁剪)",
|
| 239 |
option_type=OptionType.LABEL
|
| 240 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
PluginOption(
|
| 242 |
key="max_samples",
|
| 243 |
label="每个别名最大样本数",
|
|
@@ -286,6 +293,13 @@ class UTAUOtoExportPlugin(ExportPlugin):
|
|
| 286 |
max_value=0.5,
|
| 287 |
description="Overlap = Preutterance × 此比例"
|
| 288 |
),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 289 |
PluginOption(
|
| 290 |
key="encoding",
|
| 291 |
label="文件编码",
|
|
|
|
| 238 |
label="从 TextGrid phones 层提取音素,生成 oto.ini(音频不裁剪)",
|
| 239 |
option_type=OptionType.LABEL
|
| 240 |
),
|
| 241 |
+
PluginOption(
|
| 242 |
+
key="cross_language",
|
| 243 |
+
label="跨语种导出",
|
| 244 |
+
option_type=OptionType.SWITCH,
|
| 245 |
+
default=False,
|
| 246 |
+
description="【TODO】启用中跨日或日跨中的音素映射导出"
|
| 247 |
+
),
|
| 248 |
PluginOption(
|
| 249 |
key="max_samples",
|
| 250 |
label="每个别名最大样本数",
|
|
|
|
| 293 |
max_value=0.5,
|
| 294 |
description="Overlap = Preutterance × 此比例"
|
| 295 |
),
|
| 296 |
+
PluginOption(
|
| 297 |
+
key="auto_phoneme_combine",
|
| 298 |
+
label="自动拼字",
|
| 299 |
+
option_type=OptionType.SWITCH,
|
| 300 |
+
default=False,
|
| 301 |
+
description="【TODO】尽可能用已有的音素拆分拼接成缺失的音素"
|
| 302 |
+
),
|
| 303 |
PluginOption(
|
| 304 |
key="encoding",
|
| 305 |
label="文件编码",
|
src/gui_cloud.py
CHANGED
|
@@ -534,31 +534,13 @@ def get_plugin_options_config(plugins: Dict[str, Any]) -> Dict[str, List[Dict]]:
|
|
| 534 |
"choices": opt.choices,
|
| 535 |
"min_value": opt.min_value,
|
| 536 |
"max_value": opt.max_value,
|
|
|
|
| 537 |
}
|
| 538 |
options.append(opt_config)
|
| 539 |
config[name] = options
|
| 540 |
return config
|
| 541 |
|
| 542 |
|
| 543 |
-
def build_plugin_options_html(plugin_name: str, plugins_config: Dict) -> str:
|
| 544 |
-
"""
|
| 545 |
-
根据插件选项生成 HTML 表单
|
| 546 |
-
|
| 547 |
-
这个方法生成一个简单的 HTML 表单,用于在 Gradio 中显示插件选项
|
| 548 |
-
"""
|
| 549 |
-
if plugin_name not in plugins_config:
|
| 550 |
-
return "<p>未找到插件配置</p>"
|
| 551 |
-
|
| 552 |
-
options = plugins_config[plugin_name]
|
| 553 |
-
html_parts = []
|
| 554 |
-
|
| 555 |
-
for opt in options:
|
| 556 |
-
if opt["type"] == "label":
|
| 557 |
-
html_parts.append(f'<p style="color: #666; font-style: italic;">{opt["label"]}</p>')
|
| 558 |
-
|
| 559 |
-
return "\n".join(html_parts) if html_parts else ""
|
| 560 |
-
|
| 561 |
-
|
| 562 |
def get_default_options_json(plugin_name: str, plugins_config: Dict) -> str:
|
| 563 |
"""获取插件的默认选项 JSON"""
|
| 564 |
if plugin_name not in plugins_config:
|
|
@@ -573,6 +555,145 @@ def get_default_options_json(plugin_name: str, plugins_config: Dict) -> str:
|
|
| 573 |
return json.dumps(defaults, ensure_ascii=False)
|
| 574 |
|
| 575 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
# ==================== 构建界面 ====================
|
| 577 |
|
| 578 |
def create_cloud_ui():
|
|
@@ -743,128 +864,126 @@ def create_cloud_ui():
|
|
| 743 |
label="导出插件"
|
| 744 |
)
|
| 745 |
|
| 746 |
-
# 插件描述
|
| 747 |
-
plugin_desc = gr.Markdown(
|
| 748 |
-
value=f"> {plugins[plugin_names[0]].description}" if plugin_names and plugin_names[0] in plugins else ""
|
| 749 |
-
)
|
| 750 |
-
|
| 751 |
-
# 存储当前选项的 JSON(隐藏)
|
| 752 |
-
options_state = gr.State(
|
| 753 |
-
value=get_default_options_json(plugin_names[0], plugins_config) if plugin_names else "{}"
|
| 754 |
-
)
|
| 755 |
-
|
| 756 |
# ===== 动态选项区域 =====
|
| 757 |
-
#
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
with gr.Group():
|
| 761 |
-
gr.Markdown("#### 基本设置")
|
| 762 |
-
with gr.Row():
|
| 763 |
-
export_max_samples = gr.Number(
|
| 764 |
-
label="每个拼音/别名最大样本数",
|
| 765 |
-
value=10,
|
| 766 |
-
minimum=1,
|
| 767 |
-
maximum=1000
|
| 768 |
-
)
|
| 769 |
-
|
| 770 |
-
with gr.Row():
|
| 771 |
-
export_naming = gr.Textbox(
|
| 772 |
-
label="命名规则",
|
| 773 |
-
value="%p%%n%",
|
| 774 |
-
info="%p%=拼音/罗马音, %n%=序号"
|
| 775 |
-
)
|
| 776 |
-
export_first_naming = gr.Textbox(
|
| 777 |
-
label="首个样本命名",
|
| 778 |
-
value="%p%",
|
| 779 |
-
info="第0个样本的特殊规则"
|
| 780 |
-
)
|
| 781 |
-
|
| 782 |
-
# UTAU 专用选项
|
| 783 |
-
with gr.Group(visible=False) as utau_options_group:
|
| 784 |
-
gr.Markdown("#### UTAU 专用设置")
|
| 785 |
-
with gr.Row():
|
| 786 |
-
export_quality_metrics = gr.Dropdown(
|
| 787 |
-
label="质量评估维度",
|
| 788 |
-
choices=["duration", "duration+rms", "duration+f0", "all"],
|
| 789 |
-
value="duration+rms",
|
| 790 |
-
info="duration=仅时长, +rms=音量稳定性, +f0=音高稳定性"
|
| 791 |
-
)
|
| 792 |
-
export_alias_style = gr.Dropdown(
|
| 793 |
-
label="别名风格(日语)",
|
| 794 |
-
choices=["romaji", "hiragana"],
|
| 795 |
-
value="hiragana",
|
| 796 |
-
info="日语音源的别名格式"
|
| 797 |
-
)
|
| 798 |
-
|
| 799 |
-
with gr.Row():
|
| 800 |
-
export_overlap_ratio = gr.Number(
|
| 801 |
-
label="Overlap 比例",
|
| 802 |
-
value=0.3,
|
| 803 |
-
minimum=0.1,
|
| 804 |
-
maximum=0.5,
|
| 805 |
-
info="Overlap = Preutterance × 此比例"
|
| 806 |
-
)
|
| 807 |
-
export_encoding = gr.Dropdown(
|
| 808 |
-
label="文件编码",
|
| 809 |
-
choices=["shift_jis", "utf-8", "gbk"],
|
| 810 |
-
value="shift_jis",
|
| 811 |
-
info="oto.ini 编码(UTAU 标准为 Shift_JIS)"
|
| 812 |
-
)
|
| 813 |
-
|
| 814 |
-
export_sanitize_filename = gr.Checkbox(
|
| 815 |
-
label="文件名转拼音(防止 UTAU 识别故障)",
|
| 816 |
-
value=False
|
| 817 |
-
)
|
| 818 |
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
is_utau = "UTAU" in plugin_name
|
| 823 |
-
|
| 824 |
-
# 获取插件描述
|
| 825 |
-
desc = ""
|
| 826 |
-
if plugin_name in plugins:
|
| 827 |
-
desc = f"> {plugins[plugin_name].description}"
|
| 828 |
|
| 829 |
-
|
| 830 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 831 |
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
|
| 835 |
-
|
| 836 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 837 |
|
|
|
|
| 838 |
export_plugin.change(
|
| 839 |
fn=on_plugin_change,
|
| 840 |
inputs=[export_plugin],
|
| 841 |
-
outputs=
|
| 842 |
)
|
| 843 |
|
| 844 |
# 收集选项并导出
|
| 845 |
-
def collect_and_export(
|
| 846 |
-
|
| 847 |
-
|
| 848 |
-
|
| 849 |
-
|
| 850 |
-
):
|
| 851 |
-
"""收集所有选项并执行导出"""
|
| 852 |
-
# 构建选项字典
|
| 853 |
-
options = {
|
| 854 |
-
"max_samples": int(max_samples),
|
| 855 |
-
"naming_rule": naming_rule,
|
| 856 |
-
"first_naming_rule": first_naming_rule,
|
| 857 |
-
}
|
| 858 |
|
| 859 |
-
#
|
| 860 |
-
|
| 861 |
-
|
| 862 |
-
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
|
| 866 |
-
|
| 867 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 868 |
|
| 869 |
options_json = json.dumps(options, ensure_ascii=False)
|
| 870 |
return process_export_voicebank(zip_file, plugin_name, options_json, progress)
|
|
@@ -884,14 +1003,17 @@ def create_cloud_ui():
|
|
| 884 |
> - 导出为适配其他软件的音源格式
|
| 885 |
""")
|
| 886 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 887 |
export_btn.click(
|
| 888 |
fn=collect_and_export,
|
| 889 |
-
inputs=[
|
| 890 |
-
export_upload, export_plugin,
|
| 891 |
-
export_max_samples, export_naming, export_first_naming,
|
| 892 |
-
export_quality_metrics, export_alias_style,
|
| 893 |
-
export_overlap_ratio, export_encoding, export_sanitize_filename
|
| 894 |
-
],
|
| 895 |
outputs=[export_status, export_log, export_download]
|
| 896 |
)
|
| 897 |
|
|
|
|
| 534 |
"choices": opt.choices,
|
| 535 |
"min_value": opt.min_value,
|
| 536 |
"max_value": opt.max_value,
|
| 537 |
+
"step": opt.step,
|
| 538 |
}
|
| 539 |
options.append(opt_config)
|
| 540 |
config[name] = options
|
| 541 |
return config
|
| 542 |
|
| 543 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
def get_default_options_json(plugin_name: str, plugins_config: Dict) -> str:
|
| 545 |
"""获取插件的默认选项 JSON"""
|
| 546 |
if plugin_name not in plugins_config:
|
|
|
|
| 555 |
return json.dumps(defaults, ensure_ascii=False)
|
| 556 |
|
| 557 |
|
| 558 |
+
def create_dynamic_plugin_options(plugins: Dict[str, Any], plugins_config: Dict) -> Tuple[Dict[str, Any], callable]:
|
| 559 |
+
"""
|
| 560 |
+
创建动态插件选项组件
|
| 561 |
+
|
| 562 |
+
返回:
|
| 563 |
+
(组件字典, 收集选项函数)
|
| 564 |
+
|
| 565 |
+
组件字典结构: {
|
| 566 |
+
"container": gr.Column, # 主容器
|
| 567 |
+
"groups": {插件名: gr.Group}, # 每个插件的选项组
|
| 568 |
+
"components": {插件名: {选项key: 组件}}, # 所有组件
|
| 569 |
+
}
|
| 570 |
+
"""
|
| 571 |
+
from src.export_plugins.base import OptionType
|
| 572 |
+
|
| 573 |
+
all_groups = {}
|
| 574 |
+
all_components = {}
|
| 575 |
+
|
| 576 |
+
# 为每个插件创建选项组
|
| 577 |
+
for plugin_name, options in plugins_config.items():
|
| 578 |
+
plugin_components = {}
|
| 579 |
+
|
| 580 |
+
# 创建该插件的选项组(初始隐藏,第一个插件除外)
|
| 581 |
+
is_first = (plugin_name == list(plugins_config.keys())[0])
|
| 582 |
+
|
| 583 |
+
with gr.Group(visible=is_first) as plugin_group:
|
| 584 |
+
# 显示插件描述
|
| 585 |
+
if plugin_name in plugins:
|
| 586 |
+
gr.Markdown(f"> {plugins[plugin_name].description}")
|
| 587 |
+
|
| 588 |
+
for opt in options:
|
| 589 |
+
opt_type = opt["type"]
|
| 590 |
+
key = opt["key"]
|
| 591 |
+
label = opt["label"]
|
| 592 |
+
default = opt["default"]
|
| 593 |
+
description = opt.get("description", "")
|
| 594 |
+
choices = opt.get("choices", [])
|
| 595 |
+
min_val = opt.get("min_value")
|
| 596 |
+
max_val = opt.get("max_value")
|
| 597 |
+
step = opt.get("step")
|
| 598 |
+
|
| 599 |
+
# 根据类型创建对应的 Gradio 组件
|
| 600 |
+
if opt_type == "label":
|
| 601 |
+
# 纯文本标签
|
| 602 |
+
gr.Markdown(f"*{label}*")
|
| 603 |
+
continue
|
| 604 |
+
|
| 605 |
+
elif opt_type == "text":
|
| 606 |
+
component = gr.Textbox(
|
| 607 |
+
label=label,
|
| 608 |
+
value=default or "",
|
| 609 |
+
info=description
|
| 610 |
+
)
|
| 611 |
+
|
| 612 |
+
elif opt_type == "number":
|
| 613 |
+
component = gr.Number(
|
| 614 |
+
label=label,
|
| 615 |
+
value=default if default is not None else 0,
|
| 616 |
+
minimum=min_val,
|
| 617 |
+
maximum=max_val,
|
| 618 |
+
step=step or 1,
|
| 619 |
+
info=description
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
elif opt_type == "switch":
|
| 623 |
+
component = gr.Checkbox(
|
| 624 |
+
label=label,
|
| 625 |
+
value=bool(default),
|
| 626 |
+
info=description
|
| 627 |
+
)
|
| 628 |
+
|
| 629 |
+
elif opt_type == "combo":
|
| 630 |
+
component = gr.Dropdown(
|
| 631 |
+
label=label,
|
| 632 |
+
choices=choices,
|
| 633 |
+
value=default if default in choices else (choices[0] if choices else None),
|
| 634 |
+
info=description
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
elif opt_type == "multi_select":
|
| 638 |
+
component = gr.CheckboxGroup(
|
| 639 |
+
label=label,
|
| 640 |
+
choices=choices,
|
| 641 |
+
value=default if isinstance(default, list) else [],
|
| 642 |
+
info=description
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
else:
|
| 646 |
+
# 未知类型,使用文本框
|
| 647 |
+
component = gr.Textbox(
|
| 648 |
+
label=label,
|
| 649 |
+
value=str(default) if default else "",
|
| 650 |
+
info=description
|
| 651 |
+
)
|
| 652 |
+
|
| 653 |
+
plugin_components[key] = component
|
| 654 |
+
|
| 655 |
+
all_groups[plugin_name] = plugin_group
|
| 656 |
+
all_components[plugin_name] = plugin_components
|
| 657 |
+
|
| 658 |
+
return all_groups, all_components
|
| 659 |
+
|
| 660 |
+
|
| 661 |
+
def build_options_collector(plugins_config: Dict, all_components: Dict):
|
| 662 |
+
"""
|
| 663 |
+
构建选项收集函数
|
| 664 |
+
|
| 665 |
+
返回一个函数,该函数接收插件名和所有组件值,返回选项字典
|
| 666 |
+
"""
|
| 667 |
+
# 构建组件到选项的映射
|
| 668 |
+
component_keys = {}
|
| 669 |
+
for plugin_name, components in all_components.items():
|
| 670 |
+
component_keys[plugin_name] = list(components.keys())
|
| 671 |
+
|
| 672 |
+
def collect_options(plugin_name: str, *values) -> Dict[str, Any]:
|
| 673 |
+
"""收集当前插件的选项值"""
|
| 674 |
+
if plugin_name not in component_keys:
|
| 675 |
+
return {}
|
| 676 |
+
|
| 677 |
+
keys = component_keys[plugin_name]
|
| 678 |
+
options = {}
|
| 679 |
+
|
| 680 |
+
# 计算当前插件的值在 values 中的起始位置
|
| 681 |
+
start_idx = 0
|
| 682 |
+
for pname in component_keys:
|
| 683 |
+
if pname == plugin_name:
|
| 684 |
+
break
|
| 685 |
+
start_idx += len(component_keys[pname])
|
| 686 |
+
|
| 687 |
+
# 提取当前插件的值
|
| 688 |
+
for i, key in enumerate(keys):
|
| 689 |
+
if start_idx + i < len(values):
|
| 690 |
+
options[key] = values[start_idx + i]
|
| 691 |
+
|
| 692 |
+
return options
|
| 693 |
+
|
| 694 |
+
return collect_options
|
| 695 |
+
|
| 696 |
+
|
| 697 |
# ==================== 构建界面 ====================
|
| 698 |
|
| 699 |
def create_cloud_ui():
|
|
|
|
| 864 |
label="导出插件"
|
| 865 |
)
|
| 866 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 867 |
# ===== 动态选项区域 =====
|
| 868 |
+
# 为每个插件动态创建选���组件
|
| 869 |
+
all_plugin_groups = {}
|
| 870 |
+
all_plugin_components = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 871 |
|
| 872 |
+
for idx, (pname, poptions) in enumerate(plugins_config.items()):
|
| 873 |
+
is_first = (idx == 0)
|
| 874 |
+
plugin_components = {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 875 |
|
| 876 |
+
with gr.Group(visible=is_first) as plugin_group:
|
| 877 |
+
# 插件描述
|
| 878 |
+
if pname in plugins:
|
| 879 |
+
gr.Markdown(f"> {plugins[pname].description}")
|
| 880 |
+
|
| 881 |
+
# 动态创建选项组件
|
| 882 |
+
for opt in poptions:
|
| 883 |
+
opt_type = opt["type"]
|
| 884 |
+
key = opt["key"]
|
| 885 |
+
label = opt["label"]
|
| 886 |
+
default = opt["default"]
|
| 887 |
+
description = opt.get("description", "")
|
| 888 |
+
choices = opt.get("choices", [])
|
| 889 |
+
min_val = opt.get("min_value")
|
| 890 |
+
max_val = opt.get("max_value")
|
| 891 |
+
step = opt.get("step")
|
| 892 |
+
|
| 893 |
+
if opt_type == "label":
|
| 894 |
+
gr.Markdown(f"*{label}*")
|
| 895 |
+
continue
|
| 896 |
+
elif opt_type == "text":
|
| 897 |
+
component = gr.Textbox(
|
| 898 |
+
label=label,
|
| 899 |
+
value=default or "",
|
| 900 |
+
info=description
|
| 901 |
+
)
|
| 902 |
+
elif opt_type == "number":
|
| 903 |
+
component = gr.Number(
|
| 904 |
+
label=label,
|
| 905 |
+
value=default if default is not None else 0,
|
| 906 |
+
minimum=min_val,
|
| 907 |
+
maximum=max_val,
|
| 908 |
+
step=step or 1,
|
| 909 |
+
info=description
|
| 910 |
+
)
|
| 911 |
+
elif opt_type == "switch":
|
| 912 |
+
component = gr.Checkbox(
|
| 913 |
+
label=label,
|
| 914 |
+
value=bool(default),
|
| 915 |
+
info=description
|
| 916 |
+
)
|
| 917 |
+
elif opt_type == "combo":
|
| 918 |
+
component = gr.Dropdown(
|
| 919 |
+
label=label,
|
| 920 |
+
choices=choices,
|
| 921 |
+
value=default if default in choices else (choices[0] if choices else None),
|
| 922 |
+
info=description
|
| 923 |
+
)
|
| 924 |
+
elif opt_type == "multi_select":
|
| 925 |
+
component = gr.CheckboxGroup(
|
| 926 |
+
label=label,
|
| 927 |
+
choices=choices,
|
| 928 |
+
value=default if isinstance(default, list) else [],
|
| 929 |
+
info=description
|
| 930 |
+
)
|
| 931 |
+
else:
|
| 932 |
+
component = gr.Textbox(
|
| 933 |
+
label=label,
|
| 934 |
+
value=str(default) if default else "",
|
| 935 |
+
info=description
|
| 936 |
+
)
|
| 937 |
+
|
| 938 |
+
plugin_components[key] = component
|
| 939 |
|
| 940 |
+
all_plugin_groups[pname] = plugin_group
|
| 941 |
+
all_plugin_components[pname] = plugin_components
|
| 942 |
+
|
| 943 |
+
# 插件切换时更新选项组可见性
|
| 944 |
+
def on_plugin_change(selected_plugin):
|
| 945 |
+
"""切换插件时更新选项区域可见性"""
|
| 946 |
+
updates = []
|
| 947 |
+
for pname in plugins_config.keys():
|
| 948 |
+
updates.append(gr.update(visible=(pname == selected_plugin)))
|
| 949 |
+
return updates
|
| 950 |
|
| 951 |
+
# 绑定插件切换事件
|
| 952 |
export_plugin.change(
|
| 953 |
fn=on_plugin_change,
|
| 954 |
inputs=[export_plugin],
|
| 955 |
+
outputs=list(all_plugin_groups.values())
|
| 956 |
)
|
| 957 |
|
| 958 |
# 收集选项并导出
|
| 959 |
+
def collect_and_export(zip_file, plugin_name, *all_values, progress=gr.Progress()):
|
| 960 |
+
"""收集当前插件的选项并执行导出"""
|
| 961 |
+
# 根据插件名找到对应的选项配置
|
| 962 |
+
if plugin_name not in plugins_config:
|
| 963 |
+
return "❌ 未找到插件配置", "", None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 964 |
|
| 965 |
+
# 计算当前插件的值在 all_values 中的位置
|
| 966 |
+
start_idx = 0
|
| 967 |
+
for pname in plugins_config.keys():
|
| 968 |
+
if pname == plugin_name:
|
| 969 |
+
break
|
| 970 |
+
# 统计该插件的非 label 选项数量
|
| 971 |
+
start_idx += sum(1 for opt in plugins_config[pname] if opt["type"] != "label")
|
| 972 |
+
|
| 973 |
+
# 提取当前插件的选项值
|
| 974 |
+
options = {}
|
| 975 |
+
current_idx = start_idx
|
| 976 |
+
for opt in plugins_config[plugin_name]:
|
| 977 |
+
if opt["type"] == "label":
|
| 978 |
+
continue
|
| 979 |
+
key = opt["key"]
|
| 980 |
+
if current_idx < len(all_values):
|
| 981 |
+
value = all_values[current_idx]
|
| 982 |
+
# 类型转换
|
| 983 |
+
if opt["type"] == "number":
|
| 984 |
+
value = float(value) if value is not None else opt["default"]
|
| 985 |
+
options[key] = value
|
| 986 |
+
current_idx += 1
|
| 987 |
|
| 988 |
options_json = json.dumps(options, ensure_ascii=False)
|
| 989 |
return process_export_voicebank(zip_file, plugin_name, options_json, progress)
|
|
|
|
| 1003 |
> - 导出为适配其他软件的音源格式
|
| 1004 |
""")
|
| 1005 |
|
| 1006 |
+
# 收集所有插件的所有组件作为输入
|
| 1007 |
+
all_option_components = []
|
| 1008 |
+
for pname in plugins_config.keys():
|
| 1009 |
+
if pname in all_plugin_components:
|
| 1010 |
+
for opt in plugins_config[pname]:
|
| 1011 |
+
if opt["type"] != "label" and opt["key"] in all_plugin_components[pname]:
|
| 1012 |
+
all_option_components.append(all_plugin_components[pname][opt["key"]])
|
| 1013 |
+
|
| 1014 |
export_btn.click(
|
| 1015 |
fn=collect_and_export,
|
| 1016 |
+
inputs=[export_upload, export_plugin] + all_option_components,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1017 |
outputs=[export_status, export_log, export_download]
|
| 1018 |
)
|
| 1019 |
|
tools/README.md
CHANGED
|
@@ -30,5 +30,5 @@ tools/
|
|
| 30 |
## 注意事项
|
| 31 |
|
| 32 |
- MFA 引擎仅支持 Windows 64位系统
|
| 33 |
-
- 解压后约占用
|
| 34 |
- 首次运行可能需要较长时间初始化
|
|
|
|
| 30 |
## 注意事项
|
| 31 |
|
| 32 |
- MFA 引擎仅支持 Windows 64位系统
|
| 33 |
+
- 解压后约占用 2GB 磁盘空间
|
| 34 |
- 首次运行可能需要较长时间初始化
|
tools/mfa_engine.7z
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:5ccbaac4541b3ae414ddecb8d6e092e622887b6a30155f01c3ecc84cd6aba86b
|
| 3 |
+
size 395100074
|