| /* |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you under the Apache License, Version 2.0 (the |
| * "License") + you may not use this file except in compliance |
| * with the License. You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, |
| * software distributed under the License is distributed on an |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| * KIND, either express or implied. See the License for the |
| * specific language governing permissions and limitations |
| * under the License. |
| */ |
| package org.apache.openmeetings.core.converter; |
| |
| import static org.apache.commons.io.FileUtils.copyFile; |
| import static org.apache.commons.lang3.math.NumberUtils.toInt; |
| import static org.apache.openmeetings.util.CalendarHelper.formatMillis; |
| import static org.apache.openmeetings.util.OmFileHelper.EXTENSION_PNG; |
| import static org.apache.openmeetings.util.OmFileHelper.getPublicDir; |
| import static org.apache.openmeetings.util.OmFileHelper.getRecordingChunk; |
| import static org.apache.openmeetings.util.OmFileHelper.getStreamsSubDir; |
| import static org.apache.openmeetings.util.OpenmeetingsVariables.CONFIG_PATH_FFMPEG; |
| import static org.apache.openmeetings.util.OpenmeetingsVariables.CONFIG_PATH_IMAGEMAGIC; |
| import static org.apache.openmeetings.util.OpenmeetingsVariables.CONFIG_PATH_SOX; |
| import static org.apache.openmeetings.util.OpenmeetingsVariables.getAudioBitrate; |
| import static org.apache.openmeetings.util.OpenmeetingsVariables.getAudioRate; |
| import static org.apache.openmeetings.util.OpenmeetingsVariables.getVideoPreset; |
| |
| import java.io.File; |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.Date; |
| import java.util.List; |
| import java.util.Locale; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| |
| import org.apache.openmeetings.db.dao.basic.ConfigurationDao; |
| import org.apache.openmeetings.db.dao.file.FileItemLogDao; |
| import org.apache.openmeetings.db.dao.record.RecordingChunkDao; |
| import org.apache.openmeetings.db.dao.record.RecordingDao; |
| import org.apache.openmeetings.db.entity.file.BaseFileItem; |
| import org.apache.openmeetings.db.entity.record.Recording; |
| import org.apache.openmeetings.db.entity.record.RecordingChunk; |
| import org.apache.openmeetings.db.entity.record.RecordingChunk.Status; |
| import org.apache.openmeetings.util.process.ProcessHelper; |
| import org.apache.openmeetings.util.process.ProcessResult; |
| import org.apache.openmeetings.util.process.ProcessResultList; |
| import org.apache.wicket.util.string.Strings; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| import org.springframework.beans.factory.annotation.Autowired; |
| |
| public abstract class BaseConverter { |
| private static final Logger log = LoggerFactory.getLogger(BaseConverter.class); |
| private static final Pattern p = Pattern.compile("\\d{2,5}(x)\\d{2,5}"); |
| public static final String EXEC_EXT = System.getProperty("os.name").toUpperCase(Locale.ROOT).indexOf("WINDOWS") < 0 ? "" : ".exe"; |
| private static final int MINUTE_MULTIPLIER = 60 * 1000; |
| public static final int TIME_TO_WAIT_FOR_FRAME = 5 * MINUTE_MULTIPLIER; |
| public static final double HALF_STEP = 1. / 2; |
| |
| @Autowired |
| protected ConfigurationDao cfgDao; |
| @Autowired |
| protected RecordingChunkDao chunkDao; |
| @Autowired |
| protected FileItemLogDao logDao; |
| @Autowired |
| protected RecordingDao recordingDao; |
| |
| protected record Dimension(int width, int height) {} |
| |
| private String getPath(String key, String app) { |
| final String cfg = cfgDao.getString(key, ""); |
| StringBuilder path = new StringBuilder(cfg); |
| if (!Strings.isEmpty(path) && !cfg.endsWith(File.separator)) { |
| path.append(File.separator); |
| } |
| path.append(app).append(EXEC_EXT); |
| return path.toString(); |
| } |
| |
| public String getPathToFFMPEG() { |
| return getPath(CONFIG_PATH_FFMPEG, "ffmpeg"); |
| } |
| |
| protected String getPathToSoX() { |
| return getPath(CONFIG_PATH_SOX, "sox"); |
| } |
| |
| protected String getPathToConvert() { |
| return getPath(CONFIG_PATH_IMAGEMAGIC, "convert"); |
| } |
| |
| protected File getStreamFolder(Recording recording) { |
| return getStreamsSubDir(recording.getRoomId()); |
| } |
| |
| protected long diff(Date from, Date to) { |
| return from == null || to == null ? 0 : from.getTime() - to.getTime(); |
| } |
| |
| protected double diffSeconds(Date from, Date to) { |
| return diffSeconds(diff(from, to)); |
| } |
| |
| protected double diffSeconds(long val) { |
| return ((double)val) / 1000; |
| } |
| |
| protected void updateDuration(Recording r) { |
| r.setDuration(formatMillis(diff(r.getRecordEnd(), r.getRecordStart()))); |
| } |
| |
| protected void deleteFileIfExists(File f) { |
| if (f.exists()) { |
| f.delete(); |
| } |
| } |
| |
| private List<String> mergeAudioToWaves(List<File> waveFiles, File wav) throws IOException { |
| List<String> argv = new ArrayList<>(); |
| |
| argv.add(getPathToSoX()); |
| argv.add("-m"); |
| for (File arg : waveFiles) { |
| argv.add(arg.getCanonicalPath()); |
| } |
| argv.add(wav.getCanonicalPath()); |
| |
| return argv; |
| } |
| |
| protected void createWav(Recording r, ProcessResultList logs, File streamFolder, List<File> waveFiles, File wav, List<RecordingChunk> chunks) throws IOException { |
| deleteFileIfExists(wav); |
| stripAudioFirstPass(r, logs, waveFiles, streamFolder, chunks == null ? chunkDao.getNotScreenChunksByRecording(r.getId()) : chunks); |
| if (waveFiles.isEmpty()) { |
| // create default Audio to merge it. strip to content length |
| String oneSecWav = new File(getPublicDir(), "one_second.wav").getCanonicalPath(); |
| |
| // Calculate delta at beginning |
| double duration = diffSeconds(r.getRecordEnd(), r.getRecordStart()); |
| |
| List<String> cmd = List.of(getPathToSoX(), oneSecWav, wav.getCanonicalPath(), "pad", "0", String.valueOf(duration)); |
| |
| logs.add(ProcessHelper.exec("generateSampleAudio", cmd)); |
| } else if (waveFiles.size() == 1) { |
| copyFile(waveFiles.get(0), wav); |
| } else { |
| logs.add(ProcessHelper.exec("mergeAudioToWaves", mergeAudioToWaves(waveFiles, wav))); |
| } |
| } |
| |
| private List<String> addSoxPad(ProcessResultList logs, String job, double length, double position, File inFile, File outFile) throws IOException { |
| if (length < 0 || position < 0) { |
| log.debug("::addSoxPad {} Invalid parameters: length = {}; position = {}; inFile = {}", job, length, position, inFile); |
| } |
| List<String> argv = List.of(getPathToSoX(), inFile.getCanonicalPath(), outFile.getCanonicalPath(), "pad" |
| , String.valueOf(length < 0 ? 0 : length) |
| , String.valueOf(position < 0 ? 0 : position)); |
| |
| logs.add(ProcessHelper.exec(job, argv)); |
| return argv; |
| } |
| |
| public static void printChunkInfo(RecordingChunk chunk, String prefix) { |
| if (log.isDebugEnabled()) { |
| log.debug("### {}:: recording id {}; stream with id {}; current status: {} ", prefix, chunk.getRecording().getId() |
| , chunk.getId(), chunk.getStreamStatus()); |
| File chunkFlv = getRecordingChunk(chunk.getRecording().getRoomId(), chunk.getStreamName()); |
| log.debug("### {}:: Chunk file [{}] exists ? {}; size: {}, lastModified: {} ", prefix, chunkFlv.getPath(), chunkFlv.exists(), chunkFlv.length(), chunkFlv.lastModified()); |
| } |
| } |
| |
| protected RecordingChunk waitForTheStream(long chunkId) { |
| RecordingChunk chunk = null; |
| try { |
| long counter = 0; |
| long maxTimestamp = 0; |
| while (true) { |
| chunk = chunkDao.get(chunkId); |
| |
| if (chunk.getStreamStatus() == Status.STOPPED) { |
| printChunkInfo(chunk, "Stream now written"); |
| log.debug("### Chunk stopped, unblocking thread ... " ); |
| break; |
| } |
| File chunkFlv = getRecordingChunk(chunk.getRecording().getRoomId(), chunk.getStreamName()); |
| if (chunkFlv.exists() && maxTimestamp < chunkFlv.lastModified()) { |
| maxTimestamp = chunkFlv.lastModified(); |
| } |
| if (maxTimestamp + TIME_TO_WAIT_FOR_FRAME < System.currentTimeMillis()) { |
| log.debug("### long time without any update, closing ... "); |
| chunk.setStreamStatus(Status.STOPPED); |
| chunkDao.update(chunk); |
| break; |
| } |
| if (++counter % 1000 == 0) { |
| printChunkInfo(chunk, "Still waiting"); |
| } |
| |
| log.trace("### Stream not yet written Thread Sleep - {}", chunkId); |
| Thread.sleep(100L); |
| } |
| } catch (InterruptedException e) { |
| Thread.currentThread().interrupt(); |
| } |
| return chunk; |
| } |
| |
| private void stripAudioFirstPass(Recording recording, |
| ProcessResultList logs, |
| List<File> waveFiles, File streamFolder, |
| List<RecordingChunk> chunks) { |
| try { |
| // Init variables |
| log.debug("### Chunks count - {}", chunks.size()); |
| log.debug("###################################################"); |
| |
| for (RecordingChunk chunk : chunks) { |
| long chunkId = chunk.getId(); |
| log.debug("### processing chunk: {}", chunkId); |
| if (chunk.getStreamStatus() == Status.NONE) { |
| log.debug("Stream has not been started, error in recording {}", chunkId); |
| continue; |
| } |
| |
| chunk = waitForTheStream(chunkId); |
| |
| File inputFlvFile = getRecordingChunk(chunk.getRecording().getRoomId(), chunk.getStreamName()); |
| |
| File outputWav = new File(streamFolder, chunk.getStreamName() + "_WAVE.wav"); |
| |
| log.debug("FLV File Name: {} Length: {} ", inputFlvFile.getName(), inputFlvFile.length()); |
| |
| if (inputFlvFile.exists()) { |
| List<String> argv = List.of( |
| getPathToFFMPEG(), "-y" |
| , "-i", inputFlvFile.getCanonicalPath() |
| , "-af", String.format("aresample=%s:min_comp=0.001:min_hard_comp=0.100000", getAudioBitrate()) |
| , outputWav.getCanonicalPath()); |
| //there might be no audio in the stream |
| logs.add(ProcessHelper.exec("stripAudioFromFLVs", argv, true)); |
| } |
| |
| if (outputWav.exists() && outputWav.length() != 0) { |
| // Strip Wave to Full Length |
| // Strip Wave to Full Length |
| String hashFileFullName = chunk.getStreamName() + "_FULL_WAVE.wav"; |
| File outputFullWav = new File(streamFolder, hashFileFullName); |
| |
| // Calculate delta at beginning |
| double startPad = diffSeconds(chunk.getStart(), recording.getRecordStart()); |
| |
| // Calculate delta at ending |
| double endPad = diffSeconds(recording.getRecordEnd(), chunk.getEnd()); |
| |
| addSoxPad(logs, "addStartEndToAudio", startPad, endPad, outputWav, outputFullWav); |
| |
| // Fix for Audio Length - Invalid Audio Length in Recorded Files |
| // Audio must match 100% the Video |
| log.debug("############################################"); |
| log.debug("Trim Audio to Full Length -- Start"); |
| |
| if (!outputFullWav.exists()) { |
| throw new ConversionException("Audio File does not exist , could not extract the Audio correctly"); |
| } |
| |
| // Finally add it to the row! |
| waveFiles.add(outputFullWav); |
| } |
| chunkDao.update(chunk); |
| } |
| } catch (Exception err) { |
| log.error("[stripAudioFirstPass]", err); |
| } |
| } |
| |
| protected String getDimensions(Recording r, char delim) { |
| return String.format("%s%s%s", r.getWidth(), delim, r.getHeight()); |
| } |
| |
| protected String getDimensions(Recording r) { |
| return getDimensions(r, 'x'); |
| } |
| |
| /** |
| * This method should be overridden to supply any additional parameters |
| * |
| * @param r - recording to get params from |
| * @return additional conversion parameters |
| */ |
| protected List<String> additionalMp4OutParams(Recording r) { |
| return List.of(); |
| } |
| |
| private List<String> addMp4OutParams(Recording r, List<String> argv, boolean interview, String mp4path) { |
| argv.addAll(List.of( |
| "-c:v", "h264" // |
| , "-crf", "24" |
| , "-vsync", "0" |
| , "-pix_fmt", "yuv420p" |
| , "-preset", getVideoPreset() |
| , "-profile:v", "baseline" |
| , "-level", "3.0" |
| , "-movflags", "faststart" |
| , "-c:a", "aac" |
| , "-ar", String.valueOf(getAudioRate()) |
| , "-b:a", getAudioBitrate() |
| )); |
| if (!interview) { |
| argv.addAll(List.of("-vf", "pad=ceil(iw/2)*2:ceil(ih/2)*2")); |
| } |
| argv.addAll(additionalMp4OutParams(r)); |
| argv.add(mp4path); |
| return argv; |
| } |
| |
| protected String convertToMp4(Recording r, List<String> inArgv, boolean interview, ProcessResultList logs) throws IOException { |
| String mp4path = r.getFile().getCanonicalPath(); |
| List<String> argv = new ArrayList<>(List.of(getPathToFFMPEG(), "-y")); |
| argv.addAll(inArgv); |
| logs.add(ProcessHelper.exec("generate MP4", addMp4OutParams(r, argv, interview, mp4path))); |
| return mp4path; |
| } |
| |
| protected void convertToPng(BaseFileItem f, String mp4path, ProcessResultList logs) throws IOException { |
| // Extract first Image for preview purpose |
| // ffmpeg -i movie.mp4 -vf "thumbnail,scale=640:-1" -frames:v 1 movie.png |
| File png = f.getFile(EXTENSION_PNG); |
| List<String> argv = List.of( |
| getPathToFFMPEG(), "-y" |
| , "-i", mp4path |
| , "-vf", "thumbnail,scale=640:-1" |
| , "-frames:v", "1" |
| , png.getCanonicalPath()); |
| logs.add(ProcessHelper.exec("generate preview PNG :: " + f.getHash(), argv)); |
| } |
| |
| /** |
| * Parse the width height from the FFMPEG output |
| * |
| * @param txt FFMPEG output |
| * @return {@link Dimension} parsed |
| */ |
| protected static Dimension getDimension(String txt, Dimension def) { |
| Matcher matcher = p.matcher(txt); |
| |
| if (matcher.find()) { |
| String foundResolution = txt.substring(matcher.start(), matcher.end()); |
| String[] resolutions = foundResolution.split("x"); |
| return new Dimension(toInt(resolutions[0]), toInt(resolutions[1])); |
| } |
| |
| return def; |
| } |
| |
| protected void finalizeRec(Recording r, String mp4path, ProcessResultList logs) throws IOException { |
| convertToPng(r, mp4path, logs); |
| |
| updateDuration(r); |
| r.setStatus(Recording.Status.PROCESSED); |
| } |
| |
| protected void postProcess(Recording r, ProcessResultList logs) { |
| logDao.delete(r); |
| for (ProcessResult res : logs.getJobs()) { |
| logDao.add("generateFFMPEG", r, res); |
| } |
| } |
| |
| protected void postProcess(List<File> waveFiles) { |
| // Delete Wave Files |
| for (File audio : waveFiles) { |
| if (audio.exists()) { |
| audio.delete(); |
| } |
| } |
| } |
| } |