blob: e62d3b1b52f496c0876f20abdac5222c934c4fd3 [file] [log] [blame]
/*
* 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();
}
}
}
}