View Javadoc
1   /*
2    * Licensed to The Apereo Foundation under one or more contributor license
3    * agreements. See the NOTICE file distributed with this work for additional
4    * information regarding copyright ownership.
5    *
6    *
7    * The Apereo Foundation licenses this file to you under the Educational
8    * Community License, Version 2.0 (the "License"); you may not use this file
9    * except in compliance with the License. You may obtain a copy of the License
10   * at:
11   *
12   *   http://opensource.org/licenses/ecl2.txt
13   *
14   * Unless required by applicable law or agreed to in writing, software
15   * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
16   * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.  See the
17   * License for the specific language governing permissions and limitations under
18   * the License.
19   *
20   */
21  
22  package org.opencastproject.workflow.handler.subtitletimeshift;
23  
24  import static java.lang.String.format;
25  
26  import org.opencastproject.job.api.JobContext;
27  import org.opencastproject.mediapackage.MediaPackage;
28  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
29  import org.opencastproject.mediapackage.Track;
30  import org.opencastproject.mediapackage.selector.TrackSelector;
31  import org.opencastproject.subtitleparser.webvttparser.WebVTTParser;
32  import org.opencastproject.subtitleparser.webvttparser.WebVTTSubtitle;
33  import org.opencastproject.subtitleparser.webvttparser.WebVTTSubtitleCue;
34  import org.opencastproject.subtitleparser.webvttparser.WebVTTWriter;
35  import org.opencastproject.util.Checksum;
36  import org.opencastproject.util.ChecksumType;
37  import org.opencastproject.util.NotFoundException;
38  import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
39  import org.opencastproject.workflow.api.WorkflowInstance;
40  import org.opencastproject.workflow.api.WorkflowOperationException;
41  import org.opencastproject.workflow.api.WorkflowOperationHandler;
42  import org.opencastproject.workflow.api.WorkflowOperationInstance;
43  import org.opencastproject.workflow.api.WorkflowOperationResult;
44  import org.opencastproject.workflow.api.WorkflowOperationResult.Action;
45  import org.opencastproject.workspace.api.Workspace;
46  
47  import org.apache.commons.io.FilenameUtils;
48  import org.apache.commons.lang3.StringUtils;
49  import org.osgi.service.component.annotations.Component;
50  import org.osgi.service.component.annotations.Reference;
51  import org.slf4j.Logger;
52  import org.slf4j.LoggerFactory;
53  
54  import java.io.ByteArrayInputStream;
55  import java.io.ByteArrayOutputStream;
56  import java.io.File;
57  import java.io.FileInputStream;
58  import java.io.IOException;
59  import java.net.URI;
60  import java.util.Collection;
61  import java.util.Objects;
62  
63  /**
64   * This workflow operation allows to shift the timestamps of subtitle files.
65   * For example: If someone adds a bumper/intro video in front of an already subtitled presenter track
66   * the subtitles would start too early. With this operation, you can select a video and a subtitle track and the
67   * timestamps of the subtitle file will be shifted backwards by the duration of the selected video.
68   */
69  @Component(
70      property = {
71          "service.description=subtitle-timeshift Workflow Operation Handler",
72          "workflow.operation=subtitle-timeshift"
73      },
74      immediate = true,
75      service = WorkflowOperationHandler.class
76  )
77  public class SubtitleTimeshiftWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
78  
79    private static final String SUBTITLE_SOURCE_FLAVOR_CFG_KEY = "subtitle-source-flavor";
80    private static final String VIDEO_SOURCE_FLAVOR_CFG_KEY = "video-source-flavors";
81    private static final String TARGET_FLAVOR_CFG_KEY = "target-flavor";
82  
83    /** The workspace collection name */
84    private static final String COLLECTION = "subtitles";
85  
86    /** The logging facility */
87    private static final Logger logger = LoggerFactory.getLogger(SubtitleTimeshiftWorkflowOperationHandler.class);
88  
89    /**
90     * Reference to the workspace service
91     */
92    private Workspace workspace = null;
93  
94  
95    /**
96     * OSGi setter for the workspace class
97     *
98     * @param workspace an instance of the workspace
99     */
100   @Reference
101   public void setWorkspace(Workspace workspace) {
102     this.workspace = workspace;
103   }
104 
105 
106   @Override
107   public WorkflowOperationResult start(WorkflowInstance workflowInstance, JobContext context)
108           throws WorkflowOperationException {
109 
110     MediaPackage mediaPackage = workflowInstance.getMediaPackage();
111     logger.info("Starting subtitle timeshift workflow for mediapackage: {}", mediaPackage.getIdentifier().toString());
112 
113     // get flavor from workflow configuration
114     final WorkflowOperationInstance operation = workflowInstance.getCurrentOperation();
115     MediaPackageElementFlavor configuredSubtitleFlavor;
116     String configuredVideoFlavors;
117     MediaPackageElementFlavor configuredTargetFlavor;
118     try {
119       configuredSubtitleFlavor = MediaPackageElementFlavor.parseFlavor(
120           Objects.toString(operation.getConfiguration(SUBTITLE_SOURCE_FLAVOR_CFG_KEY)));
121       configuredTargetFlavor = MediaPackageElementFlavor.parseFlavor(
122           operation.getConfiguration(TARGET_FLAVOR_CFG_KEY));
123       configuredVideoFlavors = StringUtils
124           .trimToNull(operation.getConfiguration(VIDEO_SOURCE_FLAVOR_CFG_KEY));
125       if (configuredVideoFlavors == null) {
126         throw new WorkflowOperationException(format("Configuration property %s not set", VIDEO_SOURCE_FLAVOR_CFG_KEY));
127       }
128     } catch (Exception e) {
129       throw new WorkflowOperationException("Couldn't parse subtitle-timeshift workflow configurations.", e);
130     }
131 
132     // In this block we try to get the subtitle and video track for this workflow
133     Track[] originalSubtitleTracks;
134     Track videoTrack;
135     long totalVideoDuration = 0;
136     try {
137       // Get the subtitles and videos from the mediapackage
138       Track[] subtitleTracks = mediaPackage.getTracks(configuredSubtitleFlavor);
139 
140       TrackSelector trackSelector = new TrackSelector();
141       for (String flavor : asList(configuredVideoFlavors)) {
142         trackSelector.addFlavor(flavor);
143       }
144       Collection<Track> videoTracks = trackSelector.select(mediaPackage, false);
145       if (videoTracks.isEmpty()) {
146         throw new WorkflowOperationException(format("No video tracks found in mediapackage %s with flavor %s",
147             mediaPackage.getIdentifier().toString(), configuredVideoFlavors));
148       }
149 
150       // Check if we found the right amount of subtitles and videos
151       // Allowed are exactly 1 video track and at least 1 subtitle track
152       if (subtitleTracks.length == 0) {
153         // if no subtitle track was found, we skip the workflow operation
154         logger.info("No subtitle track found with flavor {}. Skipping subtitle-timeshift workflow operation "
155             + "for mediapackage {}", configuredSubtitleFlavor, mediaPackage.getIdentifier());
156         return createResult(mediaPackage, Action.SKIP);
157       }
158 
159       // these subtitle tracks will be used to create the new subtitle tracks with the shifted timestamps
160       originalSubtitleTracks = subtitleTracks;
161 
162       // this video track will be used to determine how much the subtitle tracks should be shifted
163       for (Track track : videoTracks) {
164         Long duration = track.getDuration();
165         if (duration != null) {
166           totalVideoDuration += duration;
167         } else {
168           logger.debug("Videotrack {} did not have a duration.", track);
169         }
170       }
171 
172       logger.info("Valid tracks found. Start shifting subtitle tracks by duration '{}'", totalVideoDuration);
173 
174     } catch (Exception e) {
175       logger.error("Error in subtitle-timeshift workflow while getting tracks for mediapackage {}",
176           mediaPackage.getIdentifier(), e);
177       throw new WorkflowOperationException(e);
178     }
179 
180     // In this block we try to create the new subtitle tracks and add them to the mediapackage
181     try {
182       for (Track originalSubtitleTrack : originalSubtitleTracks) {
183 
184         // load the subtitle file from workspace and parse it into a webvtt object
185         WebVTTSubtitle newSubtitleFile = loadAndParseSubtitleFile(originalSubtitleTrack);
186 
187         // shift the timestamps of the parsed webvtt object
188         shiftTime(newSubtitleFile, totalVideoDuration);
189 
190         // save the new subtitle file in the workspace to get a URI
191         String originalFileName = FilenameUtils.getBaseName(originalSubtitleTrack.getLogicalName());
192         String newFileName = "timeshifted-" + originalFileName + ".vtt";
193         URI newSubtitleFileUri = saveSubtitleFileToWorkspace(newSubtitleFile, newFileName);
194 
195         // create a track object out of the subtitle URI
196         Track newSubtitleTrack = createNewTrackFromSubtitleUri(newSubtitleFileUri, configuredTargetFlavor,
197             originalSubtitleTrack);
198 
199         // save the new subtitle track to the mediapackage
200         mediaPackage.add(newSubtitleTrack);
201         logger.info("Added subtitle track with URI {} to mediapackage {}", newSubtitleFileUri,
202             mediaPackage.getIdentifier());
203       }
204 
205     } catch (Exception e) {
206       logger.error("Error while shifting time of subtitle tracks for mediapackage {}", mediaPackage.getIdentifier(), e);
207       throw new WorkflowOperationException(e);
208     }
209 
210     logger.info("Subtitle-Timeshift workflow operation for media package {} completed", mediaPackage);
211     return createResult(mediaPackage, Action.CONTINUE);
212   }
213 
214   /**
215    * Takes several parameter for the new subtitle file in and creates a track object from it.
216    *
217    * @param subtitleFile The new subtitle file that will be contained in the track.
218    * @param targetFlavor The future flavor of the new subtitle track.
219    * @param originalSubtitleTrack The original subtitle track.
220    * @return The subtitle file as a track.
221    */
222   private Track createNewTrackFromSubtitleUri(URI subtitleFile, MediaPackageElementFlavor targetFlavor,
223       Track originalSubtitleTrack) throws IOException, NotFoundException {
224 
225     Track newSubtitleTrack = (Track) originalSubtitleTrack.clone();
226     newSubtitleTrack.generateIdentifier();
227     newSubtitleTrack.setFlavor(targetFlavor);
228     newSubtitleTrack.setURI(subtitleFile);
229     newSubtitleTrack.setChecksum(Checksum.create(ChecksumType.DEFAULT_TYPE, workspace.get(subtitleFile, true)));
230     return newSubtitleTrack;
231   }
232 
233   /**
234    * Saves the subtitle object into the workspace and creates a file there.
235    *
236    * @param webVTTSubtitle The subtitle object.
237    * @param fileName The filname of the new subtitle file.
238    * @return The URI of the new subtitle file.
239    * @throws WorkflowOperationException when something went wrong in the parsing and saving process.
240    */
241   private URI saveSubtitleFileToWorkspace(WebVTTSubtitle webVTTSubtitle, String fileName)
242           throws WorkflowOperationException {
243     try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
244       WebVTTWriter writer = new WebVTTWriter();
245       writer.write(webVTTSubtitle, outputStream);
246       try (ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray())) {
247         return workspace.putInCollection(COLLECTION, fileName, inputStream);
248       }
249     } catch (IOException e) {
250       logger.error("An exception occurred while parsing and saving a subtitle file to the workspace", e);
251       throw new WorkflowOperationException(e);
252     }
253   }
254 
255   /**
256    * Loads a subtitle file from the workspace and parses it into a WebVTTSubtitle Object
257    *
258    * @param subtitleTrack The track we want to load
259    * @return The parsed webVTTSubtitle object
260    * @throws WorkflowOperationException when something went wrong in the parsing and loading process
261    */
262   private WebVTTSubtitle loadAndParseSubtitleFile(Track subtitleTrack) throws WorkflowOperationException {
263     // Get the subtitle file from workspace
264     File subtitleFile;
265     try {
266       subtitleFile = workspace.get(subtitleTrack.getURI());
267     } catch (IOException ex) {
268       throw new WorkflowOperationException("Can't read " + subtitleTrack.getURI());
269     } catch (NotFoundException ex) {
270       throw new WorkflowOperationException("Workspace does not contain a track " + subtitleTrack.getURI());
271     }
272 
273     // Next try to parse the file into a WebVTT Object
274     WebVTTSubtitle subtitle;
275     try (FileInputStream fin = new FileInputStream(subtitleFile)) {
276       subtitle = new WebVTTParser().parse(fin);
277     } catch (Exception e) {
278       throw new WorkflowOperationException("Couldn't parse subtitle file " + subtitleTrack.getURI(), e);
279     }
280 
281     return subtitle;
282   }
283 
284   /**
285    * Shifts all timestamps of a subtitle file by a given time.
286    *
287    * @param time Time in milliseconds by which all timestamps shall be shifted.
288    */
289   public void shiftTime(WebVTTSubtitle subtitleFile, long time) {
290     for (WebVTTSubtitleCue cue : subtitleFile.getCues()) {
291       long start = cue.getStartTime();
292       long end = cue.getEndTime();
293       cue.setStartTime(start + time);
294       cue.setEndTime(end + time);
295     }
296   }
297 
298 }