/**
* Licensed to The Apereo Foundation under one or more contributor license
* agreements. See the NOTICE file distributed with this work for additional
* information regarding copyright ownership.
*
*
* The Apereo Foundation licenses this file to you under the Educational
* Community 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://opensource.org/licenses/ecl2.txt
*
* 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.opencastproject.waveform.ffmpeg;
import org.opencastproject.job.api.AbstractJobProducer;
import org.opencastproject.job.api.Job;
import org.opencastproject.mediapackage.Attachment;
import org.opencastproject.mediapackage.MediaPackageElement.Type;
import org.opencastproject.mediapackage.MediaPackageElementBuilder;
import org.opencastproject.mediapackage.MediaPackageElementBuilderFactory;
import org.opencastproject.mediapackage.MediaPackageElementParser;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.Track;
import org.opencastproject.mediapackage.identifier.IdBuilderFactory;
import org.opencastproject.security.api.OrganizationDirectoryService;
import org.opencastproject.security.api.SecurityService;
import org.opencastproject.security.api.UserDirectoryService;
import org.opencastproject.serviceregistry.api.ServiceRegistry;
import org.opencastproject.serviceregistry.api.ServiceRegistryException;
import org.opencastproject.util.IoSupport;
import org.opencastproject.util.LoadUtil;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.waveform.api.WaveformService;
import org.opencastproject.waveform.api.WaveformServiceException;
import org.opencastproject.workspace.api.Workspace;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.osgi.service.cm.ConfigurationException;
import org.osgi.service.cm.ManagedService;
import org.osgi.service.component.ComponentContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.URI;
import java.util.Arrays;
import java.util.Dictionary;
import java.util.List;
import java.util.concurrent.TimeUnit;
/**
* This service creates a waveform image from a media file with at least one audio channel.
* This will be done using ffmpeg.
*/
public class WaveformServiceImpl extends AbstractJobProducer implements WaveformService, ManagedService {
/** The logging facility */
protected static final Logger logger = LoggerFactory.getLogger(WaveformServiceImpl.class);
/** Path to the executable */
protected String binary = DEFAULT_FFMPEG_BINARY;
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_JOB_LOAD */
public static final String WAVEFORM_JOB_LOAD_CONFIG_KEY = "job.load.waveform";
/** The default job load of a waveform job */
public static final float DEFAULT_WAVEFORM_JOB_LOAD = 1.0f;
/** The key to look for in the service configuration file to override the DEFAULT_FFMPEG_BINARY */
public static final String FFMPEG_BINARY_CONFIG_KEY = "org.opencastproject.composer.ffmpeg.path";
/** The default path to the ffmpeg binary */
public static final String DEFAULT_FFMPEG_BINARY = "ffmpeg";
/** The default minimum waveform image width in pixels */
public static final int DEFAULT_WAVEFORM_IMAGE_WIDTH_MIN = 5000;
/** The default maximum waveform image width in pixels */
public static final int DEFAULT_WAVEFORM_IMAGE_WIDTH_MAX = 20000;
/** The default waveform image width per minute of video in pixels */
public static final int DEFAULT_WAVEFORM_IMAGE_WIDTH_PIXEL_PER_MINUTE = 200;
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_IMAGE_WIDTH_MIN */
public static final String WAVEFORM_IMAGE_WIDTH_MIN_CONFIG_KEY = "waveform.image.width.min";
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_IMAGE_WIDTH_MAX */
public static final String WAVEFORM_IMAGE_WIDTH_MAX_CONFIG_KEY = "waveform.image.width.max";
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_IMAGE_WIDTH_PIXEL_PER_MINUTE */
public static final String WAVEFORM_IMAGE_WIDTH_PPM_CONFIG_KEY = "waveform.image.width.ppm";
/** The default waveform image height in pixels */
public static final int DEFAULT_WAVEFORM_IMAGE_HEIGHT = 500;
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_IMAGE_HEIGHT */
public static final String WAVEFORM_IMAGE_HEIGHT_CONFIG_KEY = "waveform.image.height";
/** The default waveform image scale algorithm */
public static final String DEFAULT_WAVEFORM_SCALE = "lin";
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_SCALE */
public static final String WAVEFORM_SCALE_CONFIG_KEY = "waveform.scale";
/** The default value if the waveforms (per audio channel) should be renderen next to each other (if true)
* or on top of each other (if false) */
public static final boolean DEFAULT_WAVEFORM_SPLIT_CHANNELS = false;
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_SPLIT_CHANNELS */
public static final String WAVEFORM_SPLIT_CHANNELS_CONFIG_KEY = "waveform.split.channels";
/** The default waveform colors per audio channel */
public static final String[] DEFAULT_WAVEFORM_COLOR = { "black" };
/** The key to look for in the service configuration file to override the DEFAULT_WAVEFORM_COLOR */
public static final String WAVEFORM_COLOR_CONFIG_KEY = "waveform.color";
/** Resulting collection in the working file repository */
public static final String COLLECTION_ID = "waveform";
/** List of available operations on jobs */
enum Operation {
Waveform
};
/** The waveform job load */
private float waveformJobLoad = DEFAULT_WAVEFORM_JOB_LOAD;
/** The minimum waveform image width in pixels */
private int waveformImageWidthMin = DEFAULT_WAVEFORM_IMAGE_WIDTH_MIN;
/** The maximum waveform image width in pixels */
private int waveformImageWidthMax = DEFAULT_WAVEFORM_IMAGE_WIDTH_MAX;
/** The waveform image width per minute of video in pixels */
private int waveformImageWidthPPM = DEFAULT_WAVEFORM_IMAGE_WIDTH_PIXEL_PER_MINUTE;
/** The waveform image height in pixels */
private int waveformImageHeight = DEFAULT_WAVEFORM_IMAGE_HEIGHT;
/** The waveform image scale algorithm */
private String waveformScale = DEFAULT_WAVEFORM_SCALE;
/** The value if the waveforms (per audio channel) should be rendered next to each other (if true)
* or on top of each other (if false) */
private boolean waveformSplitChannels = DEFAULT_WAVEFORM_SPLIT_CHANNELS;
/** The waveform colors per audio channel */
private String[] waveformColor = DEFAULT_WAVEFORM_COLOR;
/** Reference to the service registry */
private ServiceRegistry serviceRegistry = null;
/** The workspace to use when retrieving remote media files */
private Workspace workspace = null;
/** The security service */
private SecurityService securityService = null;
/** The user directory service */
private UserDirectoryService userDirectoryService = null;
/** The organization directory service */
private OrganizationDirectoryService organizationDirectoryService = null;
public WaveformServiceImpl() {
super(JOB_TYPE);
}
@Override
public void activate(ComponentContext cc) {
super.activate(cc);
logger.info("Activate ffmpeg waveform service");
final String path = cc.getBundleContext().getProperty(FFMPEG_BINARY_CONFIG_KEY);
binary = (path == null ? DEFAULT_FFMPEG_BINARY : path);
logger.debug("ffmpeg binary set to {}", binary);
}
@Override
public void updated(Dictionary<String, ?> properties) throws ConfigurationException {
if (properties == null) {
return;
}
logger.debug("Configuring the waveform service");
waveformJobLoad = LoadUtil.getConfiguredLoadValue(properties,
WAVEFORM_JOB_LOAD_CONFIG_KEY, DEFAULT_WAVEFORM_JOB_LOAD, serviceRegistry);
Object val = properties.get(WAVEFORM_IMAGE_WIDTH_MIN_CONFIG_KEY);
if (val != null) {
try {
waveformImageWidthMin = Integer.parseInt((String) val);
} catch (NumberFormatException ex) {
logger.warn("The configuration value for {} should be an integer but is {}",
WAVEFORM_IMAGE_WIDTH_MIN_CONFIG_KEY, val);
}
}
val = properties.get(WAVEFORM_IMAGE_WIDTH_MAX_CONFIG_KEY);
if (val != null) {
try {
waveformImageWidthMax = Integer.parseInt((String) val);
} catch (NumberFormatException ex) {
logger.warn("The configuration value for {} should be an integer but is {}",
WAVEFORM_IMAGE_WIDTH_MAX_CONFIG_KEY, val);
}
}
val = properties.get(WAVEFORM_IMAGE_WIDTH_PPM_CONFIG_KEY);
if (val != null) {
try {
waveformImageWidthPPM = Integer.parseInt((String) val);
} catch (NumberFormatException ex) {
logger.warn("The configuration value for {} should be an integer but is {}",
WAVEFORM_IMAGE_WIDTH_PPM_CONFIG_KEY, val);
}
}
val = properties.get(WAVEFORM_IMAGE_HEIGHT_CONFIG_KEY);
if (val != null) {
try {
waveformImageHeight = Integer.parseInt((String) val);
} catch (NumberFormatException ex) {
logger.warn("The configuration value for {} should be an integer but is {}",
WAVEFORM_IMAGE_HEIGHT_CONFIG_KEY, val);
}
}
val = properties.get(WAVEFORM_SCALE_CONFIG_KEY);
if (val != null) {
if (StringUtils.isNotEmpty((String) val)) {
if (!"lin".equals(val) && !"log".equals(val)) {
logger.warn("Waveform scale configuration value '{}' is not in set of predefined values (lin, log). "
+ "The waveform image extraction job may fail.", val);
}
waveformScale = (String) val;
}
}
val = properties.get(WAVEFORM_SPLIT_CHANNELS_CONFIG_KEY);
if (val != null) {
waveformSplitChannels = Boolean.parseBoolean((String) val);
}
val = properties.get(WAVEFORM_COLOR_CONFIG_KEY);
if (val != null && StringUtils.isNotEmpty((String) val)) {
String colorValue = (String) val;
if (StringUtils.isNotEmpty(colorValue) && StringUtils.isNotBlank(colorValue)) {
waveformColor = StringUtils.split(colorValue, ", |:;");
}
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.waveform.api.WaveformService#createWaveformImage(org.opencastproject.mediapackage.Track)
*/
@Override
public Job createWaveformImage(Track sourceTrack) throws MediaPackageException, WaveformServiceException {
try {
return serviceRegistry.createJob(jobType, Operation.Waveform.toString(),
Arrays.asList(MediaPackageElementParser.getAsXml(sourceTrack)), waveformJobLoad);
} catch (ServiceRegistryException ex) {
throw new WaveformServiceException("Unable to create waveform job", ex);
}
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.job.api.AbstractJobProducer#process(org.opencastproject.job.api.Job)
*/
@Override
protected String process(Job job) throws Exception {
Operation op = null;
String operation = job.getOperation();
List<String> arguments = job.getArguments();
try {
op = Operation.valueOf(operation);
switch (op) {
case Waveform:
Track track = (Track) MediaPackageElementParser.getFromXml(arguments.get(0));
Attachment waveformMpe = extractWaveform(track);
return MediaPackageElementParser.getAsXml(waveformMpe);
default:
throw new IllegalStateException("Don't know how to handle operation '" + operation + "'");
}
} catch (IllegalArgumentException e) {
throw new ServiceRegistryException("This service can't handle operations of type '" + op + "'", e);
} catch (IndexOutOfBoundsException e) {
throw new ServiceRegistryException("This argument list for operation '" + op + "' does not meet expectations", e);
} catch (MediaPackageException | WaveformServiceException e) {
throw new ServiceRegistryException("Error handling operation '" + op + "'", e);
}
}
/**
* Create and run waveform extraction ffmpeg command.
*
* @param track source audio/video track with at least one audio channel
* @return waveform image attachment
* @throws WaveformServiceException if processing fails
*/
private Attachment extractWaveform(Track track) throws WaveformServiceException {
if (!track.hasAudio()) {
throw new WaveformServiceException("Track has no audio");
}
// copy source file into workspace
File mediaFile = null;
try {
mediaFile = workspace.get(track.getURI());
} catch (NotFoundException e) {
throw new WaveformServiceException(
"Error finding the media file in the workspace", e);
} catch (IOException e) {
throw new WaveformServiceException(
"Error reading the media file in the workspace", e);
}
String waveformFilePath = FilenameUtils.removeExtension(mediaFile.getAbsolutePath()).concat("_waveform.png");
// create ffmpeg command
String[] command = new String[] {
binary,
"-nostats",
"-i", mediaFile.getAbsolutePath().replaceAll(" ", "\\ "),
"-lavfi", createWaveformFilter(track),
"-an", "-vn", "-sn", "-y",
waveformFilePath.replaceAll(" ", "\\ ")
};
logger.debug("Start waveform ffmpeg process: {}", StringUtils.join(command, " "));
logger.info("Create waveform image file for track '{}' at {}", track.getIdentifier(), waveformFilePath);
// run ffmpeg
ProcessBuilder pb = new ProcessBuilder(command);
pb.redirectErrorStream(true);
Process ffmpegProcess = null;
int exitCode = 1;
BufferedReader errStream = null;
try {
ffmpegProcess = pb.start();
errStream = new BufferedReader(new InputStreamReader(ffmpegProcess.getInputStream()));
String line = errStream.readLine();
while (line != null) {
logger.debug(line);
line = errStream.readLine();
}
exitCode = ffmpegProcess.waitFor();
} catch (IOException ex) {
throw new WaveformServiceException("Start ffmpeg process failed", ex);
} catch (InterruptedException ex) {
throw new WaveformServiceException("Waiting for encoder process exited was interrupted unexpectly", ex);
} finally {
IoSupport.closeQuietly(ffmpegProcess);
IoSupport.closeQuietly(errStream);
if (exitCode != 0) {
try {
FileUtils.forceDelete(new File(waveformFilePath));
} catch (IOException e) {
// it is ok, no output file was generated by ffmpeg
}
}
}
if (exitCode != 0)
throw new WaveformServiceException("The encoder process exited abnormally with exit code " + exitCode);
// put waveform image into workspace
FileInputStream waveformFileInputStream = null;
URI waveformFileUri = null;
try {
waveformFileInputStream = new FileInputStream(waveformFilePath);
waveformFileUri = workspace.putInCollection(COLLECTION_ID,
FilenameUtils.getName(waveformFilePath), waveformFileInputStream);
logger.info("Copied the created waveform to the workspace {}", waveformFileUri.toString());
} catch (FileNotFoundException ex) {
throw new WaveformServiceException(String.format("Waveform image file '%s' not found", waveformFilePath), ex);
} catch (IOException ex) {
throw new WaveformServiceException(String.format(
"Can't write waveform image file '%s' to workspace", waveformFilePath), ex);
} catch (IllegalArgumentException ex) {
throw new WaveformServiceException(ex);
} finally {
IoSupport.closeQuietly(waveformFileInputStream);
logger.info("Deleted local waveform image file at {}", waveformFilePath);
FileUtils.deleteQuietly(new File(waveformFilePath));
}
// create media package element
MediaPackageElementBuilder mpElementBuilder = MediaPackageElementBuilderFactory.newInstance().newElementBuilder();
// it is up to the workflow operation handler to set the attachment flavor
Attachment waveformMpe = (Attachment) mpElementBuilder.elementFromURI(
waveformFileUri, Type.Attachment, track.getFlavor());
waveformMpe.setIdentifier(IdBuilderFactory.newInstance().newIdBuilder().createNew().compact());
return waveformMpe;
}
/**
* Create an ffmpeg waveform filter with parameters based on input track and service configuration.
*
* @param track source audio/video track with at least one audio channel
* @return ffmpeg filter parameter
*/
private String createWaveformFilter(Track track) {
StringBuilder filterBuilder = new StringBuilder("showwavespic=");
filterBuilder.append("split_channels=");
filterBuilder.append(waveformSplitChannels ? 1 : 0);
filterBuilder.append(":s=");
filterBuilder.append(getWaveformImageWidth(track));
filterBuilder.append("x");
filterBuilder.append(waveformImageHeight);
filterBuilder.append(":scale=");
filterBuilder.append(waveformScale);
filterBuilder.append(":colors=");
filterBuilder.append(StringUtils.join(Arrays.asList(waveformColor), "|"));
return filterBuilder.toString();
}
/**
* Return the waveform image width build from input track and service configuration.
*
* @param track source audio/video track with at least one audio channel
* @return waveform image width
*/
private int getWaveformImageWidth(Track track) {
int imageWidth = waveformImageWidthMin;
if (track.getDuration() > 0) {
int trackDurationMinutes = (int) TimeUnit.MILLISECONDS.toMinutes(track.getDuration());
if (waveformImageWidthPPM > 0 && trackDurationMinutes > 0) {
imageWidth = Math.max(waveformImageWidthMin, trackDurationMinutes * waveformImageWidthPPM);
imageWidth = Math.min(waveformImageWidthMax, imageWidth);
}
}
return imageWidth;
}
@Override
protected ServiceRegistry getServiceRegistry() {
return serviceRegistry;
}
@Override
protected SecurityService getSecurityService() {
return securityService;
}
@Override
protected UserDirectoryService getUserDirectoryService() {
return userDirectoryService;
}
@Override
protected OrganizationDirectoryService getOrganizationDirectoryService() {
return organizationDirectoryService;
}
public void setServiceRegistry(ServiceRegistry serviceRegistry) {
this.serviceRegistry = serviceRegistry;
}
public void setSecurityService(SecurityService securityService) {
this.securityService = securityService;
}
public void setUserDirectoryService(UserDirectoryService userDirectoryService) {
this.userDirectoryService = userDirectoryService;
}
public void setOrganizationDirectoryService(OrganizationDirectoryService organizationDirectoryService) {
this.organizationDirectoryService = organizationDirectoryService;
}
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
}