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.composer;
23  
24  import org.opencastproject.composer.api.ComposerService;
25  import org.opencastproject.composer.api.EncoderException;
26  import org.opencastproject.composer.api.EncodingProfile;
27  import org.opencastproject.job.api.Job;
28  import org.opencastproject.job.api.JobContext;
29  import org.opencastproject.mediapackage.MediaPackage;
30  import org.opencastproject.mediapackage.MediaPackageElementFlavor;
31  import org.opencastproject.mediapackage.MediaPackageElementParser;
32  import org.opencastproject.mediapackage.MediaPackageException;
33  import org.opencastproject.mediapackage.Track;
34  import org.opencastproject.mediapackage.selector.TrackSelector;
35  import org.opencastproject.serviceregistry.api.ServiceRegistry;
36  import org.opencastproject.util.NotFoundException;
37  import org.opencastproject.workflow.api.AbstractWorkflowOperationHandler;
38  import org.opencastproject.workflow.api.ConfiguredTagsAndFlavors;
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.lang3.StringUtils;
48  import org.osgi.service.component.annotations.Component;
49  import org.osgi.service.component.annotations.Reference;
50  import org.slf4j.Logger;
51  import org.slf4j.LoggerFactory;
52  
53  import java.io.IOException;
54  import java.util.Collection;
55  
56  /**
57   * The <code>prepare media</code> operation will make sure that media where audio and video track come in separate files
58   * will be muxed prior to further processing.
59   */
60  @Component(
61      immediate = true,
62      service = WorkflowOperationHandler.class,
63      property = {
64          "service.description=Prepare Media Workflow Operation Handler",
65          "workflow.operation=prepare-av"
66      }
67  )
68  public class PrepareAVWorkflowOperationHandler extends AbstractWorkflowOperationHandler {
69  
70    /** The logging facility */
71    private static final Logger logger = LoggerFactory.getLogger(PrepareAVWorkflowOperationHandler.class);
72    private static final String QUESTION_MARK = "?";
73  
74    /** Name of the 'encode to a/v prepared copy' encoding profile */
75    public static final String PREPARE_AV_PROFILE = "av.copy";
76  
77    /** Name of the muxing encoding profile */
78    public static final String MUX_AV_PROFILE = "mux-av.copy";
79  
80    /** Name of the 'encode to audio only prepared copy' encoding profile */
81    public static final String PREPARE_AONLY_PROFILE = "audio-only.copy";
82  
83    /** Name of the 'encode to video only prepared copy' encoding profile */
84    public static final String PREPARE_VONLY_PROFILE = "video-only.copy";
85  
86    /** Name of the 'rewrite' configuration key */
87    public static final String OPT_REWRITE = "rewrite";
88  
89    /** Name of audio muxing configuration key */
90    public static final String OPT_AUDIO_MUXING_SOURCE_FLAVORS = "audio-muxing-source-flavors";
91  
92    /** The composer service */
93    private ComposerService composerService = null;
94  
95    /** The local workspace */
96    private Workspace workspace = null;
97  
98    /**
99     * Callback for the OSGi declarative services configuration.
100    *
101    * @param composerService
102    *          the local composer service
103    */
104   @Reference
105   protected void setComposerService(ComposerService composerService) {
106     this.composerService = composerService;
107   }
108 
109   /**
110    * Callback for declarative services configuration that will introduce us to the local workspace service.
111    * Implementation assumes that the reference is configured as being static.
112    *
113    * @param workspace
114    *          an instance of the workspace
115    */
116   @Reference
117   public void setWorkspace(Workspace workspace) {
118     this.workspace = workspace;
119   }
120 
121   /**
122    * {@inheritDoc}
123    *
124    * @see org.opencastproject.workflow.api.WorkflowOperationHandler#start(org.opencastproject.workflow.api.WorkflowInstance,
125    *      JobContext)
126    */
127   public WorkflowOperationResult start(final WorkflowInstance workflowInstance, JobContext context)
128           throws WorkflowOperationException {
129     try {
130       logger.debug("Running a/v muxing workflow operation on workflow {}", workflowInstance.getId());
131       return mux(workflowInstance);
132     } catch (Exception e) {
133       throw new WorkflowOperationException(e);
134     }
135   }
136 
137   /**
138    * Merges audio and video track of the selected flavor and adds it to the media package. If there is nothing to mux, a
139    * new track with the target flavor is created (pointing to the original url).
140    *
141    * @param wi
142    *          the mux workflow instance
143    * @return the operation result containing the updated mediapackage
144    * @throws EncoderException
145    *           if encoding fails
146    * @throws IOException
147    *           if read/write operations from and to the workspace fail
148    * @throws NotFoundException
149    *           if the workspace does not contain the requested element
150    */
151   private WorkflowOperationResult mux(WorkflowInstance wi) throws EncoderException,
152           WorkflowOperationException, NotFoundException, MediaPackageException, IOException {
153     MediaPackage src = wi.getMediaPackage();
154     MediaPackage mediaPackage = (MediaPackage) src.clone();
155     WorkflowOperationInstance operation = wi.getCurrentOperation();
156 
157     // Check which tags have been configured
158     ConfiguredTagsAndFlavors tagsAndFlavors = getTagsAndFlavors(wi,
159         Configuration.none, Configuration.one, Configuration.many, Configuration.one);
160 
161     // Read the configuration properties
162     MediaPackageElementFlavor sourceFlavor = tagsAndFlavors.getSingleSrcFlavor();
163     ConfiguredTagsAndFlavors.TargetTags targetTrackTags = tagsAndFlavors.getTargetTags();
164     MediaPackageElementFlavor targetFlavor = tagsAndFlavors.getSingleTargetFlavor();
165     String muxEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("mux-encoding-profile"));
166     String audioVideoEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("audio-video-encoding-profile"));
167     String videoOnlyEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("video-encoding-profile"));
168     String audioOnlyEncodingProfileName = StringUtils.trimToNull(operation.getConfiguration("audio-encoding-profile"));
169 
170     // Reencode when there is no need for muxing?
171     boolean rewrite = true;
172     if (StringUtils.trimToNull(operation.getConfiguration(OPT_REWRITE)) != null) {
173       rewrite = Boolean.parseBoolean(operation.getConfiguration(OPT_REWRITE));
174     }
175 
176     String audioMuxingSourceFlavors = StringUtils.trimToNull(operation.getConfiguration(OPT_AUDIO_MUXING_SOURCE_FLAVORS));
177 
178     // Select those tracks that have matching flavors
179     TrackSelector trackSelector = new TrackSelector();
180     trackSelector.addFlavor(sourceFlavor);
181     Collection<Track> tracks = trackSelector.select(mediaPackage, false);
182 
183     Track audioTrack = null;
184     Track videoTrack = null;
185 
186     switch (tracks.size()) {
187       case 0:
188         logger.info("No audio/video tracks with flavor '{}' found to prepare", sourceFlavor);
189         return createResult(mediaPackage, Action.CONTINUE);
190       case 1:
191         videoTrack = tracks.iterator().next();
192         if (!videoTrack.hasAudio() && videoTrack.hasVideo() && (audioMuxingSourceFlavors != null)) {
193           audioTrack = findAudioTrack(videoTrack, mediaPackage, audioMuxingSourceFlavors);
194         } else {
195           audioTrack = videoTrack;
196         }
197         break;
198       case 2:
199         for (Track track : tracks) {
200           if (track.hasAudio() && !track.hasVideo()) {
201             audioTrack = track;
202           } else if (!track.hasAudio() && track.hasVideo()) {
203             videoTrack = track;
204           } else {
205             throw new WorkflowOperationException("Multiple tracks with competing audio/video streams and flavor '"
206                     + sourceFlavor + "' found");
207           }
208         }
209         break;
210       default:
211         logger.error("More than two tracks with flavor {} found. No idea what we should be doing", sourceFlavor);
212         throw new WorkflowOperationException("More than two tracks with flavor '" + sourceFlavor + "' found");
213     }
214 
215     Job job = null;
216     Track composedTrack = null;
217 
218     // Make sure we have a matching combination
219     if (audioTrack == null && videoTrack != null) {
220       if (rewrite) {
221         logger.info("Encoding video only track {} to prepared version", videoTrack);
222         if (videoOnlyEncodingProfileName == null)
223           videoOnlyEncodingProfileName = PREPARE_VONLY_PROFILE;
224         // Find the encoding profile to make sure the given profile exists
225         EncodingProfile profile = composerService.getProfile(videoOnlyEncodingProfileName);
226         if (profile == null)
227         throw new IllegalStateException("Encoding profile '" + videoOnlyEncodingProfileName + "' was not found");
228         composedTrack = prepare(videoTrack, mediaPackage, videoOnlyEncodingProfileName);
229       } else {
230         composedTrack = (Track) videoTrack.clone();
231         composedTrack.setIdentifier(null);
232         mediaPackage.add(composedTrack);
233       }
234     } else if (videoTrack == null && audioTrack != null) {
235       if (rewrite) {
236         logger.info("Encoding audio only track {} to prepared version", audioTrack);
237         if (audioOnlyEncodingProfileName == null)
238           audioOnlyEncodingProfileName = PREPARE_AONLY_PROFILE;
239         // Find the encoding profile to make sure the given profile exists
240         EncodingProfile profile = composerService.getProfile(audioOnlyEncodingProfileName);
241         if (profile == null)
242         throw new IllegalStateException("Encoding profile '" + audioOnlyEncodingProfileName + "' was not found");
243         composedTrack = prepare(audioTrack, mediaPackage, audioOnlyEncodingProfileName);
244       } else {
245         composedTrack = (Track) audioTrack.clone();
246         composedTrack.setIdentifier(null);
247         mediaPackage.add(composedTrack);
248       }
249     } else if (audioTrack == videoTrack) {
250       if (rewrite) {
251         logger.info("Encoding audiovisual track {} to prepared version", videoTrack);
252         if (audioVideoEncodingProfileName == null)
253           audioVideoEncodingProfileName = PREPARE_AV_PROFILE;
254         // Find the encoding profile to make sure the given profile exists
255         EncodingProfile profile = composerService.getProfile(audioVideoEncodingProfileName);
256         if (profile == null)
257         throw new IllegalStateException("Encoding profile '" + audioVideoEncodingProfileName + "' was not found");
258         composedTrack = prepare(videoTrack, mediaPackage, audioVideoEncodingProfileName);
259       } else {
260         composedTrack = (Track) videoTrack.clone();
261         composedTrack.setIdentifier(null);
262         mediaPackage.add(composedTrack);
263       }
264     } else {
265       logger.info("Muxing audio and video only track {} to prepared version", videoTrack);
266 
267       if (audioTrack.hasVideo()) {
268         logger.info("Stripping video from track {}", audioTrack);
269         audioTrack = prepare(audioTrack, null, PREPARE_AONLY_PROFILE);
270       }
271 
272       if (muxEncodingProfileName == null)
273         muxEncodingProfileName = MUX_AV_PROFILE;
274 
275       // Find the encoding profile
276       EncodingProfile profile = composerService.getProfile(muxEncodingProfileName);
277       if (profile == null)
278       throw new IllegalStateException("Encoding profile '" + muxEncodingProfileName + "' was not found");
279 
280       job = composerService.mux(videoTrack, audioTrack, profile.getIdentifier());
281       if (!waitForStatus(job).isSuccess()) {
282         throw new WorkflowOperationException("Muxing video track " + videoTrack + " and audio track " + audioTrack
283                 + " failed");
284       }
285       composedTrack = (Track) MediaPackageElementParser.getFromXml(job.getPayload());
286       mediaPackage.add(composedTrack);
287       String fileName = getFileNameFromElements(videoTrack, composedTrack);
288       composedTrack.setURI(workspace.moveTo(composedTrack.getURI(), mediaPackage.getIdentifier().toString(),
289               composedTrack.getIdentifier(), fileName));
290     }
291 
292     long timeInQueue = 0;
293     if (job != null) {
294       // add this receipt's queue time to the total
295       timeInQueue = job.getQueueTime();
296     }
297 
298     // Update the track's flavor
299     composedTrack.setFlavor(targetFlavor);
300     logger.debug("Composed track has flavor '{}'", composedTrack.getFlavor());
301 
302     // Update the track's tags
303     applyTargetTagsToElement(targetTrackTags, composedTrack);
304 
305     return createResult(mediaPackage, Action.CONTINUE, timeInQueue);
306   }
307 
308   /**
309    * Prepares a video track. If the mediapackage is specified, the prepared track will be added to it.
310    *
311    * @param videoTrack
312    *          the track containing the video
313    * @param mediaPackage
314    *          the mediapackage
315    * @return the rewritten track
316    * @throws WorkflowOperationException
317    * @throws NotFoundException
318    * @throws IOException
319    * @throws EncoderException
320    * @throws MediaPackageException
321    */
322   private Track prepare(Track videoTrack, MediaPackage mediaPackage, String encodingProfile)
323           throws WorkflowOperationException, NotFoundException, IOException, EncoderException, MediaPackageException {
324     Track composedTrack = null;
325     logger.info("Encoding video only track {} to prepared version", videoTrack);
326     Job job = composerService.encode(videoTrack, encodingProfile);
327     if (!waitForStatus(job).isSuccess()) {
328       throw new WorkflowOperationException("Rewriting container for video track " + videoTrack + " failed");
329     }
330     composedTrack = (Track) MediaPackageElementParser.getFromXml(job.getPayload());
331     if (mediaPackage != null) {
332       mediaPackage.add(composedTrack);
333       String fileName = getFileNameFromElements(videoTrack, composedTrack);
334 
335       // Note that the composed track must have an ID before being moved to the mediapackage in the working file
336       // repository. This ID is generated when the track is added to the mediapackage. So the track must be added
337       // to the mediapackage before attempting to move the file.
338       composedTrack.setURI(workspace.moveTo(composedTrack.getURI(), mediaPackage.getIdentifier().toString(),
339               composedTrack.getIdentifier(), fileName));
340     }
341     return composedTrack;
342   }
343 
344   /**
345    * Finds a suitable audio track from the mediapackage by scanning a source flavor sequence
346    *
347    * @param videoTrack
348    *          the video track
349    * @param mediaPackage
350    *          the mediapackage
351    * @param audioMuxingSourceFlavors
352    *          sequence of source flavors where an audio track should be searched for
353    * @return the found audio track
354    */
355   private Track findAudioTrack(Track videoTrack, MediaPackage mediaPackage, String audioMuxingSourceFlavors) {
356 
357     if (audioMuxingSourceFlavors != null) {
358       String type;
359       String subtype;
360       for (String flavorStr : audioMuxingSourceFlavors.split("[\\s,]")) {
361         if (!flavorStr.isEmpty()) {
362           MediaPackageElementFlavor flavor = null;
363           try {
364             flavor = MediaPackageElementFlavor.parseFlavor(flavorStr);
365           } catch (IllegalArgumentException e) {
366             logger.error("The parameter {} contains an invalid flavor: {}", OPT_AUDIO_MUXING_SOURCE_FLAVORS, flavorStr);
367             throw e;
368           }
369           type = (QUESTION_MARK.equals(flavor.getType())) ? videoTrack.getFlavor().getType() : flavor.getType();
370           subtype = (QUESTION_MARK.equals(flavor.getSubtype())) ? videoTrack.getFlavor().getSubtype() : flavor.getSubtype();
371           // Recreate the (possibly) modified flavor
372           flavor = new MediaPackageElementFlavor(type, subtype);
373           for (Track track : mediaPackage.getTracks(flavor)) {
374             if (track.hasAudio()) {
375               logger.info("Audio muxing found audio source {} with flavor {}", track, track.getFlavor());
376               return track;
377             }
378           }
379         }
380       }
381     }
382     return null;
383   }
384 
385   @Reference
386   @Override
387   public void setServiceRegistry(ServiceRegistry serviceRegistry) {
388     super.setServiceRegistry(serviceRegistry);
389   }
390 
391 }