添加批量测试数字人功能
This commit is contained in:
@@ -83,6 +83,7 @@ dependencies {
|
||||
implementation 'androidx.constraintlayout:constraintlayout:2.1.1'
|
||||
implementation "androidx.activity:activity:1.3.0"
|
||||
implementation "androidx.fragment:fragment:1.3.0"
|
||||
implementation 'androidx.documentfile:documentfile:1.0.1'
|
||||
|
||||
implementation 'com.github.bumptech.glide:glide:4.12.0'
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
|
||||
<uses-permission android:name="android.permission.CAMERA"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="29" />
|
||||
<uses-feature android:name="android.hardware.camera" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.camera.front" android:required="false"/>
|
||||
|
||||
@@ -38,6 +41,18 @@
|
||||
android:theme="@style/Theme.DUIX.Call">
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.activity.FolderPickerActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.DUIX.Test">
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.activity.DigitalHumanTestActivity"
|
||||
android:exported="false"
|
||||
android:theme="@style/Theme.DUIX.Test">
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".ui.activity.SettingsActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package ai.guiji.duix.test.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.net.Uri
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.Locale
|
||||
import kotlin.math.ceil
|
||||
import kotlin.math.floor
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object AudioFileDecoder {
|
||||
|
||||
private const val TARGET_SAMPLE_RATE = 16_000
|
||||
|
||||
data class DecodedAudio(
|
||||
val pcm16Mono16k: ByteArray,
|
||||
val durationMs: Long,
|
||||
)
|
||||
|
||||
fun decodeToPcm16Mono16k(context: Context, uri: Uri): DecodedAudio {
|
||||
val extractor = MediaExtractor()
|
||||
var decoder: MediaCodec? = null
|
||||
val rawPcmOutput = ByteArrayOutputStream()
|
||||
var selectedTrack = -1
|
||||
var sampleRate = TARGET_SAMPLE_RATE
|
||||
var channelCount = 1
|
||||
|
||||
try {
|
||||
val scheme = uri.scheme?.lowercase(Locale.US)
|
||||
if (scheme.isNullOrEmpty() || scheme == "file") {
|
||||
val path = uri.path ?: throw IllegalArgumentException("无法读取所选音频路径")
|
||||
extractor.setDataSource(path)
|
||||
} else {
|
||||
context.contentResolver.openAssetFileDescriptor(uri, "r")?.use { descriptor ->
|
||||
extractor.setDataSource(descriptor.fileDescriptor, descriptor.startOffset, descriptor.length)
|
||||
} ?: throw IllegalArgumentException("无法打开所选音频")
|
||||
}
|
||||
|
||||
selectedTrack = findFirstAudioTrack(extractor)
|
||||
if (selectedTrack < 0) {
|
||||
throw IllegalArgumentException("没有找到可读取的音频轨道")
|
||||
}
|
||||
|
||||
extractor.selectTrack(selectedTrack)
|
||||
val inputFormat = extractor.getTrackFormat(selectedTrack)
|
||||
val mime = inputFormat.getString(MediaFormat.KEY_MIME)
|
||||
?: throw IllegalArgumentException("无法识别音频格式")
|
||||
sampleRate = inputFormat.optInt(MediaFormat.KEY_SAMPLE_RATE, TARGET_SAMPLE_RATE)
|
||||
channelCount = inputFormat.optInt(MediaFormat.KEY_CHANNEL_COUNT, 1)
|
||||
|
||||
decoder = MediaCodec.createDecoderByType(mime)
|
||||
decoder.configure(inputFormat, null, null, 0)
|
||||
decoder.start()
|
||||
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
var inputDone = false
|
||||
var outputDone = false
|
||||
|
||||
while (!outputDone) {
|
||||
if (!inputDone) {
|
||||
val inputIndex = decoder.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
val inputBuffer = decoder.getInputBuffer(inputIndex)
|
||||
?: throw IllegalStateException("解码器输入缓冲区不可用")
|
||||
val sampleSize = extractor.readSampleData(inputBuffer, 0)
|
||||
if (sampleSize < 0) {
|
||||
decoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
0,
|
||||
0L,
|
||||
MediaCodec.BUFFER_FLAG_END_OF_STREAM,
|
||||
)
|
||||
inputDone = true
|
||||
} else {
|
||||
decoder.queueInputBuffer(
|
||||
inputIndex,
|
||||
0,
|
||||
sampleSize,
|
||||
extractor.sampleTime.coerceAtLeast(0L),
|
||||
0,
|
||||
)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (val outputIndex = decoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)) {
|
||||
MediaCodec.INFO_TRY_AGAIN_LATER -> Unit
|
||||
MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
val outputFormat = decoder.outputFormat
|
||||
sampleRate = outputFormat.optInt(MediaFormat.KEY_SAMPLE_RATE, sampleRate)
|
||||
channelCount = outputFormat.optInt(MediaFormat.KEY_CHANNEL_COUNT, channelCount)
|
||||
val pcmEncoding = outputFormat.optInt(
|
||||
MediaFormat.KEY_PCM_ENCODING,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
if (pcmEncoding != AudioFormat.ENCODING_PCM_16BIT) {
|
||||
throw IllegalArgumentException("暂不支持该音频 PCM 编码")
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
if (outputIndex >= 0) {
|
||||
val outputBuffer = decoder.getOutputBuffer(outputIndex)
|
||||
?: throw IllegalStateException("解码器输出缓冲区不可用")
|
||||
if (bufferInfo.size > 0) {
|
||||
val chunk = ByteArray(bufferInfo.size)
|
||||
outputBuffer.position(bufferInfo.offset)
|
||||
outputBuffer.limit(bufferInfo.offset + bufferInfo.size)
|
||||
outputBuffer.get(chunk)
|
||||
rawPcmOutput.write(chunk)
|
||||
}
|
||||
val endOfStream = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
|
||||
decoder.releaseOutputBuffer(outputIndex, false)
|
||||
outputDone = endOfStream
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val converted = convertPcm16ToMono16k(rawPcmOutput.toByteArray(), sampleRate, channelCount)
|
||||
if (converted.isEmpty()) {
|
||||
throw IllegalArgumentException("音频解码后没有可用数据")
|
||||
}
|
||||
val durationMs = ((converted.size / 2L) * 1000L / TARGET_SAMPLE_RATE).coerceAtLeast(0L)
|
||||
return DecodedAudio(
|
||||
pcm16Mono16k = converted,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
} finally {
|
||||
runCatching { decoder?.stop() }
|
||||
runCatching { decoder?.release() }
|
||||
runCatching { if (selectedTrack >= 0) extractor.unselectTrack(selectedTrack) }
|
||||
runCatching { extractor.release() }
|
||||
runCatching { rawPcmOutput.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun findFirstAudioTrack(extractor: MediaExtractor): Int {
|
||||
for (index in 0 until extractor.trackCount) {
|
||||
val mime = extractor.getTrackFormat(index).getString(MediaFormat.KEY_MIME) ?: continue
|
||||
if (mime.startsWith("audio/")) {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
private fun convertPcm16ToMono16k(input: ByteArray, inputSampleRate: Int, inputChannels: Int): ByteArray {
|
||||
if (input.isEmpty()) {
|
||||
return ByteArray(0)
|
||||
}
|
||||
if (inputChannels <= 0) {
|
||||
throw IllegalArgumentException("无效的声道数")
|
||||
}
|
||||
|
||||
val frameCount = input.size / (2 * inputChannels)
|
||||
if (frameCount <= 0) {
|
||||
return ByteArray(0)
|
||||
}
|
||||
|
||||
val monoSamples = FloatArray(frameCount)
|
||||
var byteOffset = 0
|
||||
for (frameIndex in 0 until frameCount) {
|
||||
var sum = 0
|
||||
for (channelIndex in 0 until inputChannels) {
|
||||
sum += readShortLE(input, byteOffset).toInt()
|
||||
byteOffset += 2
|
||||
}
|
||||
monoSamples[frameIndex] = sum.toFloat() / inputChannels.toFloat()
|
||||
}
|
||||
|
||||
if (inputSampleRate == TARGET_SAMPLE_RATE) {
|
||||
val output = ByteArray(frameCount * 2)
|
||||
for (index in monoSamples.indices) {
|
||||
writeShortLE(output, index * 2, monoSamples[index].roundToInt().toShort())
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
val outputFrameCount = ceil(frameCount.toDouble() * TARGET_SAMPLE_RATE / inputSampleRate).toInt()
|
||||
val output = ByteArray(outputFrameCount * 2)
|
||||
val positionScale = inputSampleRate.toDouble() / TARGET_SAMPLE_RATE.toDouble()
|
||||
|
||||
for (outputIndex in 0 until outputFrameCount) {
|
||||
val srcPosition = outputIndex * positionScale
|
||||
val srcIndex0 = floor(srcPosition).toInt().coerceIn(0, monoSamples.lastIndex)
|
||||
val srcIndex1 = (srcIndex0 + 1).coerceAtMost(monoSamples.lastIndex)
|
||||
val fraction = srcPosition - srcIndex0
|
||||
val interpolated = monoSamples[srcIndex0] +
|
||||
((monoSamples[srcIndex1] - monoSamples[srcIndex0]) * fraction.toFloat())
|
||||
val clamped = interpolated.roundToInt().coerceIn(Short.MIN_VALUE.toInt(), Short.MAX_VALUE.toInt())
|
||||
writeShortLE(output, outputIndex * 2, clamped.toShort())
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private fun readShortLE(data: ByteArray, offset: Int): Short {
|
||||
val low = data[offset].toInt() and 0xFF
|
||||
val high = data[offset + 1].toInt() shl 8
|
||||
return (low or high).toShort()
|
||||
}
|
||||
|
||||
private fun writeShortLE(data: ByteArray, offset: Int, value: Short) {
|
||||
data[offset] = (value.toInt() and 0xFF).toByte()
|
||||
data[offset + 1] = ((value.toInt() ushr 8) and 0xFF).toByte()
|
||||
}
|
||||
|
||||
private fun MediaFormat.optInt(key: String, fallback: Int): Int {
|
||||
return runCatching {
|
||||
if (containsKey(key)) getInteger(key) else fallback
|
||||
}.getOrDefault(fallback)
|
||||
}
|
||||
|
||||
private const val TIMEOUT_US = 10_000L
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package ai.guiji.duix.test.model
|
||||
|
||||
import android.net.Uri
|
||||
|
||||
data class BatchAudioJob(
|
||||
val sourceUri: Uri,
|
||||
val sourceRelativePath: String,
|
||||
val displayName: String,
|
||||
val outputRelativePath: String,
|
||||
val attemptCount: Int = 0,
|
||||
val status: Status = Status.PENDING,
|
||||
val statusText: String = "",
|
||||
val detailText: String = "",
|
||||
val outputUri: Uri? = null,
|
||||
) {
|
||||
enum class Status {
|
||||
PENDING,
|
||||
RUNNING,
|
||||
COMPLETED,
|
||||
FAILED,
|
||||
SKIPPED,
|
||||
}
|
||||
}
|
||||
@@ -446,6 +446,22 @@ public class OmniRealtimeClient {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean commitInputAudio() {
|
||||
try {
|
||||
JSONObject event = new JSONObject();
|
||||
event.put("event_id", nextEventId());
|
||||
event.put("type", "input_audio_buffer.commit");
|
||||
boolean sent = sendEvent(event);
|
||||
if (sent) {
|
||||
imageWindowOpen.set(false);
|
||||
}
|
||||
return sent;
|
||||
} catch (JSONException e) {
|
||||
Log.e(TAG, "Failed to send input_audio_buffer.commit: " + e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private void handleServerError(JSONObject event) {
|
||||
String errMsg = event.optJSONObject("error") != null
|
||||
? event.optJSONObject("error").optString("message", "Unknown error")
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package ai.guiji.duix.test.service
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
object DocumentTreeAudioStore {
|
||||
|
||||
data class AudioEntry(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
val relativePath: String,
|
||||
)
|
||||
|
||||
data class ScanResult(
|
||||
val rootName: String,
|
||||
val files: List<AudioEntry>,
|
||||
)
|
||||
|
||||
fun scanAudioFiles(context: Context, treeUri: Uri): ScanResult {
|
||||
val root = DocumentFile.fromTreeUri(context, treeUri)
|
||||
?: throw IllegalArgumentException("无法读取所选文件夹")
|
||||
if (!root.isDirectory) {
|
||||
throw IllegalArgumentException("所选内容不是文件夹")
|
||||
}
|
||||
|
||||
val files = mutableListOf<AudioEntry>()
|
||||
traverse(root, "", files)
|
||||
return ScanResult(
|
||||
rootName = root.name ?: "selected-folder",
|
||||
files = files.sortedBy { it.relativePath.lowercase(Locale.US) },
|
||||
)
|
||||
}
|
||||
|
||||
fun writeVideoResult(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
outputRootName: String,
|
||||
sourceRelativePath: String,
|
||||
localFile: File,
|
||||
): Uri {
|
||||
val root = DocumentFile.fromTreeUri(context, treeUri)
|
||||
?: throw IllegalArgumentException("无法写入所选文件夹")
|
||||
if (!root.isDirectory) {
|
||||
throw IllegalArgumentException("所选内容不是文件夹")
|
||||
}
|
||||
|
||||
var currentDir = findOrCreateDirectory(root, outputRootName)
|
||||
val normalizedPath = sourceRelativePath.replace('\\', '/')
|
||||
val pathSegments = normalizedPath.split('/').filter { it.isNotBlank() }
|
||||
if (pathSegments.isEmpty()) {
|
||||
throw IllegalArgumentException("无效的源文件路径")
|
||||
}
|
||||
|
||||
for (segment in pathSegments.dropLast(1)) {
|
||||
currentDir = findOrCreateDirectory(currentDir, segment)
|
||||
}
|
||||
|
||||
val outputFileName = replaceExtension(pathSegments.last(), "mp4")
|
||||
currentDir.findFile(outputFileName)?.delete()
|
||||
val outputDocument = currentDir.createFile(VIDEO_MIME_TYPE, outputFileName)
|
||||
?: throw IllegalStateException("无法创建输出文件")
|
||||
|
||||
localFile.inputStream().use { input ->
|
||||
context.contentResolver.openOutputStream(outputDocument.uri, "w")?.use { output ->
|
||||
input.copyTo(output)
|
||||
} ?: throw IllegalStateException("无法打开输出文件")
|
||||
}
|
||||
return outputDocument.uri
|
||||
}
|
||||
|
||||
fun buildOutputRelativePath(outputRootName: String, sourceRelativePath: String): String {
|
||||
val normalizedPath = sourceRelativePath.replace('\\', '/')
|
||||
return outputRootName + "/" + replaceExtension(normalizedPath, "mp4")
|
||||
}
|
||||
|
||||
private fun traverse(directory: DocumentFile, parentPath: String, output: MutableList<AudioEntry>) {
|
||||
val children = directory.listFiles().sortedBy { it.name?.lowercase(Locale.US) ?: "" }
|
||||
for (child in children) {
|
||||
val childName = child.name ?: continue
|
||||
val childRelativePath = if (parentPath.isEmpty()) childName else "$parentPath/$childName"
|
||||
when {
|
||||
child.isDirectory -> traverse(child, childRelativePath, output)
|
||||
child.isFile && isAudioFile(child) -> {
|
||||
output += AudioEntry(
|
||||
uri = child.uri,
|
||||
displayName = childName,
|
||||
relativePath = childRelativePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAudioFile(document: DocumentFile): Boolean {
|
||||
val mimeType = document.type?.lowercase(Locale.US).orEmpty()
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return true
|
||||
}
|
||||
val name = document.name?.lowercase(Locale.US).orEmpty()
|
||||
return AUDIO_EXTENSIONS.any { extension -> name.endsWith(".$extension") }
|
||||
}
|
||||
|
||||
private fun findOrCreateDirectory(parent: DocumentFile, name: String): DocumentFile {
|
||||
parent.findFile(name)?.let { existing ->
|
||||
if (existing.isDirectory) {
|
||||
return existing
|
||||
}
|
||||
existing.delete()
|
||||
}
|
||||
return parent.createDirectory(name)
|
||||
?: throw IllegalStateException("无法创建目录: $name")
|
||||
}
|
||||
|
||||
private fun replaceExtension(name: String, extension: String): String {
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val baseName = if (dotIndex > 0) name.substring(0, dotIndex) else name
|
||||
return "$baseName.$extension"
|
||||
}
|
||||
|
||||
private val AUDIO_EXTENSIONS = setOf(
|
||||
"aac",
|
||||
"amr",
|
||||
"flac",
|
||||
"m4a",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"opus",
|
||||
"wav",
|
||||
"webm",
|
||||
"wma",
|
||||
"3gp",
|
||||
)
|
||||
|
||||
private const val VIDEO_MIME_TYPE = "video/mp4"
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package ai.guiji.duix.test.service
|
||||
|
||||
import android.net.Uri
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
object LocalFileAudioStore {
|
||||
|
||||
data class AudioEntry(
|
||||
val uri: Uri,
|
||||
val displayName: String,
|
||||
val relativePath: String,
|
||||
)
|
||||
|
||||
data class ScanResult(
|
||||
val rootDir: File,
|
||||
val rootName: String,
|
||||
val files: List<AudioEntry>,
|
||||
)
|
||||
|
||||
data class WriteResult(
|
||||
val file: File,
|
||||
val skipped: Boolean,
|
||||
)
|
||||
|
||||
fun scanAudioFiles(rootDir: File): ScanResult {
|
||||
val normalizedRoot = rootDir.canonicalFile
|
||||
if (!normalizedRoot.exists() || !normalizedRoot.isDirectory) {
|
||||
throw IllegalArgumentException("无法读取所选文件夹")
|
||||
}
|
||||
|
||||
val files = mutableListOf<AudioEntry>()
|
||||
traverse(normalizedRoot, "", files)
|
||||
return ScanResult(
|
||||
rootDir = normalizedRoot,
|
||||
rootName = normalizedRoot.name.ifBlank { normalizedRoot.absolutePath },
|
||||
files = files.sortedBy { it.relativePath.lowercase(Locale.US) },
|
||||
)
|
||||
}
|
||||
|
||||
fun writeVideoResult(
|
||||
rootDir: File,
|
||||
outputRootName: String,
|
||||
sourceRelativePath: String,
|
||||
localFile: File,
|
||||
): WriteResult {
|
||||
val normalizedRoot = rootDir.canonicalFile
|
||||
if (!normalizedRoot.exists() || !normalizedRoot.isDirectory) {
|
||||
throw IllegalArgumentException("无法写入所选文件夹")
|
||||
}
|
||||
|
||||
val outputFile = resolveOutputFile(normalizedRoot, outputRootName, sourceRelativePath)
|
||||
if (outputFile.exists()) {
|
||||
return WriteResult(outputFile, skipped = true)
|
||||
}
|
||||
outputFile.parentFile?.let { parent ->
|
||||
if (!parent.exists() && !parent.mkdirs()) {
|
||||
throw IllegalStateException("无法创建输出目录")
|
||||
}
|
||||
}
|
||||
return runCatching {
|
||||
localFile.copyTo(outputFile, overwrite = false)
|
||||
WriteResult(outputFile, skipped = false)
|
||||
}.getOrElse { error ->
|
||||
if (outputFile.exists()) {
|
||||
WriteResult(outputFile, skipped = true)
|
||||
} else {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resolveOutputFile(
|
||||
rootDir: File,
|
||||
outputRootName: String,
|
||||
sourceRelativePath: String,
|
||||
): File {
|
||||
val normalizedRoot = rootDir.canonicalFile
|
||||
return File(normalizedRoot, buildOutputRelativePath(outputRootName, sourceRelativePath))
|
||||
}
|
||||
|
||||
fun buildOutputRelativePath(outputRootName: String, sourceRelativePath: String): String {
|
||||
val normalizedPath = sourceRelativePath.replace('\\', '/')
|
||||
return outputRootName + "/" + replaceExtension(normalizedPath, "mp4")
|
||||
}
|
||||
|
||||
private fun traverse(directory: File, parentPath: String, output: MutableList<AudioEntry>) {
|
||||
val children = directory.listFiles()
|
||||
?.sortedBy { it.name.lowercase(Locale.US) }
|
||||
.orEmpty()
|
||||
for (child in children) {
|
||||
val childRelativePath = if (parentPath.isEmpty()) child.name else "$parentPath/${child.name}"
|
||||
when {
|
||||
child.isDirectory -> traverse(child, childRelativePath, output)
|
||||
child.isFile && isAudioFile(child) -> {
|
||||
output += AudioEntry(
|
||||
uri = Uri.fromFile(child),
|
||||
displayName = child.name,
|
||||
relativePath = childRelativePath,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isAudioFile(file: File): Boolean {
|
||||
val extension = file.extension.lowercase(Locale.US)
|
||||
return extension in AUDIO_EXTENSIONS
|
||||
}
|
||||
|
||||
private fun replaceExtension(name: String, extension: String): String {
|
||||
val dotIndex = name.lastIndexOf('.')
|
||||
val baseName = if (dotIndex > 0) name.substring(0, dotIndex) else name
|
||||
return "$baseName.$extension"
|
||||
}
|
||||
|
||||
private val AUDIO_EXTENSIONS = setOf(
|
||||
"aac",
|
||||
"amr",
|
||||
"flac",
|
||||
"m4a",
|
||||
"mp3",
|
||||
"ogg",
|
||||
"opus",
|
||||
"wav",
|
||||
"webm",
|
||||
"wma",
|
||||
"3gp",
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,133 @@
|
||||
package ai.guiji.duix.test.ui.activity
|
||||
|
||||
import ai.guiji.duix.test.R
|
||||
import ai.guiji.duix.test.databinding.ActivityFolderPickerBinding
|
||||
import ai.guiji.duix.test.ui.adapter.FolderBrowserAdapter
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.view.View
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import java.io.File
|
||||
|
||||
class FolderPickerActivity : BaseActivity() {
|
||||
|
||||
private lateinit var binding: ActivityFolderPickerBinding
|
||||
private lateinit var folderAdapter: FolderBrowserAdapter
|
||||
private lateinit var browseRoot: File
|
||||
private lateinit var currentDirectory: File
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
binding = ActivityFolderPickerBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
|
||||
browseRoot = resolveBrowseRoot()
|
||||
currentDirectory = resolveInitialDirectory(intent.getStringExtra(EXTRA_INITIAL_PATH))
|
||||
|
||||
onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) {
|
||||
override fun handleOnBackPressed() {
|
||||
if (canNavigateUp()) {
|
||||
navigateUp()
|
||||
} else {
|
||||
isEnabled = false
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
setupList()
|
||||
setupActions()
|
||||
renderCurrentDirectory()
|
||||
}
|
||||
|
||||
private fun setupList() {
|
||||
folderAdapter = FolderBrowserAdapter { folder ->
|
||||
currentDirectory = folder
|
||||
renderCurrentDirectory()
|
||||
}
|
||||
binding.rvFolders.layoutManager = LinearLayoutManager(this)
|
||||
binding.rvFolders.adapter = folderAdapter
|
||||
}
|
||||
|
||||
private fun setupActions() {
|
||||
binding.btnBack.setOnClickListener { finish() }
|
||||
binding.btnNavigateUp.setOnClickListener { navigateUp() }
|
||||
binding.btnSelectCurrent.setOnClickListener {
|
||||
setResult(
|
||||
Activity.RESULT_OK,
|
||||
Intent().putExtra(EXTRA_SELECTED_PATH, currentDirectory.absolutePath),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderCurrentDirectory() {
|
||||
val subFolders = currentDirectory.listFiles()
|
||||
?.filter { it.isDirectory && it.canRead() }
|
||||
?.sortedBy { it.name.lowercase() }
|
||||
.orEmpty()
|
||||
|
||||
binding.tvCurrentFolderName.text = currentDirectory.name.ifBlank { currentDirectory.absolutePath }
|
||||
binding.tvCurrentPath.text = currentDirectory.absolutePath
|
||||
binding.btnNavigateUp.isEnabled = canNavigateUp()
|
||||
binding.tvEmpty.visibility = if (subFolders.isEmpty()) View.VISIBLE else View.GONE
|
||||
binding.rvFolders.visibility = if (subFolders.isEmpty()) View.GONE else View.VISIBLE
|
||||
folderAdapter.submitList(subFolders)
|
||||
}
|
||||
|
||||
private fun navigateUp() {
|
||||
if (!canNavigateUp()) {
|
||||
return
|
||||
}
|
||||
currentDirectory.parentFile?.let { parent ->
|
||||
currentDirectory = parent
|
||||
renderCurrentDirectory()
|
||||
}
|
||||
}
|
||||
|
||||
private fun canNavigateUp(): Boolean {
|
||||
val rootPath = browseRoot.canonicalPath
|
||||
val currentPath = currentDirectory.canonicalPath
|
||||
return currentPath != rootPath && currentDirectory.parentFile != null && currentPath.startsWith(rootPath)
|
||||
}
|
||||
|
||||
private fun resolveBrowseRoot(): File {
|
||||
return Environment.getExternalStorageDirectory().canonicalFile
|
||||
}
|
||||
|
||||
private fun resolveInitialDirectory(initialPath: String?): File {
|
||||
val candidates = listOfNotNull(
|
||||
initialPath?.takeIf { it.isNotBlank() }?.let(::File),
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES),
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
browseRoot,
|
||||
)
|
||||
for (candidate in candidates) {
|
||||
val file = runCatching { candidate.canonicalFile }.getOrNull() ?: continue
|
||||
if (!file.exists() || !file.isDirectory || !file.canRead()) {
|
||||
continue
|
||||
}
|
||||
if (file.canonicalPath.startsWith(browseRoot.canonicalPath)) {
|
||||
return file
|
||||
}
|
||||
}
|
||||
return browseRoot
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val EXTRA_INITIAL_PATH = "extra_initial_path"
|
||||
const val EXTRA_SELECTED_PATH = "extra_selected_path"
|
||||
|
||||
fun createIntent(context: Context, initialPath: String?): Intent {
|
||||
return Intent(context, FolderPickerActivity::class.java).apply {
|
||||
putExtra(EXTRA_INITIAL_PATH, initialPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,9 @@ class SettingsActivity : BaseActivity() {
|
||||
binding.rowDebugMode.setOnClickListener {
|
||||
binding.switchDebugMode.performClick()
|
||||
}
|
||||
binding.rowDigitalHumanTest.setOnClickListener {
|
||||
startActivity(Intent(mContext, DigitalHumanTestActivity::class.java))
|
||||
}
|
||||
|
||||
binding.tvVersionValue.text = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE})"
|
||||
binding.tvServerValue.text = Uri.parse(DashScopeConfig.MONITOR_BASE_URL).host
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package ai.guiji.duix.test.ui.adapter
|
||||
|
||||
import ai.guiji.duix.test.R
|
||||
import ai.guiji.duix.test.databinding.ItemDigitalHumanBatchJobBinding
|
||||
import ai.guiji.duix.test.model.BatchAudioJob
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.util.Locale
|
||||
|
||||
class DigitalHumanBatchJobAdapter : RecyclerView.Adapter<DigitalHumanBatchJobAdapter.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<BatchAudioJob>()
|
||||
|
||||
class ViewHolder(val binding: ItemDigitalHumanBatchJobBinding) :
|
||||
RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemDigitalHumanBatchJobBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false,
|
||||
)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
val context = holder.binding.root.context
|
||||
holder.binding.tvIndex.text = String.format(Locale.getDefault(), "%02d", position + 1)
|
||||
holder.binding.tvPath.text = item.sourceRelativePath
|
||||
holder.binding.tvStatus.text = item.statusText
|
||||
holder.binding.tvDetail.text = item.detailText
|
||||
holder.binding.tvDetail.alpha = if (item.detailText.isBlank()) 0.55f else 1f
|
||||
|
||||
val statusColor = when (item.status) {
|
||||
BatchAudioJob.Status.PENDING -> R.color.warm_status_pending
|
||||
BatchAudioJob.Status.RUNNING -> R.color.warm_status_running
|
||||
BatchAudioJob.Status.COMPLETED -> R.color.warm_status_success
|
||||
BatchAudioJob.Status.FAILED -> R.color.warm_status_error
|
||||
BatchAudioJob.Status.SKIPPED -> R.color.warm_status_skipped
|
||||
}
|
||||
holder.binding.tvStatus.setTextColor(ContextCompat.getColor(context, statusColor))
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun submitList(newItems: List<BatchAudioJob>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun updateItem(index: Int, item: BatchAudioJob) {
|
||||
if (index !in items.indices) {
|
||||
return
|
||||
}
|
||||
items[index] = item
|
||||
notifyItemChanged(index)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package ai.guiji.duix.test.ui.adapter
|
||||
|
||||
import ai.guiji.duix.test.databinding.ItemFolderEntryBinding
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import java.io.File
|
||||
|
||||
class FolderBrowserAdapter(
|
||||
private val onFolderSelected: (File) -> Unit,
|
||||
) : RecyclerView.Adapter<FolderBrowserAdapter.ViewHolder>() {
|
||||
|
||||
private val items = mutableListOf<File>()
|
||||
|
||||
class ViewHolder(val binding: ItemFolderEntryBinding) : RecyclerView.ViewHolder(binding.root)
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
|
||||
val binding = ItemFolderEntryBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false,
|
||||
)
|
||||
return ViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
|
||||
val item = items[position]
|
||||
holder.binding.tvFolderName.text = item.name.ifBlank { item.absolutePath }
|
||||
holder.binding.tvFolderPath.text = item.absolutePath
|
||||
holder.binding.root.setOnClickListener {
|
||||
onFolderSelected(item)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = items.size
|
||||
|
||||
fun submitList(newItems: List<File>) {
|
||||
items.clear()
|
||||
items.addAll(newItems)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,560 @@
|
||||
package ai.guiji.duix.test.video
|
||||
|
||||
import ai.guiji.duix.sdk.client.bean.ImageFrame
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaFormat
|
||||
import android.media.MediaMuxer
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
class DigitalHumanMp4Exporter(
|
||||
private val outputFile: File,
|
||||
) {
|
||||
|
||||
data class ExportResult(
|
||||
val file: File,
|
||||
val durationMs: Long,
|
||||
)
|
||||
|
||||
private data class QueuedAudio(val data: ByteArray)
|
||||
|
||||
private data class FramePacket(
|
||||
val raw: ByteArray,
|
||||
val mask: ByteArray,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val timestampNs: Long,
|
||||
)
|
||||
|
||||
private data class EncodedSample(
|
||||
val data: ByteArray,
|
||||
val info: MediaCodec.BufferInfo,
|
||||
)
|
||||
|
||||
private val executor = Executors.newSingleThreadExecutor()
|
||||
|
||||
private var startNs = -1L
|
||||
private var initialized = false
|
||||
private var closed = false
|
||||
|
||||
private var videoWidth = 0
|
||||
private var videoHeight = 0
|
||||
private var videoColorFormat = MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible
|
||||
|
||||
private var muxer: MediaMuxer? = null
|
||||
private var videoEncoder: MediaCodec? = null
|
||||
private var audioEncoder: MediaCodec? = null
|
||||
|
||||
private var videoTrackIndex = -1
|
||||
private var audioTrackIndex = -1
|
||||
private var muxerStarted = false
|
||||
|
||||
private val pendingAudioChunks = ArrayList<QueuedAudio>()
|
||||
private val pendingVideoSamples = ArrayList<EncodedSample>()
|
||||
private val pendingAudioSamples = ArrayList<EncodedSample>()
|
||||
|
||||
private var lastFramePacket: FramePacket? = null
|
||||
private var lastVideoPtsUs = -1L
|
||||
private var audioBytesQueued = 0L
|
||||
|
||||
fun recordAudioChunk(pcm24k: ByteArray, timestampNs: Long = System.nanoTime()) {
|
||||
if (pcm24k.isEmpty() || closed) {
|
||||
return
|
||||
}
|
||||
val copy = pcm24k.clone()
|
||||
executor.execute {
|
||||
if (closed) {
|
||||
return@execute
|
||||
}
|
||||
ensureStarted(timestampNs)
|
||||
if (!initialized) {
|
||||
pendingAudioChunks.add(QueuedAudio(copy))
|
||||
} else {
|
||||
queueAudioInternal(copy)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun recordFrame(imageFrame: ImageFrame, timestampNs: Long = System.nanoTime()) {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
val rawSize = imageFrame.width * imageFrame.height * RGB_CHANNELS
|
||||
val rawCopy = copyBufferBytes(imageFrame.rawBuffer, rawSize)
|
||||
val maskCopy = copyBufferBytes(imageFrame.maskBuffer, rawSize)
|
||||
val packet = FramePacket(rawCopy, maskCopy, imageFrame.width, imageFrame.height, timestampNs)
|
||||
executor.execute {
|
||||
if (closed) {
|
||||
return@execute
|
||||
}
|
||||
ensureStarted(timestampNs)
|
||||
if (!initialized) {
|
||||
initializeEncoders(packet.width, packet.height)
|
||||
}
|
||||
queueVideoInternal(packet)
|
||||
lastFramePacket = packet
|
||||
if (pendingAudioChunks.isNotEmpty()) {
|
||||
flushPendingAudioChunks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun finish(): ExportResult {
|
||||
val latch = CountDownLatch(1)
|
||||
var result: ExportResult? = null
|
||||
var failure: Throwable? = null
|
||||
|
||||
executor.execute {
|
||||
try {
|
||||
closed = true
|
||||
if (!initialized) {
|
||||
throw IllegalStateException("未收到数字人渲染帧,无法导出视频")
|
||||
}
|
||||
if (pendingAudioChunks.isNotEmpty()) {
|
||||
flushPendingAudioChunks()
|
||||
}
|
||||
if (audioBytesQueued <= 0L) {
|
||||
throw IllegalStateException("未收到模型音频响应")
|
||||
}
|
||||
|
||||
val audioDurationUs = calculateAudioDurationUs()
|
||||
val lastFrame = lastFramePacket
|
||||
if (lastFrame != null && audioDurationUs > lastVideoPtsUs + FRAME_PADDING_US) {
|
||||
queueVideoInternal(lastFrame.copy(timestampNs = startNs + (audioDurationUs * 1000L)))
|
||||
}
|
||||
|
||||
signalAudioEndOfStream(audioDurationUs)
|
||||
drainAudioEncoder(endOfStream = true)
|
||||
|
||||
val finalVideoPtsUs = max(lastVideoPtsUs, audioDurationUs)
|
||||
signalVideoEndOfStream(finalVideoPtsUs)
|
||||
drainVideoEncoder(endOfStream = true)
|
||||
|
||||
releaseInternal(deleteOutput = false)
|
||||
result = ExportResult(
|
||||
file = outputFile,
|
||||
durationMs = (audioDurationUs / 1000L).coerceAtLeast(0L),
|
||||
)
|
||||
} catch (throwable: Throwable) {
|
||||
failure = throwable
|
||||
releaseInternal(deleteOutput = true)
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
|
||||
if (!latch.await(FINISH_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
executor.shutdownNow()
|
||||
throw IllegalStateException("视频导出超时")
|
||||
}
|
||||
executor.shutdown()
|
||||
failure?.let { throw it }
|
||||
return result ?: throw IllegalStateException("视频导出失败")
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
val latch = CountDownLatch(1)
|
||||
executor.execute {
|
||||
try {
|
||||
closed = true
|
||||
releaseInternal(deleteOutput = true)
|
||||
} finally {
|
||||
latch.countDown()
|
||||
}
|
||||
}
|
||||
latch.await(CANCEL_TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun ensureStarted(timestampNs: Long) {
|
||||
if (startNs < 0L) {
|
||||
startNs = timestampNs
|
||||
}
|
||||
}
|
||||
|
||||
private fun initializeEncoders(width: Int, height: Int) {
|
||||
if (initialized) {
|
||||
return
|
||||
}
|
||||
if (width <= 0 || height <= 0 || width % 2 != 0 || height % 2 != 0) {
|
||||
throw IllegalArgumentException("渲染帧尺寸无效: ${width}x${height}")
|
||||
}
|
||||
|
||||
outputFile.parentFile?.mkdirs()
|
||||
if (outputFile.exists()) {
|
||||
outputFile.delete()
|
||||
}
|
||||
|
||||
videoWidth = width
|
||||
videoHeight = height
|
||||
muxer = MediaMuxer(outputFile.absolutePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4)
|
||||
|
||||
videoEncoder = MediaCodec.createEncoderByType(VIDEO_MIME_TYPE).apply {
|
||||
videoColorFormat = selectVideoColorFormat(codecInfo)
|
||||
val format = MediaFormat.createVideoFormat(VIDEO_MIME_TYPE, width, height).apply {
|
||||
setInteger(MediaFormat.KEY_COLOR_FORMAT, videoColorFormat)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, max(width * height * VIDEO_BITRATE_FACTOR, MIN_VIDEO_BITRATE))
|
||||
setInteger(MediaFormat.KEY_FRAME_RATE, VIDEO_FRAME_RATE)
|
||||
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, VIDEO_I_FRAME_INTERVAL)
|
||||
}
|
||||
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
start()
|
||||
}
|
||||
|
||||
audioEncoder = MediaCodec.createEncoderByType(AUDIO_MIME_TYPE).apply {
|
||||
val format = MediaFormat.createAudioFormat(AUDIO_MIME_TYPE, AUDIO_SAMPLE_RATE, AUDIO_CHANNEL_COUNT).apply {
|
||||
setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC)
|
||||
setInteger(MediaFormat.KEY_BIT_RATE, AUDIO_BITRATE)
|
||||
setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, AUDIO_MAX_INPUT_SIZE)
|
||||
}
|
||||
configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||
start()
|
||||
}
|
||||
|
||||
initialized = true
|
||||
}
|
||||
|
||||
private fun flushPendingAudioChunks() {
|
||||
if (pendingAudioChunks.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val iterator = pendingAudioChunks.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
queueAudioInternal(iterator.next().data)
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueAudioInternal(pcm24k: ByteArray) {
|
||||
var offset = 0
|
||||
val encoder = audioEncoder ?: throw IllegalStateException("音频编码器尚未初始化")
|
||||
while (offset < pcm24k.size) {
|
||||
val inputIndex = dequeueInputBuffer(encoder)
|
||||
val inputBuffer = encoder.getInputBuffer(inputIndex)
|
||||
?: throw IllegalStateException("音频输入缓冲区不可用")
|
||||
inputBuffer.clear()
|
||||
val chunkSize = min(inputBuffer.remaining(), pcm24k.size - offset)
|
||||
inputBuffer.put(pcm24k, offset, chunkSize)
|
||||
val ptsUs = audioBytesQueued * MICROS_PER_SECOND / AUDIO_BYTES_PER_SECOND
|
||||
encoder.queueInputBuffer(inputIndex, 0, chunkSize, ptsUs, 0)
|
||||
audioBytesQueued += chunkSize.toLong()
|
||||
offset += chunkSize
|
||||
drainAudioEncoder(endOfStream = false)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queueVideoInternal(packet: FramePacket) {
|
||||
if (packet.width != videoWidth || packet.height != videoHeight) {
|
||||
throw IllegalStateException("渲染帧尺寸发生变化,无法继续编码")
|
||||
}
|
||||
val encoder = videoEncoder ?: throw IllegalStateException("视频编码器尚未初始化")
|
||||
val inputIndex = dequeueInputBuffer(encoder)
|
||||
val inputBuffer = encoder.getInputBuffer(inputIndex)
|
||||
?: throw IllegalStateException("视频输入缓冲区不可用")
|
||||
inputBuffer.clear()
|
||||
|
||||
val yuv = convertFrameToYuv(packet.raw, packet.mask, packet.width, packet.height, videoColorFormat)
|
||||
if (inputBuffer.remaining() < yuv.size) {
|
||||
throw IllegalStateException("视频输入缓冲区容量不足")
|
||||
}
|
||||
|
||||
inputBuffer.put(yuv)
|
||||
val ptsUs = computeVideoPresentationTimeUs(packet.timestampNs)
|
||||
encoder.queueInputBuffer(inputIndex, 0, yuv.size, ptsUs, 0)
|
||||
lastVideoPtsUs = ptsUs
|
||||
drainVideoEncoder(endOfStream = false)
|
||||
}
|
||||
|
||||
private fun signalAudioEndOfStream(finalPtsUs: Long) {
|
||||
val encoder = audioEncoder ?: return
|
||||
val inputIndex = dequeueInputBuffer(encoder)
|
||||
encoder.queueInputBuffer(inputIndex, 0, 0, finalPtsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
}
|
||||
|
||||
private fun signalVideoEndOfStream(finalPtsUs: Long) {
|
||||
val encoder = videoEncoder ?: return
|
||||
val inputIndex = dequeueInputBuffer(encoder)
|
||||
encoder.queueInputBuffer(inputIndex, 0, 0, finalPtsUs, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
}
|
||||
|
||||
private fun drainAudioEncoder(endOfStream: Boolean) {
|
||||
drainEncoder(
|
||||
encoder = audioEncoder,
|
||||
pendingSamples = pendingAudioSamples,
|
||||
getTrackIndex = { audioTrackIndex },
|
||||
setTrackIndex = { audioTrackIndex = it },
|
||||
endOfStream = endOfStream,
|
||||
)
|
||||
}
|
||||
|
||||
private fun drainVideoEncoder(endOfStream: Boolean) {
|
||||
drainEncoder(
|
||||
encoder = videoEncoder,
|
||||
pendingSamples = pendingVideoSamples,
|
||||
getTrackIndex = { videoTrackIndex },
|
||||
setTrackIndex = { videoTrackIndex = it },
|
||||
endOfStream = endOfStream,
|
||||
)
|
||||
}
|
||||
|
||||
private fun drainEncoder(
|
||||
encoder: MediaCodec?,
|
||||
pendingSamples: MutableList<EncodedSample>,
|
||||
getTrackIndex: () -> Int,
|
||||
setTrackIndex: (Int) -> Unit,
|
||||
endOfStream: Boolean,
|
||||
) {
|
||||
if (encoder == null) {
|
||||
return
|
||||
}
|
||||
val bufferInfo = MediaCodec.BufferInfo()
|
||||
while (true) {
|
||||
val outputIndex = encoder.dequeueOutputBuffer(bufferInfo, TIMEOUT_US)
|
||||
when {
|
||||
outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER -> {
|
||||
if (!endOfStream) {
|
||||
return
|
||||
}
|
||||
}
|
||||
outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED -> {
|
||||
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
|
||||
val trackIndex = muxerInstance.addTrack(encoder.outputFormat)
|
||||
setTrackIndex(trackIndex)
|
||||
startMuxerIfReady()
|
||||
}
|
||||
outputIndex >= 0 -> {
|
||||
val outputBuffer = encoder.getOutputBuffer(outputIndex)
|
||||
?: throw IllegalStateException("编码器输出缓冲区不可用")
|
||||
|
||||
if ((bufferInfo.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) {
|
||||
bufferInfo.size = 0
|
||||
}
|
||||
|
||||
if (bufferInfo.size > 0) {
|
||||
val trackIndex = getTrackIndex()
|
||||
if (trackIndex < 0) {
|
||||
throw IllegalStateException("编码轨道尚未创建")
|
||||
}
|
||||
val sample = copyEncodedSample(outputBuffer, bufferInfo)
|
||||
if (muxerStarted) {
|
||||
writeEncodedSample(trackIndex, sample)
|
||||
} else {
|
||||
pendingSamples.add(sample)
|
||||
}
|
||||
}
|
||||
|
||||
val endReached = (bufferInfo.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
|
||||
encoder.releaseOutputBuffer(outputIndex, false)
|
||||
if (endReached) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startMuxerIfReady() {
|
||||
if (muxerStarted || videoTrackIndex < 0 || audioTrackIndex < 0) {
|
||||
return
|
||||
}
|
||||
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
|
||||
muxerInstance.start()
|
||||
muxerStarted = true
|
||||
|
||||
flushPendingSamples(videoTrackIndex, pendingVideoSamples)
|
||||
flushPendingSamples(audioTrackIndex, pendingAudioSamples)
|
||||
}
|
||||
|
||||
private fun flushPendingSamples(trackIndex: Int, pendingSamples: MutableList<EncodedSample>) {
|
||||
if (pendingSamples.isEmpty()) {
|
||||
return
|
||||
}
|
||||
pendingSamples.sortBy { it.info.presentationTimeUs }
|
||||
val iterator = pendingSamples.iterator()
|
||||
while (iterator.hasNext()) {
|
||||
writeEncodedSample(trackIndex, iterator.next())
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeEncodedSample(trackIndex: Int, sample: EncodedSample) {
|
||||
val muxerInstance = muxer ?: throw IllegalStateException("复用器未初始化")
|
||||
muxerInstance.writeSampleData(trackIndex, ByteBuffer.wrap(sample.data), sample.info)
|
||||
}
|
||||
|
||||
private fun copyEncodedSample(buffer: ByteBuffer, info: MediaCodec.BufferInfo): EncodedSample {
|
||||
val data = ByteArray(info.size)
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
buffer.get(data)
|
||||
val copiedInfo = MediaCodec.BufferInfo().apply {
|
||||
offset = 0
|
||||
size = data.size
|
||||
presentationTimeUs = info.presentationTimeUs
|
||||
flags = info.flags
|
||||
}
|
||||
return EncodedSample(data, copiedInfo)
|
||||
}
|
||||
|
||||
private fun computeVideoPresentationTimeUs(timestampNs: Long): Long {
|
||||
val relativeUs = ((timestampNs - startNs) / 1_000L).coerceAtLeast(0L)
|
||||
return if (lastVideoPtsUs < 0L) {
|
||||
relativeUs
|
||||
} else {
|
||||
max(relativeUs, lastVideoPtsUs + 1L)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateAudioDurationUs(): Long {
|
||||
return audioBytesQueued * MICROS_PER_SECOND / AUDIO_BYTES_PER_SECOND
|
||||
}
|
||||
|
||||
private fun dequeueInputBuffer(encoder: MediaCodec): Int {
|
||||
while (true) {
|
||||
val inputIndex = encoder.dequeueInputBuffer(TIMEOUT_US)
|
||||
if (inputIndex >= 0) {
|
||||
return inputIndex
|
||||
}
|
||||
if (inputIndex != MediaCodec.INFO_TRY_AGAIN_LATER) {
|
||||
throw IllegalStateException("获取编码器输入缓冲区失败")
|
||||
}
|
||||
if (closed) {
|
||||
throw IllegalStateException("编码任务已结束")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun releaseInternal(deleteOutput: Boolean) {
|
||||
runCatching {
|
||||
if (muxerStarted) {
|
||||
muxer?.stop()
|
||||
}
|
||||
}
|
||||
runCatching { muxer?.release() }
|
||||
runCatching { videoEncoder?.stop() }
|
||||
runCatching { videoEncoder?.release() }
|
||||
runCatching { audioEncoder?.stop() }
|
||||
runCatching { audioEncoder?.release() }
|
||||
|
||||
muxer = null
|
||||
videoEncoder = null
|
||||
audioEncoder = null
|
||||
muxerStarted = false
|
||||
initialized = false
|
||||
pendingAudioChunks.clear()
|
||||
pendingAudioSamples.clear()
|
||||
pendingVideoSamples.clear()
|
||||
|
||||
if (deleteOutput && outputFile.exists()) {
|
||||
outputFile.delete()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectVideoColorFormat(codecInfo: MediaCodecInfo): Int {
|
||||
val capabilities = codecInfo.getCapabilitiesForType(VIDEO_MIME_TYPE)
|
||||
val candidates = intArrayOf(
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Planar,
|
||||
MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420Flexible,
|
||||
)
|
||||
for (candidate in candidates) {
|
||||
if (capabilities.colorFormats.contains(candidate)) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
throw IllegalStateException("当前设备不支持可用的 YUV420 视频编码格式")
|
||||
}
|
||||
|
||||
private fun convertFrameToYuv(
|
||||
raw: ByteArray,
|
||||
mask: ByteArray,
|
||||
width: Int,
|
||||
height: Int,
|
||||
colorFormat: Int,
|
||||
): ByteArray {
|
||||
val frameSize = width * height
|
||||
val output = ByteArray(frameSize + frameSize / 2)
|
||||
val isSemiPlanar = colorFormat == MediaCodecInfo.CodecCapabilities.COLOR_FormatYUV420SemiPlanar
|
||||
var yIndex = 0
|
||||
var uIndex = frameSize
|
||||
var vIndex = if (isSemiPlanar) frameSize + 1 else frameSize + frameSize / 4
|
||||
|
||||
for (row in 0 until height) {
|
||||
for (col in 0 until width) {
|
||||
val pixelIndex = row * width + col
|
||||
val rgbOffset = pixelIndex * RGB_CHANNELS
|
||||
|
||||
val alpha = ((mask[rgbOffset].toInt() and 0xFF) +
|
||||
(mask[rgbOffset + 1].toInt() and 0xFF) +
|
||||
(mask[rgbOffset + 2].toInt() and 0xFF)) / RGB_CHANNELS
|
||||
|
||||
val blue = ((raw[rgbOffset].toInt() and 0xFF) * alpha) / 255
|
||||
val green = ((raw[rgbOffset + 1].toInt() and 0xFF) * alpha) / 255
|
||||
val red = ((raw[rgbOffset + 2].toInt() and 0xFF) * alpha) / 255
|
||||
|
||||
val y = clampToByte(((66 * red + 129 * green + 25 * blue + 128) shr 8) + 16)
|
||||
val u = clampToByte(((-38 * red - 74 * green + 112 * blue + 128) shr 8) + 128)
|
||||
val v = clampToByte(((112 * red - 94 * green - 18 * blue + 128) shr 8) + 128)
|
||||
|
||||
output[yIndex++] = y.toByte()
|
||||
|
||||
if (row % 2 == 0 && col % 2 == 0) {
|
||||
if (isSemiPlanar) {
|
||||
output[uIndex] = u.toByte()
|
||||
output[vIndex] = v.toByte()
|
||||
uIndex += 2
|
||||
vIndex += 2
|
||||
} else {
|
||||
output[uIndex++] = u.toByte()
|
||||
output[vIndex++] = v.toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
private fun clampToByte(value: Int): Int {
|
||||
return value.coerceIn(0, 255)
|
||||
}
|
||||
|
||||
private fun copyBufferBytes(buffer: ByteBuffer, size: Int): ByteArray {
|
||||
if (buffer.hasArray()) {
|
||||
return buffer.array().copyOf(size)
|
||||
}
|
||||
val duplicate = buffer.duplicate()
|
||||
duplicate.position(0)
|
||||
val output = ByteArray(size)
|
||||
duplicate.get(output, 0, min(size, duplicate.remaining()))
|
||||
return output
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val VIDEO_MIME_TYPE = "video/avc"
|
||||
private const val AUDIO_MIME_TYPE = "audio/mp4a-latm"
|
||||
|
||||
private const val AUDIO_SAMPLE_RATE = 24_000
|
||||
private const val AUDIO_CHANNEL_COUNT = 1
|
||||
private const val AUDIO_BYTES_PER_SECOND = AUDIO_SAMPLE_RATE * AUDIO_CHANNEL_COUNT * 2L
|
||||
private const val AUDIO_BITRATE = 64_000
|
||||
private const val AUDIO_MAX_INPUT_SIZE = 48_000
|
||||
|
||||
private const val VIDEO_FRAME_RATE = 24
|
||||
private const val VIDEO_I_FRAME_INTERVAL = 1
|
||||
private const val VIDEO_BITRATE_FACTOR = 6
|
||||
private const val MIN_VIDEO_BITRATE = 1_200_000
|
||||
|
||||
private const val RGB_CHANNELS = 3
|
||||
private const val FRAME_PADDING_US = 80_000L
|
||||
private const val MICROS_PER_SECOND = 1_000_000L
|
||||
private const val TIMEOUT_US = 10_000L
|
||||
private const val FINISH_TIMEOUT_SECONDS = 60L
|
||||
private const val CANCEL_TIMEOUT_SECONDS = 5L
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/warm_bg"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingTop="28dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingBottom="28dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/shape_warm_icon_button"
|
||||
android:contentDescription="@string/device_settings_back"
|
||||
android:padding="10dp"
|
||||
android:scaleType="centerInside"
|
||||
android:src="@drawable/ic_arrow_back" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/digital_human_test_title"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="25sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="@string/digital_human_test_subtitle"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="340dp"
|
||||
android:layout_marginTop="18dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:clipChildren="true"
|
||||
android:clipToPadding="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/ivBg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<ai.guiji.duix.sdk.client.render.DUIXTextureView
|
||||
android:id="@+id/glTextureView"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPreviewHint"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:background="#8A1A1E1F"
|
||||
android:gravity="center"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvSubtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom"
|
||||
android:background="#99000000"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:padding="14dp"
|
||||
android:textColor="@color/white"
|
||||
android:textSize="16sp"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStatus"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:padding="18dp"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvInputTranscriptLabel"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/digital_human_test_input_transcript_title"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvInputTranscript"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:padding="18dp"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="16sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutAvatarSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/voice_clone_choose_avatar"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvAvatars"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:clipToPadding="false"
|
||||
android:nestedScrollingEnabled="false"
|
||||
android:paddingBottom="12dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/digital_human_test_folder_title"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFolderName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFolderMeta"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatButton
|
||||
android:id="@+id/btnSelectFolder"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="54dp"
|
||||
android:layout_marginTop="14dp"
|
||||
android:background="@drawable/selector_warm_secondary_button"
|
||||
android:elevation="0dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/digital_human_test_select_folder"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/text_warm_secondary_button"
|
||||
android:textSize="17sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layoutProgressSection"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:visibility="gone">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/digital_human_test_progress_title"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProgressSummary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvProgressEmpty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:padding="18dp"
|
||||
android:text="@string/digital_human_test_progress_empty"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvBatchJobs"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:clipToPadding="false"
|
||||
android:nestedScrollingEnabled="false"
|
||||
android:paddingBottom="6dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.appcompat.widget.AppCompatButton
|
||||
android:id="@+id/btnPrimaryAction"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:layout_marginTop="20dp"
|
||||
android:background="@drawable/selector_warm_primary_button"
|
||||
android:elevation="0dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/digital_human_test_generate"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/text_warm_primary_button"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
@@ -0,0 +1,132 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/warm_bg"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingTop="28dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingBottom="20dp">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/btnBack"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/shape_warm_icon_button"
|
||||
android:contentDescription="@string/device_settings_back"
|
||||
android:padding="10dp"
|
||||
android:scaleType="centerInside"
|
||||
android:src="@drawable/ic_arrow_back" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:text="@string/folder_picker_title"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="25sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="@string/folder_picker_subtitle"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="15sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="18dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:orientation="vertical"
|
||||
android:padding="18dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrentFolderName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvCurrentPath"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.appcompat.widget.AppCompatButton
|
||||
android:id="@+id/btnNavigateUp"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/selector_warm_secondary_button"
|
||||
android:elevation="0dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/folder_picker_up"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/text_warm_secondary_button"
|
||||
android:textSize="16sp" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatButton
|
||||
android:id="@+id/btnSelectCurrent"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/selector_warm_primary_button"
|
||||
android:elevation="0dp"
|
||||
android:stateListAnimator="@null"
|
||||
android:text="@string/folder_picker_select_current"
|
||||
android:textAllCaps="false"
|
||||
android:textColor="@color/text_warm_primary_button"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="@string/folder_picker_list_title"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvEmpty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:padding="18dp"
|
||||
android:text="@string/folder_picker_empty"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="15sp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/rvFolders"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="10dp"
|
||||
android:layout_weight="1"
|
||||
android:clipToPadding="false"
|
||||
android:nestedScrollingEnabled="false"
|
||||
android:paddingBottom="8dp" />
|
||||
</LinearLayout>
|
||||
@@ -134,6 +134,51 @@
|
||||
android:text="" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/rowDigitalHumanTest"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:foreground="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="18dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/settings_digital_human_test_title"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:lineSpacingExtra="4dp"
|
||||
android:text="@string/settings_digital_human_test_desc"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="14sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_chevron_right" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
app:cardBackgroundColor="@color/warm_surface"
|
||||
app:cardCornerRadius="18dp"
|
||||
app:cardElevation="2dp"
|
||||
app:strokeColor="@color/warm_surface_alt"
|
||||
app:strokeWidth="1dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvIndex"
|
||||
android:layout_width="44dp"
|
||||
android:layout_height="44dp"
|
||||
android:background="@drawable/shape_warm_icon_button"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="15sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="14dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvPath"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvStatus"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvDetail"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
@@ -0,0 +1,41 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="10dp"
|
||||
android:background="@drawable/shape_warm_feature_card"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFolderName"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="@color/warm_text_primary"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tvFolderPath"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:lineSpacingExtra="3dp"
|
||||
android:textColor="@color/warm_text_secondary"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginStart="12dp"
|
||||
android:contentDescription="@null"
|
||||
android:src="@drawable/ic_chevron_right" />
|
||||
</LinearLayout>
|
||||
@@ -82,10 +82,60 @@
|
||||
<string name="settings_device_code_desc">打开设备二维码页面后,家里人扫一扫就能把这台设备绑定到自己的账号。</string>
|
||||
<string name="settings_debug_title">显示调试详情</string>
|
||||
<string name="settings_debug_desc">开启后,聊天页面会显示完整的交互日志,包括工具调用的输入和输出。</string>
|
||||
<string name="settings_digital_human_test_title">数字人测试</string>
|
||||
<string name="settings_digital_human_test_desc">选择一个文件夹,批量把里面的音频送给 Omni,关闭 search 和 tooluse,并按原目录结构导出数字人 mp4。</string>
|
||||
<string name="settings_about_title">关于这台设备</string>
|
||||
<string name="settings_version_label">应用版本</string>
|
||||
<string name="settings_server_label">家属面板域名</string>
|
||||
<string name="settings_device_label">当前设备 UUID</string>
|
||||
<string name="digital_human_test_title">数字人测试</string>
|
||||
<string name="digital_human_test_subtitle">选择一个文件夹后,应用会递归处理其中的音频,关闭 search 和 tools,并按原目录结构批量导出数字人回应 mp4。</string>
|
||||
<string name="digital_human_test_status_idle">为 %1$s 选好一个文件夹后,我会按顺序处理里面的音频,并批量导出完整 mp4。</string>
|
||||
<string name="digital_human_test_status_scanning_folder">正在扫描所选文件夹里的音频…</string>
|
||||
<string name="digital_human_test_status_preparing_resources">正在为 %1$s 准备测试资源…</string>
|
||||
<string name="digital_human_test_status_loading_avatar">正在让 %1$s 准备好批量处理…</string>
|
||||
<string name="digital_human_test_status_decoding_audio">正在解析您选好的音频…</string>
|
||||
<string name="digital_human_test_status_connecting">正在连接 Omni…</string>
|
||||
<string name="digital_human_test_status_uploading_audio">正在把音频送给 Omni…</string>
|
||||
<string name="digital_human_test_status_generating">Omni 正在回应,数字人也在跟着这段声音做同步反应。</string>
|
||||
<string name="digital_human_test_status_muxing">回应已经完成,正在整理并封装最终 mp4…</string>
|
||||
<string name="digital_human_test_status_done">当前这条完整 mp4 已经保存好了。</string>
|
||||
<string name="digital_human_test_status_batch_ready">已准备好 %1$d 条待处理任务,点击开始后会按顺序处理。</string>
|
||||
<string name="digital_human_test_status_batch_current">正在处理第 %1$d/%2$d 条:%3$s</string>
|
||||
<string name="digital_human_test_status_batch_done">批处理完成,成功 %1$d 条,跳过 %2$d 条,失败 %3$d 条。</string>
|
||||
<string name="digital_human_test_status_copying">正在按原目录结构保存结果…</string>
|
||||
<string name="digital_human_test_status_retrying">当前任务失败,正在重试 %3$s(%1$d/%2$d)…</string>
|
||||
<string name="digital_human_test_preview_idle">%1$s 会在这里依次回应当前队列里的音频。</string>
|
||||
<string name="digital_human_test_preview_loading">%1$s 正在准备当前这条任务…</string>
|
||||
<string name="digital_human_test_folder_title">选择输入文件夹</string>
|
||||
<string name="digital_human_test_folder_none">还没有选文件夹</string>
|
||||
<string name="digital_human_test_folder_hint">直接选择一个文件夹,应用会递归扫描其中的常见音频文件,并按相同目录层级写回 mp4。</string>
|
||||
<string name="digital_human_test_folder_summary">已扫描 %1$d 段音频,结果会保存到 %2$s/%3$s 下,并保留原目录结构。</string>
|
||||
<string name="digital_human_test_select_folder">选择文件夹</string>
|
||||
<string name="digital_human_test_generate">开始批量生成</string>
|
||||
<string name="digital_human_test_running">批处理中…</string>
|
||||
<string name="digital_human_test_need_folder">请先选择一个文件夹</string>
|
||||
<string name="digital_human_test_all_outputs_exist">目标 mp4 都已存在,当前没有需要处理的任务。</string>
|
||||
<string name="digital_human_test_input_transcript_title">当前输入识别</string>
|
||||
<string name="digital_human_test_input_transcript_empty">暂时还没有识别内容</string>
|
||||
<string name="digital_human_test_progress_title">处理进度</string>
|
||||
<string name="digital_human_test_progress_summary">总计 %1$d 条,已完成 %2$d 条,已跳过 %3$d 条,失败 %4$d 条。</string>
|
||||
<string name="digital_human_test_progress_empty">点击开始后,这里会按顺序显示每条音频的处理进度。</string>
|
||||
<string name="digital_human_test_job_pending">等待处理</string>
|
||||
<string name="digital_human_test_job_decoding">解析音频</string>
|
||||
<string name="digital_human_test_job_connecting">连接 Omni</string>
|
||||
<string name="digital_human_test_job_uploading">发送音频</string>
|
||||
<string name="digital_human_test_job_generating">生成响应</string>
|
||||
<string name="digital_human_test_job_muxing">封装 mp4</string>
|
||||
<string name="digital_human_test_job_copying">保存结果</string>
|
||||
<string name="digital_human_test_job_retrying">重试中(%1$d/%2$d)</string>
|
||||
<string name="digital_human_test_job_done">已完成</string>
|
||||
<string name="digital_human_test_job_failed">失败</string>
|
||||
<string name="digital_human_test_job_skipped">已跳过</string>
|
||||
<string name="digital_human_test_job_output">输出:%1$s</string>
|
||||
<string name="digital_human_test_no_audio_found">所选文件夹里没有找到可处理的音频文件</string>
|
||||
<string name="digital_human_test_no_audio_response">这次 Omni 没有返回可用的响应音频</string>
|
||||
<string name="digital_human_test_connection_lost">测试连接中途断开了</string>
|
||||
<string name="main_download_tips">Github地址可能下载失败,您可以考虑使用代理或者将文件缓存到自己的存储服务</string>
|
||||
<string name="debug_message">调试信息</string>
|
||||
|
||||
|
||||
@@ -25,4 +25,9 @@
|
||||
<color name="warm_button_secondary_bg_pressed">#FFF0E0D2</color>
|
||||
<color name="warm_button_secondary_bg_disabled">#FFF8F1EB</color>
|
||||
<color name="warm_button_secondary_stroke">#FFD7B39B</color>
|
||||
<color name="warm_status_pending">#FF9C7C6E</color>
|
||||
<color name="warm_status_running">#FFC16845</color>
|
||||
<color name="warm_status_success">#FF5C8C5A</color>
|
||||
<color name="warm_status_error">#FFB85C4A</color>
|
||||
<color name="warm_status_skipped">#FF7B8A94</color>
|
||||
</resources>
|
||||
@@ -84,10 +84,69 @@
|
||||
<string name="settings_device_code_desc">Open the QR code page for your family to scan and bind this elder device.</string>
|
||||
<string name="settings_debug_title">Show debug details</string>
|
||||
<string name="settings_debug_desc">When enabled, the chat page will show full interaction logs, including tool input and output.</string>
|
||||
<string name="settings_digital_human_test_title">Digital Human Test</string>
|
||||
<string name="settings_digital_human_test_desc">Choose a local folder, batch-send the audio files inside to Omni, and export avatar MP4s while keeping the original directory structure.</string>
|
||||
<string name="settings_about_title">About this device</string>
|
||||
<string name="settings_version_label">App version</string>
|
||||
<string name="settings_server_label">Family panel domain</string>
|
||||
<string name="settings_device_label">Current device UUID</string>
|
||||
<string name="digital_human_test_title">Digital Human Test</string>
|
||||
<string name="digital_human_test_subtitle">Choose a local folder, recursively process its audio files without search or tools, and export avatar MP4s while preserving the original directory structure.</string>
|
||||
<string name="digital_human_test_status_idle">Choose a local folder for %1$s, then I will process the audio files one by one and export complete MP4 responses in batch.</string>
|
||||
<string name="digital_human_test_status_scanning_folder">Scanning the selected folder for audio files…</string>
|
||||
<string name="digital_human_test_status_preparing_resources">Preparing %1$s for the test…</string>
|
||||
<string name="digital_human_test_status_loading_avatar">Loading %1$s for the batch…</string>
|
||||
<string name="digital_human_test_status_decoding_audio">Decoding the selected audio…</string>
|
||||
<string name="digital_human_test_status_connecting">Connecting to Omni…</string>
|
||||
<string name="digital_human_test_status_uploading_audio">Sending the selected audio to Omni…</string>
|
||||
<string name="digital_human_test_status_generating">Omni is answering. The avatar is following the response audio now.</string>
|
||||
<string name="digital_human_test_status_muxing">The answer is ready. Packing the final MP4…</string>
|
||||
<string name="digital_human_test_status_done">The current MP4 has been saved.</string>
|
||||
<string name="digital_human_test_status_batch_ready">%1$d jobs are ready to run. Tap start to process them one by one.</string>
|
||||
<string name="digital_human_test_status_batch_current">Processing %1$d/%2$d: %3$s</string>
|
||||
<string name="digital_human_test_status_batch_done">Batch completed. %1$d succeeded, %2$d were skipped, and %3$d failed.</string>
|
||||
<string name="digital_human_test_status_copying">Saving the result with the original folder structure…</string>
|
||||
<string name="digital_human_test_status_retrying">Retrying %3$s (%1$d/%2$d)…</string>
|
||||
<string name="digital_human_test_preview_idle">%1$s will respond to the queued audio files here.</string>
|
||||
<string name="digital_human_test_preview_loading">Getting %1$s ready for the current job…</string>
|
||||
<string name="digital_human_test_folder_title">Choose an input folder</string>
|
||||
<string name="digital_human_test_folder_none">No folder selected yet</string>
|
||||
<string name="digital_human_test_folder_hint">Choose a local folder directly. On Android 11 and above, the app will first ask for file management access so it can avoid the system folder picker restrictions.</string>
|
||||
<string name="digital_human_test_folder_summary">Found %1$d audio files. Results will be saved under %2$s/%3$s while keeping the original structure.</string>
|
||||
<string name="digital_human_test_select_folder">Choose Folder</string>
|
||||
<string name="digital_human_test_generate">Start Batch Export</string>
|
||||
<string name="digital_human_test_running">Processing…</string>
|
||||
<string name="digital_human_test_need_folder">Choose a folder first</string>
|
||||
<string name="digital_human_test_all_outputs_exist">All target MP4 files already exist. There is nothing to process right now.</string>
|
||||
<string name="digital_human_test_need_storage_access">Allow file management access first, then choose the input folder.</string>
|
||||
<string name="digital_human_test_manage_storage_denied">File management access is still disabled, so the app cannot browse local folders yet.</string>
|
||||
<string name="digital_human_test_legacy_storage_denied">Storage permission is required to browse local folders on this Android version.</string>
|
||||
<string name="digital_human_test_input_transcript_title">Current recognized input</string>
|
||||
<string name="digital_human_test_input_transcript_empty">No recognized transcript yet</string>
|
||||
<string name="digital_human_test_progress_title">Progress</string>
|
||||
<string name="digital_human_test_progress_summary">%1$d total, %2$d completed, %3$d skipped, %4$d failed.</string>
|
||||
<string name="digital_human_test_progress_empty">After the batch starts, each audio job will appear here in order.</string>
|
||||
<string name="digital_human_test_job_pending">Queued</string>
|
||||
<string name="digital_human_test_job_decoding">Decoding audio</string>
|
||||
<string name="digital_human_test_job_connecting">Connecting to Omni</string>
|
||||
<string name="digital_human_test_job_uploading">Uploading audio</string>
|
||||
<string name="digital_human_test_job_generating">Generating response</string>
|
||||
<string name="digital_human_test_job_muxing">Packing MP4</string>
|
||||
<string name="digital_human_test_job_copying">Saving result</string>
|
||||
<string name="digital_human_test_job_retrying">Retrying (%1$d/%2$d)</string>
|
||||
<string name="digital_human_test_job_done">Completed</string>
|
||||
<string name="digital_human_test_job_failed">Failed</string>
|
||||
<string name="digital_human_test_job_skipped">Skipped</string>
|
||||
<string name="digital_human_test_job_output">Output: %1$s</string>
|
||||
<string name="digital_human_test_no_audio_found">No supported audio files were found in the selected folder</string>
|
||||
<string name="digital_human_test_no_audio_response">Omni did not return response audio this time</string>
|
||||
<string name="digital_human_test_connection_lost">The test connection was interrupted</string>
|
||||
<string name="folder_picker_title">Choose Folder</string>
|
||||
<string name="folder_picker_subtitle">Browse the device storage directly, enter the target folder, and then use the current folder as the batch input root.</string>
|
||||
<string name="folder_picker_select_current">Use Current Folder</string>
|
||||
<string name="folder_picker_up">Up One Level</string>
|
||||
<string name="folder_picker_list_title">Subfolders</string>
|
||||
<string name="folder_picker_empty">There are no readable subfolders here. You can still use the current folder.</string>
|
||||
<string name="main_download_tips">The GitHub url may fail to download, you can consider using a proxy or caching the file to your own storage service</string>
|
||||
<string name="debug_message">Debug message</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user