ExtractAudioFromVideo.ets 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609
  1. import { common, ConfigurationConstant } from '@kit.AbilityKit';
  2. import { CommonConstants } from '../common/constants/CommonConstants';
  3. import { VideoItem } from '../viewmodel/VideoItem';
  4. import { AppUtil, FileUtil, StrUtil } from '@pura/harmony-utils';
  5. import { taskpool, util } from '@kit.ArkTS';
  6. import { Utility } from '../common/util/Utility';
  7. import MediaTable from '../common/util/MediaTable';
  8. import { FFmpeg, FFProgressMessageParser } from '@sj/ffmpeg';
  9. import { BusinessError } from '@kit.BasicServicesKit';
  10. import { PlayStatus } from '../common/PlayStatus';
  11. import { generateOutputPath, getFileNameWithoutExtension, setRingTone } from './EditAudio';
  12. import { SegmentButtonItemTuple, SegmentButtonOptions, SwipeRefresher } from '@kit.ArkUI';
  13. import { SegmentButton } from '@kit.ArkUI';
  14. import PermissionUtil from '../common/util/PermissionUtil'
  15. import { ButtonFancyModifier, ShadowModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil';
  16. // 视频音频提取功能
  17. @Component
  18. export struct ExtractAudioFromVideo {
  19. onResult = (_result: boolean, outPath: string) => {
  20. }
  21. onBack = () => {
  22. }
  23. onPlayOrPause = () => {
  24. }
  25. onSeeOutputPath = (outPutPath:string) => {
  26. }
  27. onSliderChange = (value:number) => {
  28. }
  29. @Prop currentPath: string;
  30. @State isEditing:boolean = false
  31. @Link progressValue: number;
  32. @Link currentTime: string ;
  33. @Link videoUrl: string ;//用于判断当期播放的歌曲是不是和剪辑的歌一样,如果不是 播放控制不跟随更新
  34. @State PROGRESS_MAX_VALUE: number = 100;
  35. @Link CONTROL_PlayStatus: number;
  36. @State bundleName: string = '';
  37. @State durationStr:string = ''
  38. @State outputPath: string = '';
  39. @State inputPath: string = '';
  40. @Prop mVideoItem: VideoItem;
  41. @StorageProp('topRectHeight') topRectHeight: number = 0;
  42. @State isDarkMode: boolean = false;
  43. @StorageProp('currentColorMode') @Watch('onColorModeChange') currentMode: number =
  44. ConfigurationConstant.ColorMode.COLOR_MODE_LIGHT;
  45. @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
  46. context = this.getUIContext().getHostContext() as common.UIAbilityContext;
  47. // 音频提取质量选项
  48. @State extractionQuality: number = 2; // 0-低, 1-中, 2-高
  49. @State audioFormat: number = 0; // 0-MP3, 1-WAV, 2-AAC
  50. @State isProcessing: boolean = false;
  51. // SegmentButton选项
  52. @State audioFormatOptions: SegmentButtonOptions = SegmentButtonOptions.capsule({
  53. buttons: [{ text: 'MP3' }, { text: 'WAV' }, { text: 'AAC' }] as SegmentButtonItemTuple,
  54. backgroundColor: $r('app.color.index_background'),
  55. selectedBackgroundColor:$r('app.color.start_window_background'),
  56. selectedFontColor: $r('app.color.text_color'),
  57. buttonPadding:{top:10,bottom:10},
  58. multiply: false
  59. });
  60. @State qualityOptions: SegmentButtonOptions = SegmentButtonOptions.capsule({
  61. buttons: [{ text: '低质量' }, { text: '中等质量' }, { text: '高质量' }] as SegmentButtonItemTuple,
  62. backgroundColor: $r('app.color.index_background'),
  63. selectedBackgroundColor:$r('app.color.start_window_background'),
  64. selectedFontColor: $r('app.color.text_color'),
  65. buttonPadding:{top:10,bottom:10},
  66. multiply: false
  67. });
  68. @State @Watch('onAudioFormatChange') selectedAudioFormatIndex: number[] = [this.audioFormat];
  69. @State @Watch('onQualityChange') selectedQualityIndex: number[] = [this.extractionQuality];
  70. private table: MediaTable = new MediaTable(this.context);
  71. onColorModeChange() {
  72. this.isDarkMode = this.currentMode === ConfigurationConstant.ColorMode.COLOR_MODE_DARK;
  73. }
  74. // 音频格式变化监听
  75. onAudioFormatChange() {
  76. if (this.selectedAudioFormatIndex.length > 0) {
  77. this.audioFormat = this.selectedAudioFormatIndex[0];
  78. this.outputPath = generateOutputPath(this.inputPath,'提取音频',this.currentPath,
  79. this.getAudioFormatExtension(this.audioFormat))
  80. }
  81. }
  82. // 音质变化监听
  83. onQualityChange() {
  84. if (this.selectedQualityIndex.length > 0) {
  85. this.extractionQuality = this.selectedQualityIndex[0];
  86. }
  87. }
  88. // 添加generateNewPath函数
  89. generateNewPath(inputPath: string, fileName: string): string {
  90. const dir = inputPath.substring(0, inputPath.lastIndexOf('/') + 1);
  91. const dotIndex = fileName.lastIndexOf('.');
  92. const name = dotIndex === -1 ? fileName : fileName.substring(0, dotIndex);
  93. const ext = this.getAudioFormatExtension(this.audioFormat);
  94. return `${dir}${name}${ext}`;
  95. }
  96. // 获取音频格式扩展名
  97. getAudioFormatExtension(format: number): string {
  98. switch (format) {
  99. case 0: return '.mp3';
  100. case 1: return '.wav';
  101. case 2: return '.aac';
  102. default: return '.mp3';
  103. }
  104. }
  105. async aboutToAppear() {
  106. this.bundleName = AppUtil.getBundleName();
  107. const rawDuration = this.mVideoItem.duration || '00:00:00';
  108. this.durationStr = rawDuration;
  109. await PermissionUtil.activatePermission(this.mVideoItem.filePath)
  110. this.outputPath = generateOutputPath(this.mVideoItem.filePath,'提取音频',this.currentPath,
  111. this.getAudioFormatExtension(this.audioFormat))
  112. console.info('onecold this.outputPath ='+this.outputPath)
  113. await new Promise<void>((resolve, reject) => {
  114. this.table.getRdbStore(this.context, (err:Error) => {
  115. err ? reject(err) : resolve();
  116. });
  117. });
  118. }
  119. // 提取视频音频
  120. async extractVideoAudio() {
  121. if (this.isProcessing) {
  122. return;
  123. }
  124. if(StrUtil.isEmpty(this.inputPath)){
  125. this.inputPath =this.mVideoItem.filePath
  126. }
  127. if(FileUtil.accessSync(this.outputPath)){
  128. this.outputPath = generateOutputPath(this.inputPath,'提取音频',this.currentPath,
  129. this.getAudioFormatExtension(this.audioFormat))
  130. }
  131. this.isProcessing = true;
  132. try {
  133. const task = new taskpool.Task(
  134. extractAudioFromVideo,
  135. this.context,
  136. this.inputPath,
  137. this.outputPath,
  138. this.extractionQuality,
  139. this.audioFormat
  140. );
  141. const result = await taskpool.execute(task, taskpool.Priority.HIGH);
  142. if (result) {
  143. await this.saveDb(this.context, this.outputPath)
  144. this.onResult(true, this.outputPath);
  145. this.showSuccess();
  146. } else {
  147. this.onResult(false, this.outputPath);
  148. }
  149. } catch (error) {
  150. console.error('Extract video audio failed:', error);
  151. this.onResult(false, this.outputPath);
  152. } finally {
  153. this.isProcessing = false;
  154. }
  155. }
  156. async saveDb(context: Context, outputPath: string): Promise<boolean> {
  157. try {
  158. // 媒体入库
  159. const table: MediaTable = new MediaTable(context);
  160. // 等待数据库连接就绪(修复回调转Promise逻辑)
  161. await new Promise<void>((resolve, reject) => {
  162. table.getRdbStore(context, (err: Error | null) => { // 补充err可能为null的类型定义
  163. if (err) {
  164. reject(new Error(`获取数据库连接失败: ${err.message}`));
  165. } else {
  166. resolve();
  167. }
  168. });
  169. });
  170. // 获取媒体元数据
  171. const mediaItem = await Utility.uriGetMusicAssetsFromFile(
  172. context,
  173. outputPath,
  174. CommonConstants.TYPE_LOCAL,
  175. true
  176. );
  177. if (!mediaItem) {
  178. console.error(` 获取媒体元数据失败: ${outputPath}`);
  179. return false;
  180. }
  181. // 插入数据库(回调转Promise,确保异步完成)
  182. await new Promise<void>((resolve, reject) => {
  183. table.insert(
  184. mediaItem,
  185. (err: Error | null) => { // 补充insert回调的错误参数
  186. if (err) {
  187. reject(new Error(`数据库插入失败: ${err.message}`));
  188. } else {
  189. resolve();
  190. }
  191. },
  192. '' // 空字符串参数(根据table.insert 定义传入,若无用可考虑移除)
  193. );
  194. });
  195. console.log(` 媒体入库成功: ${outputPath}`);
  196. return true;
  197. } catch (error) {
  198. console.error(` 媒体入库失败: ${error instanceof Error ? error.message : error}`);
  199. return false;
  200. }
  201. }
  202. showSuccess() {
  203. try {
  204. this.getUIContext().getPromptAction().showDialog({
  205. title: '提取成功',
  206. message: '视频音频提取完成,保存路径为:\n\n' + this.outputPath + '\n\n',
  207. buttons: [
  208. {
  209. text: '查看路径',
  210. color: '#000000'
  211. },
  212. {
  213. text: '设为铃声',
  214. color: '#000000'
  215. },
  216. ]
  217. }, (err, data) => {
  218. if (err) {
  219. console.error('showDialog err: ' + err);
  220. return;
  221. }
  222. // 根据点击的按钮索引处理不同逻辑
  223. if (data.index === 1) { // "设为铃声"按钮的索引为1
  224. setRingTone(this.context, this.outputPath,FileUtil.getFileName(this.outputPath))
  225. }else if (data.index === 0){//查看路径
  226. this.onSeeOutputPath(this.outputPath)
  227. }
  228. console.info('showDialog success callback, click button: ' + data.index);
  229. });
  230. } catch (error) {
  231. let message = (error as BusinessError).message;
  232. let code = (error as BusinessError).code;
  233. console.error(`showDialog args error code is ${code}, message is ${message}`);
  234. }
  235. }
  236. build() {
  237. Column() {
  238. this.topTitleBar()
  239. this.buildContent()
  240. }
  241. .height('100%')
  242. .width('100%')
  243. .backgroundColor($r('app.color.index_background'))
  244. }
  245. @Builder
  246. buildContent() {
  247. Column({ space: 20 }) {
  248. // 文件信息显示
  249. Row({ space: 10 }) {
  250. Image(this.mVideoItem.pixelMapPath)
  251. .height(55)
  252. .width(55)
  253. .alt($r('app.media.llq'))
  254. .clickEffect({level:ClickEffectLevel.LIGHT, scale: 0.6})
  255. .borderRadius(12)
  256. .clip(true)
  257. Column(){
  258. Text(` ${this.mVideoItem.name || this.mVideoItem.fileName}`)
  259. .fontSize(16)
  260. .fontColor($r('app.color.text_color'))
  261. .textAlign(TextAlign.Start)
  262. Text(` ${this.mVideoItem.artist || this.mVideoItem.size}`)
  263. .fontSize(16)
  264. .fontColor($r('app.color.text_color'))
  265. .textAlign(TextAlign.Start)
  266. }
  267. .layoutWeight(1)
  268. .alignItems(HorizontalAlign.Start)
  269. .width('100%')
  270. }
  271. .width('90%')
  272. .margin({ top: 5})
  273. .justifyContent(FlexAlign.Start)
  274. // 输出文件名设置
  275. Column({ space: 10 }) {
  276. Text('输出文件名:')
  277. .fontSize(16)
  278. .width('90%')
  279. .textAlign(TextAlign.Start)
  280. .fontColor($r('app.color.text_color'))
  281. TextArea({ text: getFileNameWithoutExtension(this.outputPath) })
  282. .height('auto')
  283. .fontSize(14)
  284. .maxLines(3)
  285. .fontColor($r('app.color.text_color'))
  286. .width('90%')
  287. .onEditChange((isEditing: boolean) => {
  288. console.info(`onecold isEditing ${isEditing}`);
  289. this.isEditing = isEditing;
  290. })
  291. .onChange((val: string) => {
  292. if(this.isEditing){
  293. console.info('onecold onChange val=' + val)
  294. this.outputPath = generateOutputPath(this.mVideoItem.filePath,'',this.currentPath,
  295. this.getAudioFormatExtension(this.audioFormat), val )
  296. }
  297. })
  298. }
  299. .width('100%')
  300. .margin({ top: 5, bottom: 5 })
  301. .justifyContent(FlexAlign.Start)
  302. // 音频格式选择
  303. Column({ space: 10 }) {
  304. Text('音频格式:')
  305. .fontSize(16)
  306. .width('90%')
  307. .textAlign(TextAlign.Start)
  308. SegmentButton({
  309. options: this.audioFormatOptions,
  310. selectedIndexes: $selectedAudioFormatIndex
  311. })
  312. .width('90%')
  313. // 音质选择
  314. Column({ space: 10 }) {
  315. Text('音质质量:')
  316. .fontSize(16)
  317. .width('90%')
  318. .textAlign(TextAlign.Start)
  319. SegmentButton({
  320. options: this.qualityOptions,
  321. selectedIndexes: $selectedQualityIndex
  322. })
  323. .width('90%')
  324. this.playButton()
  325. // 处理按钮
  326. Button({ type: ButtonType.Capsule, stateEffect: true }){
  327. Row({ space: 8 }) {
  328. if (this.isProcessing) {
  329. LoadingProgress()
  330. .width(26)
  331. .color(Color.Blue)
  332. }
  333. Text(this.isProcessing ? '处理中...' : '提取音频')
  334. .fontSize(14)
  335. .fontColor(Color.White)
  336. }
  337. }
  338. .width(200)
  339. .height(45)
  340. .backgroundColor(this.isProcessing ? Color.Gray : this.themeColor)
  341. .borderRadius(20)
  342. .margin({ top: 20 })
  343. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  344. .onClick(() => {
  345. if (!this.isProcessing) {
  346. this.extractVideoAudio();
  347. }
  348. })
  349. .enabled(!this.isProcessing)
  350. }
  351. .width('100%')
  352. .padding({ top: 20, bottom: 20 })
  353. .alignItems(HorizontalAlign.Center)
  354. }
  355. }
  356. .width('100%')
  357. }
  358. @Builder
  359. playButton() {
  360. Row(){
  361. Button({type:ButtonType.Circle,stateEffect:true}){
  362. Column() {
  363. Image(this.CONTROL_PlayStatus === PlayStatus.PLAY&&this.videoUrl==this.mVideoItem.filePath ?
  364. $r('app.media.hm_pause')
  365. : $r('app.media.hm_play2'))
  366. .width(38)
  367. .clickEffect({level:ClickEffectLevel.LIGHT, scale: 0.5})
  368. .fillColor(Color.White)
  369. .aspectRatio(CommonConstants.ASPECT_RATIO)
  370. .onClick(async () => {
  371. this.onPlayOrPause()
  372. })
  373. }
  374. }
  375. .backgroundColor(this.themeColor)
  376. Text(this.videoUrl==this.mVideoItem.filePath?this.currentTime:'00:00')
  377. .fontSize($r('app.float.slider_font_size'))
  378. .fontColor($r('app.color.text_color'))
  379. .margin(3)
  380. Slider({
  381. value: this.videoUrl==this.mVideoItem.filePath?this.progressValue:0,
  382. min: 0,
  383. max: this.PROGRESS_MAX_VALUE,
  384. step: 1,
  385. style: SliderStyle.OutSet
  386. })
  387. .width('600px')
  388. .trackThickness(3)
  389. .layoutWeight(1)
  390. .margin({ left:1 })
  391. .showSteps(false)
  392. .showTips(true)
  393. .onChange((value: number, mode: SliderChangeMode) => {
  394. this.onSliderChange(value)
  395. })
  396. Text(this.durationStr)
  397. .fontSize($r('app.float.slider_font_size'))
  398. .fontColor($r('app.color.text_color'))
  399. .margin(3)
  400. .margin({ left: 2 })
  401. }
  402. .width('90%')
  403. .margin({top:10})
  404. }
  405. @Builder
  406. topTitleBar(){
  407. Column() {
  408. Row({ space: 15 }) {
  409. //左侧滑动按钮
  410. Button({ type: ButtonType.Circle, stateEffect: true }) {
  411. SymbolGlyph($r('sys.symbol.chevron_left'))
  412. .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
  413. }
  414. .attributeModifier(new ButtonFancyModifier(40, 40))
  415. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  416. .animation({ duration: 300, curve: Curve.Ease })
  417. .onClick(() => {
  418. this.onBack()
  419. })
  420. .attributeModifier(new ShadowModifier())
  421. .zIndex(0)
  422. Text('提取视频音频')
  423. .margin({left:3,right:10})
  424. .fontColor($r('app.color.text_color'))
  425. .fontSize(19)
  426. .maxLines(1)
  427. .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
  428. .layoutWeight(1)
  429. .clickEffect({ level: ClickEffectLevel.MIDDLE,scale:0.6 })
  430. }
  431. }
  432. .padding({ top: this.topRectHeight, left: 10, right: 10,bottom:12 })
  433. .width('100%')
  434. }
  435. }
  436. // 视频音频提取的核心方法
  437. @Concurrent
  438. async function extractAudioFromVideo(
  439. context: Context,
  440. inputPath: string,
  441. outPath?: string,
  442. quality: number = 2,
  443. audioFormat: number = 0
  444. ): Promise<boolean> {
  445. try {
  446. inputPath = FileUtil.getFilePath(inputPath);
  447. // 生成输出路径
  448. let outputPath = outPath ?? (() => {
  449. const dir = inputPath.substring(0, inputPath.lastIndexOf('/') + 1);
  450. const fullName = inputPath.substring(inputPath.lastIndexOf('/') + 1);
  451. const dotIndex = fullName.lastIndexOf('.');
  452. const name = dotIndex === -1 ? fullName : fullName.substring(0, dotIndex);
  453. const ext = dotIndex === -1 ? '' : fullName.substring(dotIndex);
  454. const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
  455. return `${dir}${name}_${timestamp}_audio${ext}`;
  456. })();
  457. console.info(`onecold 开始提取视频音频,输入: ${inputPath}, 输出: ${outputPath}`);
  458. // 根据音频格式和质量设置参数
  459. let qualityParams: string[] = [];
  460. let outputExtension: string = '';
  461. let codec: string = '';
  462. // 设置音频格式相关参数
  463. switch (audioFormat) {
  464. case 0: // MP3
  465. codec = 'libmp3lame';
  466. outputExtension = '.mp3';
  467. break;
  468. case 1: // WAV
  469. codec = 'pcm_s16le';
  470. outputExtension = '.wav';
  471. break;
  472. case 2: // AAC
  473. codec = 'aac';
  474. outputExtension = '.aac';
  475. break;
  476. default:
  477. codec = 'libmp3lame';
  478. outputExtension = '.mp3';
  479. break;
  480. }
  481. // 确保输出路径有正确的扩展名
  482. if (!outputPath.toLowerCase().endsWith(outputExtension)) {
  483. const basePath = outputPath.lastIndexOf('.') > outputPath.lastIndexOf('/')
  484. ? outputPath.substring(0, outputPath.lastIndexOf('.'))
  485. : outputPath;
  486. outputPath = `${basePath}${outputExtension}`;
  487. }
  488. // 根据质量设置参数
  489. switch (quality) {
  490. case 0: // 低质量
  491. qualityParams = ['-ar', '22050', '-b:a', '64k'];
  492. break;
  493. case 1: // 中等质量
  494. qualityParams = ['-ar', '44100', '-b:a', '128k'];
  495. break;
  496. case 2: // 高质量
  497. default:
  498. qualityParams = ['-ar', '48000', '-b:a', '192k'];
  499. break;
  500. }
  501. try {
  502. console.info(`onecold 提取视频音频,格式: ${audioFormat === 0 ? 'MP3' : audioFormat === 1 ? 'WAV' : 'AAC'}`);
  503. // 构建FFmpeg命令 - 提取音频流,不重新编码视频
  504. const commands = [
  505. 'ffmpeg',
  506. '-i', inputPath,
  507. '-vn', // 不处理视频流
  508. '-acodec', codec, // 音频编码器
  509. ...qualityParams,
  510. '-y', outputPath
  511. ];
  512. // 执行 FFmpeg 命令
  513. await FFmpeg.execute(commands, {
  514. logCallback: (logLevel, logMessage) =>
  515. console.log(`[${logLevel}] 视频音频提取: ${logMessage}`),
  516. progressCallback: (message) =>
  517. console.log(`[progress] 视频音频提取: ${JSON.stringify(FFProgressMessageParser.parse(message))}`),
  518. });
  519. console.info(`onecold 视频音频提取成功: ${outputPath}`);
  520. return true;
  521. } catch (error) {
  522. console.error(`onecold 视频音频提取失败: ${error}`);
  523. return false;
  524. }
  525. } catch (error) {
  526. console.error(`onecold 主流程错误: ${error}`);
  527. return false;
  528. }
  529. }
  530. // 音频格式描述
  531. function getAudioFormatDescription(format: number): string {
  532. switch (format) {
  533. case 0:
  534. return 'MP3(通用格式,文件较小)';
  535. case 1:
  536. return 'WAV(无损格式,文件较大)';
  537. case 2:
  538. default:
  539. return 'AAC(高质量,文件较小)';
  540. }
  541. }
  542. // 音频质量描述
  543. function getQualityDescription(quality: number): string {
  544. switch (quality) {
  545. case 0:
  546. return '低质量(文件小,适合语音)';
  547. case 1:
  548. return '中等质量(平衡大小与音质)';
  549. case 2:
  550. default:
  551. return '高质量(音质好,文件较大)';
  552. }
  553. }