/**
* 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.workflow.handler.composer;
import org.opencastproject.composer.api.ComposerService;
import org.opencastproject.composer.api.EncoderException;
import org.opencastproject.composer.api.EncodingProfile;
import org.opencastproject.job.api.Job;
import org.opencastproject.job.api.JobContext;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.mediapackage.MediaPackageElementParser;
import org.opencastproject.mediapackage.MediaPackageException;
import org.opencastproject.mediapackage.Track;
import org.opencastproject.util.NotFoundException;
import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
import org.opencastproject.workflow.api.WorkflowInstance;
import org.opencastproject.workflow.api.WorkflowOperationException;
import org.opencastproject.workflow.api.WorkflowOperationInstance;
import org.opencastproject.workflow.api.WorkflowOperationResult;
import org.opencastproject.workflow.api.WorkflowOperationResult.Action;
import org.opencastproject.workspace.api.Workspace;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.SortedMap;
import java.util.TreeMap;
/**
* The <tt>prepare media</tt> operation will make sure that media where audio and video track come in separate files
* will be muxed prior to further processing.
*/
public class PrepareAVWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
/** The logging facility */
private static final Logger logger = LoggerFactory.getLogger(ComposeWorkflowOperationHandler.class);
private static final String PLUS = "+";
private static final String MINUS = "-";
private static final String QUESTION_MARK = "?";
/** Name of the 'encode to a/v work copy' encoding profile */
public static final String PREPARE_AV_PROFILE = "av.work";
/** Name of the muxing encoding profile */
public static final String MUX_AV_PROFILE = "mux-av.work";
/** Name of the 'encode to audio only work copy' encoding profile */
public static final String PREPARE_AONLY_PROFILE = "audio-only.work";
/** Name of the 'encode to video only work copy' encoding profile */
public static final String PREPARE_VONLY_PROFILE = "video-only.work";
/** Name of the 'rewrite' configuration key */
public static final String OPT_REWRITE = "rewrite";
/** Name of audio muxing configuration key */
public static final String OPT_AUDIO_MUXING_SOURCE_FLAVORS = "audio-muxing-source-flavors";
/** The configuration options for this handler */
private static final SortedMap<String, String> CONFIG_OPTIONS;
static {
CONFIG_OPTIONS = new TreeMap<String, String>();
CONFIG_OPTIONS.put("source-flavor", "The \"flavor\" of the track to use as a video source input");
CONFIG_OPTIONS.put("encoding-profile", "The encoding profile to use (default is 'mux-av.http')");
CONFIG_OPTIONS.put("target-flavor", "The flavor to apply to the encoded file");
CONFIG_OPTIONS.put(OPT_REWRITE, "Indicating whether the container for audio and video tracks should be rewritten");
CONFIG_OPTIONS.put(OPT_AUDIO_MUXING_SOURCE_FLAVORS, "If the video track has no audio, try to find an audio track in this sequence of flavors");
CONFIG_OPTIONS.put("target-tags", "The tags to apply to the encoded file");
}
/** The composer service */
private ComposerService composerService = null;
/** The local workspace */
private Workspace workspace = null;
/**
* Callback for the OSGi declarative services configuration.
*
* @param composerService
* the local composer service
*/
protected void setComposerService(ComposerService composerService) {
this.composerService = composerService;
}
/**
* Callback for declarative services configuration that will introduce us to the local workspace service.
* Implementation assumes that the reference is configured as being static.
*
* @param workspace
* an instance of the workspace
*/
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.workflow.api.WorkflowOperationHandler#getConfigurationOptions()
*/
@Override
public SortedMap<String, String> getConfigurationOptions() {
return CONFIG_OPTIONS;
}
/**
* {@inheritDoc}
*
* @see org.opencastproject.workflow.api.WorkflowOperationHandler#start(org.opencastproject.workflow.api.WorkflowInstance,
* JobContext)
*/
public WorkflowOperationResult start(final WorkflowInstance workflowInstance, JobContext context)
throws WorkflowOperationException {
logger.debug("Running a/v muxing workflow operation on workflow {}", workflowInstance.getId());
try {
return mux(workflowInstance.getMediaPackage(), workflowInstance.getCurrentOperation());
} catch (Exception e) {
throw new WorkflowOperationException(e);
}
}
/**
* Merges audio and video track of the selected flavor and adds it to the media package. If there is nothing to mux, a
* new track with the target flavor is created (pointing to the original url).
*
* @param src
* The source media package
* @param operation
* the mux workflow operation
* @return the operation result containing the updated mediapackage
* @throws EncoderException
* if encoding fails
* @throws IOException
* if read/write operations from and to the workspace fail
* @throws NotFoundException
* if the workspace does not contain the requested element
*/
private WorkflowOperationResult mux(MediaPackage src, WorkflowOperationInstance operation) throws EncoderException,
WorkflowOperationException, NotFoundException, MediaPackageException, IOException {
MediaPackage mediaPackage = (MediaPackage) src.clone();
// Read the configuration properties
String sourceFlavorName = StringUtils.trimToNull(operation.getConfiguration("source-flavor"));
String targetTrackTags = StringUtils.trimToNull(operation.getConfiguration("target-tags"));
String targetTrackFlavorName = StringUtils.trimToNull(operation.getConfiguration("target-flavor"));
String muxEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("mux-encoding-profile"));
String audioVideoEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("audio-video-encoding-profile"));
String videoOnlyEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("video-encoding-profile"));
String audioOnlyEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("audio-encoding-profile"));
String[] targetTags = StringUtils.split(targetTrackTags, ",");
List<String> removeTags = new ArrayList<String>();
List<String> addTags = new ArrayList<String>();
List<String> overrideTags = new ArrayList<String>();
if (targetTags != null) {
for (String tag : targetTags) {
if (tag.startsWith(MINUS)) {
removeTags.add(tag);
} else if (tag.startsWith(PLUS)) {
addTags.add(tag);
} else {
overrideTags.add(tag);
}
}
}
// Make sure the source flavor is properly set
if (sourceFlavorName == null)
throw new IllegalStateException("Source flavor must be specified");
MediaPackageElementFlavor sourceFlavor = MediaPackageElementFlavor.parseFlavor(sourceFlavorName);
// Make sure the target flavor is properly set
if (targetTrackFlavorName == null)
throw new IllegalStateException("Target flavor must be specified");
MediaPackageElementFlavor targetFlavor = MediaPackageElementFlavor.parseFlavor(targetTrackFlavorName);
// Reencode when there is no need for muxing?
boolean rewrite = true;
if (StringUtils.trimToNull(operation.getConfiguration(OPT_REWRITE)) != null) {
rewrite = Boolean.parseBoolean(operation.getConfiguration(OPT_REWRITE));
}
String audioMuxingSourceFlavors = StringUtils.trimToNull(operation.getConfiguration(OPT_AUDIO_MUXING_SOURCE_FLAVORS));
// Select those tracks that have matching flavors
Track[] tracks = mediaPackage.getTracks(sourceFlavor);
Track audioTrack = null;
Track videoTrack = null;
switch (tracks.length) {
case 0:
logger.info("No audio/video tracks with flavor '{}' found to prepare", sourceFlavor);
return createResult(mediaPackage, Action.CONTINUE);
case 1:
videoTrack = tracks[0];
if (!tracks[0].hasAudio() && tracks[0].hasVideo() && (audioMuxingSourceFlavors != null)) {
audioTrack = findAudioTrack(tracks[0], mediaPackage, audioMuxingSourceFlavors);
} else {
audioTrack = tracks[0];
}
break;
case 2:
for (Track track : tracks) {
if (track.hasAudio() && !track.hasVideo()) {
audioTrack = track;
} else if (!track.hasAudio() && track.hasVideo()) {
videoTrack = track;
} else {
throw new WorkflowOperationException("Multiple tracks with competing audio/video streams and flavor '"
+ sourceFlavor + "' found");
}
}
break;
default:
logger.error("More than two tracks with flavor {} found. No idea what we should be doing", sourceFlavor);
throw new WorkflowOperationException("More than two tracks with flavor '" + sourceFlavor + "' found");
}
Job job = null;
Track composedTrack = null;
// Make sure we have a matching combination
if (audioTrack == null && videoTrack != null) {
if (rewrite) {
logger.info("Encoding video only track {} to work version", videoTrack);
if (videoOnlyEncodingProfileName == null)
videoOnlyEncodingProfileName = PREPARE_VONLY_PROFILE;
// Find the encoding profile to make sure the given profile exists
EncodingProfile profile = composerService.getProfile(videoOnlyEncodingProfileName);
if (profile == null)
throw new IllegalStateException("Encoding profile '" + videoOnlyEncodingProfileName + "' was not found");
composedTrack = prepare(videoTrack, mediaPackage, videoOnlyEncodingProfileName);
} else {
composedTrack = (Track) videoTrack.clone();
composedTrack.setIdentifier(null);
mediaPackage.add(composedTrack);
}
} else if (videoTrack == null && audioTrack != null) {
if (rewrite) {
logger.info("Encoding audio only track {} to work version", audioTrack);
if (audioOnlyEncodingProfileName == null)
audioOnlyEncodingProfileName = PREPARE_AONLY_PROFILE;
// Find the encoding profile to make sure the given profile exists
EncodingProfile profile = composerService.getProfile(audioOnlyEncodingProfileName);
if (profile == null)
throw new IllegalStateException("Encoding profile '" + audioOnlyEncodingProfileName + "' was not found");
composedTrack = prepare(audioTrack, mediaPackage, audioOnlyEncodingProfileName);
} else {
composedTrack = (Track) audioTrack.clone();
composedTrack.setIdentifier(null);
mediaPackage.add(composedTrack);
}
} else if (audioTrack == videoTrack) {
if (rewrite) {
logger.info("Encoding audiovisual track {} to work version", videoTrack);
if (audioVideoEncodingProfileName == null)
audioVideoEncodingProfileName = PREPARE_AV_PROFILE;
// Find the encoding profile to make sure the given profile exists
EncodingProfile profile = composerService.getProfile(audioVideoEncodingProfileName);
if (profile == null)
throw new IllegalStateException("Encoding profile '" + audioVideoEncodingProfileName + "' was not found");
composedTrack = prepare(videoTrack, mediaPackage, audioVideoEncodingProfileName);
} else {
composedTrack = (Track) videoTrack.clone();
composedTrack.setIdentifier(null);
mediaPackage.add(composedTrack);
}
} else {
logger.info("Muxing audio and video only track {} to work version", videoTrack);
if (audioTrack.hasVideo()) {
logger.info("Stripping audio from track {}", audioTrack);
audioTrack = prepare(audioTrack, null, PREPARE_AONLY_PROFILE);
}
if (muxEncodingProfileName == null)
muxEncodingProfileName = MUX_AV_PROFILE;
// Find the encoding profile
EncodingProfile profile = composerService.getProfile(muxEncodingProfileName);
if (profile == null)
throw new IllegalStateException("Encoding profile '" + muxEncodingProfileName + "' was not found");
job = composerService.mux(videoTrack, audioTrack, profile.getIdentifier());
if (!waitForStatus(job).isSuccess()) {
throw new WorkflowOperationException("Muxing video track " + videoTrack + " and audio track " + audioTrack
+ " failed");
}
composedTrack = (Track) MediaPackageElementParser.getFromXml(job.getPayload());
mediaPackage.add(composedTrack);
String fileName = getFileNameFromElements(videoTrack, composedTrack);
composedTrack.setURI(workspace.moveTo(composedTrack.getURI(), mediaPackage.getIdentifier().toString(),
composedTrack.getIdentifier(), fileName));
}
long timeInQueue = 0;
if (job != null) {
// add this receipt's queue time to the total
timeInQueue = job.getQueueTime();
}
// Update the track's flavor
composedTrack.setFlavor(targetFlavor);
logger.debug("Composed track has flavor '{}'", composedTrack.getFlavor());
// Add the target tags
if (overrideTags.size() > 0) {
composedTrack.clearTags();
for (String tag : overrideTags) {
logger.trace("Tagging composed track with '{}'", tag);
composedTrack.addTag(tag);
}
} else {
for (String tag : removeTags) {
logger.trace("Remove tagging '{}' from composed track", tag);
composedTrack.removeTag(tag.substring(MINUS.length()));
}
for (String tag : addTags) {
logger.trace("Add tagging '{}' to composed track", tag);
composedTrack.addTag(tag.substring(PLUS.length()));
}
}
return createResult(mediaPackage, Action.CONTINUE, timeInQueue);
}
/**
* Prepares a video track. If the mediapackage is specified, the prepared track will be added to it.
*
* @param videoTrack
* the track containing the video
* @param mediaPackage
* the mediapackage
* @return the rewritten track
* @throws WorkflowOperationException
* @throws NotFoundException
* @throws IOException
* @throws EncoderException
* @throws MediaPackageException
*/
private Track prepare(Track videoTrack, MediaPackage mediaPackage, String encodingProfile)
throws WorkflowOperationException, NotFoundException, IOException, EncoderException, MediaPackageException {
Track composedTrack = null;
logger.info("Encoding video only track {} to work version", videoTrack);
Job job = composerService.encode(videoTrack, encodingProfile);
if (!waitForStatus(job).isSuccess()) {
throw new WorkflowOperationException("Rewriting container for video track " + videoTrack + " failed");
}
composedTrack = (Track) MediaPackageElementParser.getFromXml(job.getPayload());
if (mediaPackage != null) {
mediaPackage.add(composedTrack);
String fileName = getFileNameFromElements(videoTrack, composedTrack);
// Note that the composed track must have an ID before being moved to the mediapackage in the working file
// repository. This ID is generated when the track is added to the mediapackage. So the track must be added
// to the mediapackage before attempting to move the file.
composedTrack.setURI(workspace.moveTo(composedTrack.getURI(), mediaPackage.getIdentifier().toString(),
composedTrack.getIdentifier(), fileName));
}
return composedTrack;
}
/**
* Finds a suitable audio track from the mediapackage by scanning a source flavor sequence
*
* @param videoTrack
* the video track
* @param mediaPackage
* the mediapackage
* @param audioMuxingSourceFlavors
* sequence of source flavors where an audio track should be searched for
* @return the found audio track
*/
private Track findAudioTrack(Track videoTrack, MediaPackage mediaPackage, String audioMuxingSourceFlavors) {
if (audioMuxingSourceFlavors != null) {
String type;
String subtype;
for (String flavorStr : audioMuxingSourceFlavors.split("[\\s,]")) {
if (!flavorStr.isEmpty()) {
MediaPackageElementFlavor flavor = null;
try {
flavor = MediaPackageElementFlavor.parseFlavor(flavorStr);
} catch (IllegalArgumentException e) {
logger.error("The parameter {} contains an invalid flavor: {}", OPT_AUDIO_MUXING_SOURCE_FLAVORS, flavorStr);
throw e;
}
type = (QUESTION_MARK.equals(flavor.getType())) ? videoTrack.getFlavor().getType() : flavor.getType();
subtype = (QUESTION_MARK.equals(flavor.getSubtype())) ? videoTrack.getFlavor().getSubtype() : flavor.getSubtype();
// Recreate the (possibly) modified flavor
flavor = new MediaPackageElementFlavor(type, subtype);
for (Track track : mediaPackage.getTracks(flavor)) {
if (track.hasAudio()) {
logger.info("Audio muxing found audio source {} with flavor {}", track, track.getFlavor());
return track;
}
}
}
}
}
return null;
}
}