SubtitleTimeshiftWorkflowOperationHandler.java
/*
* 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.subtitletimeshift;
import static java.lang.String.format;
import org.opencastproject.job.api.JobContext;
import org.opencastproject.mediapackage.MediaPackage;
import org.opencastproject.mediapackage.MediaPackageElementFlavor;
import org.opencastproject.mediapackage.Track;
import org.opencastproject.mediapackage.selector.TrackSelector;
import org.opencastproject.subtitleparser.webvttparser.WebVTTParser;
import org.opencastproject.subtitleparser.webvttparser.WebVTTSubtitle;
import org.opencastproject.subtitleparser.webvttparser.WebVTTSubtitleCue;
import org.opencastproject.subtitleparser.webvttparser.WebVTTWriter;
import org.opencastproject.util.Checksum;
import org.opencastproject.util.ChecksumType;
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.WorkflowOperationHandler;
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.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.net.URI;
import java.util.Collection;
import java.util.Objects;
/**
* This workflow operation allows to shift the timestamps of subtitle files.
* For example: If someone adds a bumper/intro video in front of an already subtitled presenter track
* the subtitles would start too early. With this operation, you can select a video and a subtitle track and the
* timestamps of the subtitle file will be shifted backwards by the duration of the selected video.
*/
@Component(
property = {
"service.description=subtitle-timeshift Workflow Operation Handler",
"workflow.operation=subtitle-timeshift"
},
immediate = true,
service = WorkflowOperationHandler.class
)
public class SubtitleTimeshiftWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
private static final String SUBTITLE_SOURCE_FLAVOR_CFG_KEY = "subtitle-source-flavor";
private static final String VIDEO_SOURCE_FLAVOR_CFG_KEY = "video-source-flavors";
private static final String TARGET_FLAVOR_CFG_KEY = "target-flavor";
/** The workspace collection name */
private static final String COLLECTION = "subtitles";
/** The logging facility */
private static final Logger logger = LoggerFactory.getLogger(SubtitleTimeshiftWorkflowOperationHandler.class);
/**
* Reference to the workspace service
*/
private Workspace workspace = null;
/**
* OSGi setter for the workspace class
*
* @param workspace an instance of the workspace
*/
@Reference
public void setWorkspace(Workspace workspace) {
this.workspace = workspace;
}
@Override
public WorkflowOperationResult start(WorkflowInstance workflowInstance, JobContext context)
throws WorkflowOperationException {
MediaPackage mediaPackage = workflowInstance.getMediaPackage();
logger.info("Starting subtitle timeshift workflow for mediapackage: {}", mediaPackage.getIdentifier().toString());
// get flavor from workflow configuration
final WorkflowOperationInstance operation = workflowInstance.getCurrentOperation();
MediaPackageElementFlavor configuredSubtitleFlavor;
String configuredVideoFlavors;
MediaPackageElementFlavor configuredTargetFlavor;
try {
configuredSubtitleFlavor = MediaPackageElementFlavor.parseFlavor(
Objects.toString(operation.getConfiguration(SUBTITLE_SOURCE_FLAVOR_CFG_KEY)));
configuredTargetFlavor = MediaPackageElementFlavor.parseFlavor(
operation.getConfiguration(TARGET_FLAVOR_CFG_KEY));
configuredVideoFlavors = StringUtils
.trimToNull(operation.getConfiguration(VIDEO_SOURCE_FLAVOR_CFG_KEY));
if (configuredVideoFlavors == null) {
throw new WorkflowOperationException(format("Configuration property %s not set", VIDEO_SOURCE_FLAVOR_CFG_KEY));
}
} catch (Exception e) {
throw new WorkflowOperationException("Couldn't parse subtitle-timeshift workflow configurations.", e);
}
// In this block we try to get the subtitle and video track for this workflow
Track[] originalSubtitleTracks;
Track videoTrack;
long totalVideoDuration = 0;
try {
// Get the subtitles and videos from the mediapackage
Track[] subtitleTracks = mediaPackage.getTracks(configuredSubtitleFlavor);
TrackSelector trackSelector = new TrackSelector();
for (String flavor : asList(configuredVideoFlavors)) {
trackSelector.addFlavor(flavor);
}
Collection<Track> videoTracks = trackSelector.select(mediaPackage, false);
if (videoTracks.isEmpty()) {
throw new WorkflowOperationException(format("No video tracks found in mediapackage %s with flavor %s",
mediaPackage.getIdentifier().toString(), configuredVideoFlavors));
}
// Check if we found the right amount of subtitles and videos
// Allowed are exactly 1 video track and at least 1 subtitle track
if (subtitleTracks.length == 0) {
// if no subtitle track was found, we skip the workflow operation
logger.info("No subtitle track found with flavor {}. Skipping subtitle-timeshift workflow operation "
+ "for mediapackage {}", configuredSubtitleFlavor, mediaPackage.getIdentifier());
return createResult(mediaPackage, Action.SKIP);
}
// these subtitle tracks will be used to create the new subtitle tracks with the shifted timestamps
originalSubtitleTracks = subtitleTracks;
// this video track will be used to determine how much the subtitle tracks should be shifted
for (Track track : videoTracks) {
Long duration = track.getDuration();
if (duration != null) {
totalVideoDuration += duration;
} else {
logger.debug("Videotrack {} did not have a duration.", track);
}
}
logger.info("Valid tracks found. Start shifting subtitle tracks by duration '{}'", totalVideoDuration);
} catch (Exception e) {
logger.error("Error in subtitle-timeshift workflow while getting tracks for mediapackage {}",
mediaPackage.getIdentifier(), e);
throw new WorkflowOperationException(e);
}
// In this block we try to create the new subtitle tracks and add them to the mediapackage
try {
for (Track originalSubtitleTrack : originalSubtitleTracks) {
// load the subtitle file from workspace and parse it into a webvtt object
WebVTTSubtitle newSubtitleFile = loadAndParseSubtitleFile(originalSubtitleTrack);
// shift the timestamps of the parsed webvtt object
shiftTime(newSubtitleFile, totalVideoDuration);
// save the new subtitle file in the workspace to get a URI
String originalFileName = FilenameUtils.getBaseName(originalSubtitleTrack.getLogicalName());
String newFileName = "timeshifted-" + originalFileName + ".vtt";
URI newSubtitleFileUri = saveSubtitleFileToWorkspace(newSubtitleFile, newFileName);
// create a track object out of the subtitle URI
Track newSubtitleTrack = createNewTrackFromSubtitleUri(newSubtitleFileUri, configuredTargetFlavor,
originalSubtitleTrack);
// save the new subtitle track to the mediapackage
mediaPackage.add(newSubtitleTrack);
logger.info("Added subtitle track with URI {} to mediapackage {}", newSubtitleFileUri,
mediaPackage.getIdentifier());
}
} catch (Exception e) {
logger.error("Error while shifting time of subtitle tracks for mediapackage {}", mediaPackage.getIdentifier(), e);
throw new WorkflowOperationException(e);
}
logger.info("Subtitle-Timeshift workflow operation for media package {} completed", mediaPackage);
return createResult(mediaPackage, Action.CONTINUE);
}
/**
* Takes several parameter for the new subtitle file in and creates a track object from it.
*
* @param subtitleFile The new subtitle file that will be contained in the track.
* @param targetFlavor The future flavor of the new subtitle track.
* @param originalSubtitleTrack The original subtitle track.
* @return The subtitle file as a track.
*/
private Track createNewTrackFromSubtitleUri(URI subtitleFile, MediaPackageElementFlavor targetFlavor,
Track originalSubtitleTrack) throws IOException, NotFoundException {
Track newSubtitleTrack = (Track) originalSubtitleTrack.clone();
newSubtitleTrack.generateIdentifier();
newSubtitleTrack.setFlavor(targetFlavor);
newSubtitleTrack.setURI(subtitleFile);
newSubtitleTrack.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, workspace.get(subtitleFile, true)));
return newSubtitleTrack;
}
/**
* Saves the subtitle object into the workspace and creates a file there.
*
* @param webVTTSubtitle The subtitle object.
* @param fileName The filname of the new subtitle file.
* @return The URI of the new subtitle file.
* @throws WorkflowOperationException when something went wrong in the parsing and saving process.
*/
private URI saveSubtitleFileToWorkspace(WebVTTSubtitle webVTTSubtitle, String fileName)
throws WorkflowOperationException {
try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
WebVTTWriter writer = new WebVTTWriter();
writer.write(webVTTSubtitle, outputStream);
try (ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray())) {
return workspace.putInCollection(COLLECTION, fileName, inputStream);
}
} catch (IOException e) {
logger.error("An exception occurred while parsing and saving a subtitle file to the workspace", e);
throw new WorkflowOperationException(e);
}
}
/**
* Loads a subtitle file from the workspace and parses it into a WebVTTSubtitle Object
*
* @param subtitleTrack The track we want to load
* @return The parsed webVTTSubtitle object
* @throws WorkflowOperationException when something went wrong in the parsing and loading process
*/
private WebVTTSubtitle loadAndParseSubtitleFile(Track subtitleTrack) throws WorkflowOperationException {
// Get the subtitle file from workspace
File subtitleFile;
try {
subtitleFile = workspace.get(subtitleTrack.getURI());
} catch (IOException ex) {
throw new WorkflowOperationException("Can't read " + subtitleTrack.getURI());
} catch (NotFoundException ex) {
throw new WorkflowOperationException("Workspace does not contain a track " + subtitleTrack.getURI());
}
// Next try to parse the file into a WebVTT Object
WebVTTSubtitle subtitle;
try (FileInputStream fin = new FileInputStream(subtitleFile)) {
subtitle = new WebVTTParser().parse(fin);
} catch (Exception e) {
throw new WorkflowOperationException("Couldn't parse subtitle file " + subtitleTrack.getURI(), e);
}
return subtitle;
}
/**
* Shifts all timestamps of a subtitle file by a given time.
*
* @param time Time in milliseconds by which all timestamps shall be shifted.
*/
public void shiftTime(WebVTTSubtitle subtitleFile, long time) {
for (WebVTTSubtitleCue cue : subtitleFile.getCues()) {
long start = cue.getStartTime();
long end = cue.getEndTime();
cue.setStartTime(start + time);
cue.setEndTime(end + time);
}
}
}