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.composer.impl.endpoint;
23  
24  import static org.opencastproject.util.doc.rest.RestParameter.Type.TEXT;
25  
26  import org.opencastproject.composer.api.ComposerService;
27  import org.opencastproject.composer.api.EncoderException;
28  import org.opencastproject.composer.api.EncodingProfile;
29  import org.opencastproject.composer.api.EncodingProfileImpl;
30  import org.opencastproject.composer.api.EncodingProfileList;
31  import org.opencastproject.composer.api.LaidOutElement;
32  import org.opencastproject.composer.layout.Dimension;
33  import org.opencastproject.composer.layout.Layout;
34  import org.opencastproject.composer.layout.Serializer;
35  import org.opencastproject.job.api.JaxbJob;
36  import org.opencastproject.job.api.Job;
37  import org.opencastproject.job.api.JobProducer;
38  import org.opencastproject.mediapackage.Attachment;
39  import org.opencastproject.mediapackage.MediaPackageElement;
40  import org.opencastproject.mediapackage.MediaPackageElementParser;
41  import org.opencastproject.mediapackage.Track;
42  import org.opencastproject.rest.AbstractJobProducerEndpoint;
43  import org.opencastproject.serviceregistry.api.ServiceRegistry;
44  import org.opencastproject.smil.api.SmilService;
45  import org.opencastproject.smil.entity.api.Smil;
46  import org.opencastproject.util.JsonObj;
47  import org.opencastproject.util.LocalHashMap;
48  import org.opencastproject.util.NotFoundException;
49  import org.opencastproject.util.UrlSupport;
50  import org.opencastproject.util.doc.rest.RestParameter;
51  import org.opencastproject.util.doc.rest.RestParameter.Type;
52  import org.opencastproject.util.doc.rest.RestQuery;
53  import org.opencastproject.util.doc.rest.RestResponse;
54  import org.opencastproject.util.doc.rest.RestService;
55  
56  import org.apache.commons.lang3.StringUtils;
57  import org.apache.commons.lang3.math.NumberUtils;
58  import org.osgi.service.component.ComponentContext;
59  import org.osgi.service.component.annotations.Component;
60  import org.osgi.service.component.annotations.Reference;
61  import org.osgi.service.jaxrs.whiteboard.propertytypes.JaxrsResource;
62  import org.slf4j.Logger;
63  import org.slf4j.LoggerFactory;
64  
65  import java.util.ArrayList;
66  import java.util.Arrays;
67  import java.util.HashMap;
68  import java.util.LinkedList;
69  import java.util.List;
70  import java.util.Map;
71  import java.util.Optional;
72  
73  import javax.servlet.http.HttpServletResponse;
74  import javax.ws.rs.DefaultValue;
75  import javax.ws.rs.FormParam;
76  import javax.ws.rs.GET;
77  import javax.ws.rs.POST;
78  import javax.ws.rs.Path;
79  import javax.ws.rs.PathParam;
80  import javax.ws.rs.Produces;
81  import javax.ws.rs.core.MediaType;
82  import javax.ws.rs.core.Response;
83  
84  /**
85   * A REST endpoint delegating functionality to the {@link ComposerService}
86   */
87  @Path("/composer/ffmpeg")
88  @RestService(
89      name = "composer",
90      title = "Composer",
91      abstractText = "This service creates and augments Opencast media packages that include media tracks, metadata "
92          + "catalogs and attachments.", notes = {
93          "All paths above are relative to the REST endpoint base (something like http://your.server/files)",
94          "If the service is down or not working it will return a status 503, this means the the underlying service is "
95              + "not working and is either restarting or has failed",
96          "A status code 500 means a general failure has occurred which is not recoverable and was not anticipated. In "
97              + "other words, there is a bug! You should file an error report with your server logs from the time when "
98              + "the error occurred: "
99              + "<a href=\"https://github.com/opencast/opencast/issues\">Opencast Issue Tracker</a>" })
100 @Component(
101     property = {
102         "service.description=Composer REST Endpoint",
103         "opencast.service.type=org.opencastproject.composer",
104         "opencast.service.path=/composer/ffmpeg",
105         "opencast.service.jobproducer=true"
106     },
107     immediate = true,
108     service = ComposerRestService.class
109 )
110 @JaxrsResource
111 public class ComposerRestService extends AbstractJobProducerEndpoint {
112 
113   /** The logger */
114   private static final Logger logger = LoggerFactory.getLogger(ComposerRestService.class);
115 
116   private static final String VIDEO_TRACK_DEFAULT = "<track id=\"track-1\" type=\"presentation/source\">\n"
117           + "  <mimetype>video/quicktime</mimetype>\n"
118           + "  <url>http://localhost:8080/workflow/samples/camera.mpg</url>\n"
119           + "  <checksum type=\"md5\">43b7d843b02c4a429b2f547a4f230d31</checksum>\n"
120           + "  <duration>14546</duration>\n" + "  <video>\n"
121           + "    <device type=\"UFG03\" version=\"30112007\" vendor=\"Unigraf\" />\n"
122           + "    <encoder type=\"H.264\" version=\"7.4\" vendor=\"Apple Inc\" />\n"
123           + "    <resolution>640x480</resolution>\n"
124           + "    <scanType type=\"progressive\" />\n"
125           + "    <bitrate>540520</bitrate>\n"
126           + "    <frameRate>2</frameRate>\n"
127           + "  </video>\n"
128           + "</track>";
129 
130   private static final String AUDIO_TRACK_DEFAULT = "<track id=\"track-2\" type=\"presentation/source\">\n"
131           + "  <mimetype>audio/mp3</mimetype>\n"
132           + "  <url>serverUrl/workflow/samples/audio.mp3</url>\n"
133           + "  <checksum type=\"md5\">950f9fa49caa8f1c5bbc36892f6fd062</checksum>\n"
134           + "  <duration>10472</duration>\n"
135           + "  <audio>\n"
136           + "    <channels>2</channels>\n"
137           + "    <bitdepth>0</bitdepth>\n"
138           + "    <bitrate>128004.0</bitrate>\n"
139           + "    <samplingrate>44100</samplingrate>\n"
140           + "  </audio>\n"
141           + "</track>";
142 
143   private static final String IMAGE_ATTACHMENT_DEFAULT = "<attachment id=\"track-3\">\n"
144           + "  <mimetype>image/jpeg</mimetype>\n"
145           + "  <url>serverUrl/workflow/samples/image.jpg</url>\n"
146           + "</attachment>";
147 
148   /** The base server URL */
149   protected String serverUrl;
150 
151   /** The composer service */
152   protected ComposerService composerService = null;
153 
154   /** The service registry */
155   protected ServiceRegistry serviceRegistry = null;
156 
157   /** The smil service */
158   protected SmilService smilService = null;
159 
160   @Reference
161   public void setSmilService(SmilService smilService) {
162     this.smilService = smilService;
163   }
164 
165   /**
166    * Callback from the OSGi declarative services to set the service registry.
167    *
168    * @param serviceRegistry
169    *          the service registry
170    */
171   @Reference
172   protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
173     this.serviceRegistry = serviceRegistry;
174   }
175 
176   /**
177    * Sets the composer service.
178    *
179    * @param composerService
180    *          the composer service
181    */
182   @Reference
183   public void setComposerService(ComposerService composerService) {
184     this.composerService = composerService;
185   }
186 
187   /**
188    * Callback from OSGi that is called when this service is activated.
189    *
190    * @param cc
191    *          OSGi component context
192    */
193   public void activate(ComponentContext cc) {
194     if (cc == null || cc.getBundleContext().getProperty("org.opencastproject.server.url") == null) {
195       serverUrl = UrlSupport.DEFAULT_BASE_URL;
196     } else {
197       serverUrl = cc.getBundleContext().getProperty("org.opencastproject.server.url");
198     }
199   }
200 
201   /**
202    * Encodes a track.
203    *
204    * @param sourceTrackAsXml
205    *          The source track
206    * @param profileId
207    *          The profile to use in encoding this track
208    * @return A response containing the job for this encoding job in the response body.
209    * @throws Exception
210    */
211   @POST
212   @Path("encode")
213   @Produces(MediaType.TEXT_XML)
214   @RestQuery(
215       name = "encode",
216       description = "Starts an encoding process, based on the specified encoding profile ID and the track",
217       restParameters = {
218           @RestParameter(description = "The track containing the stream", isRequired = true, name = "sourceTrack",
219               type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
220           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
221               type = Type.STRING, defaultValue = "mp4-medium.http")
222       },
223       responses = {
224           @RestResponse(description = "Results in an xml document containing the job for the encoding task",
225               responseCode = HttpServletResponse.SC_OK),
226           @RestResponse(description = "If required parameters aren't set or if sourceTrack isn't from the type Track",
227               responseCode = HttpServletResponse.SC_BAD_REQUEST)
228       },
229       returnDescription = "")
230   public Response encode(@FormParam("sourceTrack") String sourceTrackAsXml, @FormParam("profileId") String profileId)
231           throws Exception {
232     // Ensure that the POST parameters are present
233     if (StringUtils.isBlank(sourceTrackAsXml) || StringUtils.isBlank(profileId)) {
234       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack and profileId must not be null").build();
235     }
236 
237     // Deserialize the track
238     MediaPackageElement sourceTrack = MediaPackageElementParser.getFromXml(sourceTrackAsXml);
239     if (!Track.TYPE.equals(sourceTrack.getElementType())) {
240       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element must be of type track").build();
241     }
242 
243     try {
244       // Asynchronously encode the specified tracks
245       Job job = composerService.encode((Track) sourceTrack, profileId);
246       return Response.ok().entity(new JaxbJob(job)).build();
247     } catch (EncoderException e) {
248       logger.warn("Unable to encode the track: " + e.getMessage());
249       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
250     }
251   }
252 
253   /**
254    * Encodes a track to multiple tracks in parallel.
255    *
256    * @param sourceTrackAsXml
257    *          The source track
258    * @param profileId
259    *          The profile to use in encoding this track
260    * @return A response containing the job for this encoding job in the response body.
261    * @throws Exception
262    */
263   @POST
264   @Path("parallelencode")
265   @Produces(MediaType.TEXT_XML)
266   @RestQuery(
267       name = "parallelencode",
268       description = "Starts an encoding process, based on the specified encoding profile ID and the track",
269       restParameters = {
270           @RestParameter(description = "The track containing the stream", isRequired = true, name = "sourceTrack",
271               type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
272           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
273               type = Type.STRING, defaultValue = "mp4-medium.http")
274       },
275       responses = {
276           @RestResponse(description = "Results in an xml document containing the job for the encoding task",
277               responseCode = HttpServletResponse.SC_OK)
278       },
279       returnDescription = "")
280   public Response parallelencode(@FormParam("sourceTrack") String sourceTrackAsXml,
281       @FormParam("profileId") String profileId)
282           throws Exception {
283     // Ensure that the POST parameters are present
284     if (sourceTrackAsXml == null || profileId == null) {
285       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack and profileId must not be null").build();
286     }
287 
288     // Deserialize the track
289     MediaPackageElement sourceTrack = MediaPackageElementParser.getFromXml(sourceTrackAsXml);
290     if (!Track.TYPE.equals(sourceTrack.getElementType())) {
291       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element must be of type track").build();
292     }
293 
294     // Asynchronously encode the specified tracks
295     Job job = composerService.parallelEncode((Track) sourceTrack, profileId);
296     if (job == null) {
297       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity("Encoding failed").build();
298     }
299     return Response.ok().entity(new JaxbJob(job)).build();
300   }
301 
302   /**
303    * Trims a track to a new length.
304    *
305    * @param sourceTrackAsXml
306    *          The source track
307    * @param profileId
308    *          the encoding profile to use for trimming
309    * @param start
310    *          the new trimming start time
311    * @param duration
312    *          the new video duration
313    * @return A response containing the job for this encoding job in the response body.
314    * @throws Exception
315    */
316   @POST
317   @Path("trim")
318   @Produces(MediaType.TEXT_XML)
319   @RestQuery(
320       name = "trim",
321       description = "Starts a trimming process, based on the specified track, start time and duration in ms",
322       restParameters = {
323           @RestParameter(description = "The track containing the stream", isRequired = true, name = "sourceTrack",
324               type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
325           @RestParameter(description = "The encoding profile to use for trimming", isRequired = true,
326               name = "profileId", type = Type.STRING, defaultValue = "trim.work"),
327           @RestParameter(description = "The start time in milisecond", isRequired = true, name = "start",
328               type = Type.STRING, defaultValue = "0"),
329           @RestParameter(description = "The duration in milisecond", isRequired = true, name = "duration",
330               type = Type.STRING, defaultValue = "10000")
331       },
332       responses = {
333           @RestResponse(description = "Results in an xml document containing the job for the trimming task",
334               responseCode = HttpServletResponse.SC_OK),
335           @RestResponse(description = "If the start time is negative or exceeds the track duration",
336               responseCode = HttpServletResponse.SC_BAD_REQUEST),
337           @RestResponse(description = "If the duration is negative or, including the new start time, exceeds the track "
338               + "duration", responseCode = HttpServletResponse.SC_BAD_REQUEST)
339       },
340       returnDescription = "")
341   public Response trim(@FormParam("sourceTrack") String sourceTrackAsXml, @FormParam("profileId") String profileId,
342           @FormParam("start") long start, @FormParam("duration") long duration) throws Exception {
343     // Ensure that the POST parameters are present
344     if (StringUtils.isBlank(sourceTrackAsXml) || StringUtils.isBlank(profileId)) {
345       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack and profileId must not be null").build();
346     }
347 
348     // Deserialize the track
349     MediaPackageElement sourceElement = MediaPackageElementParser.getFromXml(sourceTrackAsXml);
350     if (!Track.TYPE.equals(sourceElement.getElementType())) {
351       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element must be of type track").build();
352     }
353 
354     // Make sure the trim times make sense
355     Track sourceTrack = (Track) sourceElement;
356 
357     if (sourceTrack.getDuration() == null) {
358       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element does not have a duration")
359           .build();
360     }
361 
362     if (start < 0) {
363       start = 0;
364     } else if (duration <= 0) {
365       duration = (sourceTrack.getDuration() - start);
366     } else if (start + duration > sourceTrack.getDuration()) {
367       duration = (sourceTrack.getDuration() - start);
368     }
369 
370     try {
371       // Asynchronously encode the specified tracks
372       Job job = composerService.trim(sourceTrack, profileId, start, duration);
373       return Response.ok().entity(new JaxbJob(job)).build();
374     } catch (EncoderException e) {
375       logger.warn("Unable to trim the track: " + e.getMessage());
376       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
377     }
378   }
379 
380   /**
381    * Encodes a track.
382    *
383    * @param audioSourceTrackXml
384    *          The audio source track
385    * @param videoSourceTrackXml
386    *          The video source track
387    * @param profileId
388    *          The profile to use in encoding this track
389    * @return A response containing the job for this encoding job in the response body.
390    * @throws Exception
391    */
392   @POST
393   @Path("mux")
394   @Produces(MediaType.TEXT_XML)
395   @RestQuery(
396       name = "mux",
397       description = "Starts an encoding process, which will mux the two tracks using the given encoding profile",
398       restParameters = {
399           @RestParameter(description = "The track containing the audio stream", isRequired = false,
400               name = "sourceAudioTrack", type = Type.TEXT, defaultValue = AUDIO_TRACK_DEFAULT),
401           @RestParameter(description = "The track containing the video stream", isRequired = false,
402               name = "sourceVideoTrack", type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
403           @RestParameter(description = "The track containing the video stream", isRequired = false,
404               name = "sourceTracks", type = Type.TEXT),
405           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
406               type = Type.STRING, defaultValue = "mp4-medium.http")
407       },
408       responses = {
409           @RestResponse(description = "Results in an xml document containing the job for the encoding task",
410               responseCode = HttpServletResponse.SC_OK),
411           @RestResponse(description = "If required parameters aren't set or if the source tracks aren't from the type "
412               + "Track", responseCode = HttpServletResponse.SC_BAD_REQUEST)
413       },
414       returnDescription = "")
415   public Response mux(@FormParam("audioSourceTrack") String audioSourceTrackXml,
416           @FormParam("videoSourceTrack") String videoSourceTrackXml,
417           @FormParam("sourceTracks") String sourceTracksXml, @FormParam("profileId") String profileId)
418           throws Exception {
419     // Ensure that the POST parameters are present
420     if (StringUtils.isBlank(profileId)) {
421       return Response.status(Response.Status.BAD_REQUEST).entity("profileId must not be null").build();
422     }
423     if ((StringUtils.isBlank(audioSourceTrackXml) || StringUtils.isBlank(videoSourceTrackXml))
424         && StringUtils.isBlank(sourceTracksXml)) {
425       return Response.status(Response.Status.BAD_REQUEST)
426           .entity("audioSourceTrack, videoSourceTrack or sourceTracks must not be null").build();
427     }
428 
429     try {
430       Job job;
431       Map<String, Track> sourceTracks = new HashMap<>();
432       if (StringUtils.isNotBlank(sourceTracksXml)) {
433         for (String sourceTrackEntry : StringUtils.splitByWholeSeparator(sourceTracksXml, "#|#")) {
434           String[] sourceTrackEntryTuple = StringUtils.splitByWholeSeparator(sourceTrackEntry, "#=#", 2);
435           if (sourceTrackEntryTuple.length != 2) {
436             return Response.status(Response.Status.BAD_REQUEST).entity("sourceTracks value invalid").build();
437           }
438           sourceTracks.put(sourceTrackEntryTuple[0],
439               (Track) MediaPackageElementParser.getFromXml(sourceTrackEntryTuple[1]));
440         }
441         // Asynchronously encode the specified tracks
442         job = composerService.mux(sourceTracks, profileId);
443       } else {
444         // Deserialize the audio track
445         MediaPackageElement audioSourceTrack = MediaPackageElementParser.getFromXml(audioSourceTrackXml);
446         if (!Track.TYPE.equals(audioSourceTrack.getElementType())) {
447           return Response.status(Response.Status.BAD_REQUEST).entity("audioSourceTrack must be of type track").build();
448         }
449 
450         // Deserialize the video track
451         MediaPackageElement videoSourceTrack = MediaPackageElementParser.getFromXml(videoSourceTrackXml);
452         if (!Track.TYPE.equals(videoSourceTrack.getElementType())) {
453           return Response.status(Response.Status.BAD_REQUEST).entity("videoSourceTrack must be of type track").build();
454         }
455 
456         job = composerService.mux((Track) videoSourceTrack, (Track) audioSourceTrack, profileId);
457       }
458       return Response.ok().entity(new JaxbJob(job)).build();
459     } catch (EncoderException e) {
460       logger.warn("Unable to mux tracks: " + e.getMessage());
461       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
462     }
463   }
464 
465   /**
466    * Encodes a track in a media package.
467    *
468    * @param sourceTrackXml
469    *          The source track
470    * @param profileId
471    *          The profile to use in encoding this track
472    * @param times
473    *          one or more times in seconds separated by comma
474    * @return A {@link Response} with the resulting track in the response body
475    * @throws Exception
476    */
477   @POST
478   @Path("image")
479   @Produces(MediaType.TEXT_XML)
480   @RestQuery(
481       name = "image",
482       description = "Starts an image extraction process, based on the specified encoding profile ID and the source "
483           + "track",
484       restParameters = {
485           @RestParameter(description = "The track containing the video stream", isRequired = true, name = "sourceTrack",
486               type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
487           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
488               type = Type.STRING, defaultValue = "player-preview.http"),
489           @RestParameter(description = "The number of seconds (many numbers can be specified, separated by semicolon) "
490               + "into the video to extract the image", isRequired = false, name = "time", type = Type.STRING),
491           @RestParameter(description = "An optional set of key=value\\n properties", isRequired = false,
492               name = "properties", type = TEXT)
493       },
494       responses = {
495           @RestResponse(description = "Results in an xml document containing the image attachment",
496               responseCode = HttpServletResponse.SC_OK),
497           @RestResponse(description = "If required parameters aren't set or if sourceTrack isn't from the type Track",
498               responseCode = HttpServletResponse.SC_BAD_REQUEST)
499       },
500       returnDescription = "The image extraction job")
501   public Response image(@FormParam("sourceTrack") String sourceTrackXml, @FormParam("profileId") String profileId,
502           @FormParam("time") String times, @FormParam("properties") LocalHashMap localMap) throws Exception {
503     // Ensure that the POST parameters are present
504     if (StringUtils.isBlank(sourceTrackXml) || StringUtils.isBlank(profileId)) {
505       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack and profileId must not be null").build();
506     }
507 
508     // Deserialize the source track
509     MediaPackageElement sourceTrack = MediaPackageElementParser.getFromXml(sourceTrackXml);
510     if (!Track.TYPE.equals(sourceTrack.getElementType())) {
511       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element must be of type track").build();
512     }
513 
514     boolean timeBased = false;
515     double[] timeArray = null;
516     if (StringUtils.isNotBlank(times)) {
517       // parse time codes
518       try {
519         timeArray = parseTimeArray(times);
520       } catch (Exception e) {
521         return Response.status(Response.Status.BAD_REQUEST).entity("could not parse times: invalid format").build();
522       }
523       timeBased = true;
524     } else if (localMap == null) {
525       return Response.status(Response.Status.BAD_REQUEST).build();
526     }
527 
528     try {
529       // Asynchronously encode the specified tracks
530       Job job;
531       if (timeBased) {
532         job = composerService.image((Track) sourceTrack, profileId, timeArray);
533       } else {
534         job = composerService.image((Track) sourceTrack, profileId, localMap.getMap());
535       }
536       return Response.ok().entity(new JaxbJob(job)).build();
537     } catch (EncoderException e) {
538       logger.warn("Unable to extract image(s): " + e.getMessage());
539       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
540     }
541   }
542 
543   /**
544    * Encodes a track in a media package.
545    *
546    * @param sourceTrackXml
547    *          The source track
548    * @param profileId
549    *          The profile to use in encoding this track
550    * @param times
551    *          one or more times in seconds separated by comma
552    * @return A {@link Response} with the resulting track in the response body
553    * @throws Exception
554    */
555   @POST
556   @Path("imagesync")
557   @Produces(MediaType.TEXT_XML)
558   @RestQuery(
559       name = "imagesync",
560       description = "Synchronously extracts an image, based on the specified encoding profile ID and the source track",
561       restParameters = {
562           @RestParameter(description = "The track containing the video stream", isRequired = true, name = "sourceTrack",
563               type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
564           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
565               type = Type.STRING, defaultValue = "player-preview.http"),
566           @RestParameter(description = "The number of seconds (many numbers can be specified, separated by semicolon) "
567               + "into the video to extract the image", isRequired = false, name = "time", type = Type.STRING)
568       },
569       responses = {
570           @RestResponse(description = "Results in an xml document containing the image attachment",
571               responseCode = HttpServletResponse.SC_OK),
572           @RestResponse(description = "If required parameters aren't set or if sourceTrack isn't from the type Track",
573               responseCode = HttpServletResponse.SC_BAD_REQUEST)
574       }, returnDescription = "The extracted image")
575   public Response imageSync(@FormParam("sourceTrack") String sourceTrackXml, @FormParam("profileId") String profileId,
576                         @FormParam("time") String times) throws Exception {
577     // Ensure that the POST parameters are present
578     if (StringUtils.isBlank(sourceTrackXml) || StringUtils.isBlank(profileId) || StringUtils.isBlank(times)) {
579       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack, times, and profileId must not be null")
580           .build();
581     }
582 
583     // Deserialize the source track
584     MediaPackageElement sourceTrack = MediaPackageElementParser.getFromXml(sourceTrackXml);
585     if (!Track.TYPE.equals(sourceTrack.getElementType())) {
586       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element must be of type track").build();
587     }
588 
589     double[] timeArray = null;
590     // parse time codes
591     try {
592       timeArray = parseTimeArray(times);
593     } catch (Exception e) {
594       return Response.status(Response.Status.BAD_REQUEST).entity("could not parse times: invalid format").build();
595     }
596 
597     try {
598       List<Attachment> result = composerService.imageSync((Track) sourceTrack, profileId, timeArray);
599       return Response.ok().entity(MediaPackageElementParser.getArrayAsXml(result)).build();
600     } catch (EncoderException e) {
601       logger.warn("Unable to extract image(s): " + e.getMessage());
602       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
603     }
604   }
605 
606   /**
607    * Compose two videos into one with an optional watermark.
608    *
609    * @param compositeSizeJson
610    *          The composite track dimension as JSON
611    * @param lowerTrackXml
612    *          The lower track of the composition as XML
613    * @param lowerLayoutJson
614    *          The lower layout as JSON
615    * @param upperTrackXml
616    *          The upper track of the composition as XML
617    * @param upperLayoutJson
618    *          The upper layout as JSON
619    * @param watermarkAttachmentXml
620    *          The watermark image attachment of the composition as XML
621    * @param watermarkLayoutJson
622    *          The watermark layout as JSON
623    * @param profileId
624    *          The encoding profile to use
625    * @param background
626    *          The background color
627    * @return A {@link Response} with the resulting track in the response body
628    * @throws Exception
629    */
630   @POST
631   @Path("composite")
632   @Produces(MediaType.TEXT_XML)
633   @RestQuery(
634       name = "composite",
635       description = "Starts a video compositing process, based on the specified resolution, encoding profile ID, the "
636           + "source elements and their layouts",
637       restParameters = {
638           @RestParameter(description = "The resolution size of the resulting video as JSON", isRequired = true,
639               name = "compositeSize", type = Type.STRING),
640           @RestParameter(description = "The lower source track containing the lower video", isRequired = true,
641               name = "lowerTrack", type = Type.TEXT),
642           @RestParameter(description = "The lower layout containing the JSON definition of the layout",
643               isRequired = true, name = "lowerLayout", type = Type.TEXT),
644           @RestParameter(description = "The upper source track containing the upper video", isRequired = false,
645               name = "upperTrack", type = Type.TEXT),
646           @RestParameter(description = "The upper layout containing the JSON definition of the layout",
647               isRequired = false, name = "upperLayout", type = Type.TEXT),
648           @RestParameter(description = "The watermark source attachment containing watermark image", isRequired = false,
649               name = "watermarkTrack", type = Type.TEXT),
650           @RestParameter(description = "The watermark layout containing the JSON definition of the layout",
651               isRequired = false, name = "watermarkLayout", type = Type.TEXT),
652           @RestParameter(description = "The background color", isRequired = false, name = "background",
653               type = Type.TEXT, defaultValue = "black"),
654           @RestParameter(description = "The name of the audio source (lower or upper or both)", isRequired = false,
655               name = "audioSourceName", type = Type.TEXT, defaultValue = ComposerService.BOTH),
656           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
657               type = Type.STRING)
658       },
659       responses = {
660           @RestResponse(description = "Results in an xml document containing the compound video track",
661               responseCode = HttpServletResponse.SC_OK),
662           @RestResponse(description = "If required parameters aren't set or if the source elements aren't from the "
663               + "right type", responseCode = HttpServletResponse.SC_BAD_REQUEST)
664       },
665       returnDescription = "")
666   public Response composite(@FormParam("compositeSize") String compositeSizeJson,
667           @FormParam("lowerTrack") String lowerTrackXml, @FormParam("lowerLayout") String lowerLayoutJson,
668           @FormParam("upperTrack") String upperTrackXml, @FormParam("upperLayout") String upperLayoutJson,
669           @FormParam("watermarkAttachment") String watermarkAttachmentXml,
670           @FormParam("watermarkLayout") String watermarkLayoutJson, @FormParam("profileId") String profileId,
671           @FormParam("background") @DefaultValue("black") String background,
672           @FormParam("sourceAudioName") @DefaultValue(ComposerService.BOTH) String sourceAudioName) throws Exception {
673     // Ensure that the POST parameters are present
674     if (StringUtils.isBlank(compositeSizeJson) || StringUtils.isBlank(lowerTrackXml)
675             || StringUtils.isBlank(lowerLayoutJson) || StringUtils.isBlank(profileId)) {
676       return Response.status(Response.Status.BAD_REQUEST).entity("One of the required parameters must not be null")
677           .build();
678     }
679 
680     // Deserialize the source elements
681     MediaPackageElement lowerTrack = MediaPackageElementParser.getFromXml(lowerTrackXml);
682     Layout lowerLayout = Serializer.layout(JsonObj.jsonObj(lowerLayoutJson));
683     if (!Track.TYPE.equals(lowerTrack.getElementType())) {
684       return Response.status(Response.Status.BAD_REQUEST).entity("lowerTrack element must be of type track").build();
685     }
686     LaidOutElement<Track> lowerLaidOutElement = new LaidOutElement<Track>((Track) lowerTrack, lowerLayout);
687 
688     Optional<LaidOutElement<Track>> upperLaidOutElement = Optional.<LaidOutElement<Track>> empty();
689     if (StringUtils.isNotBlank(upperTrackXml)) {
690       MediaPackageElement upperTrack = MediaPackageElementParser.getFromXml(upperTrackXml);
691       Layout upperLayout = Serializer.layout(JsonObj.jsonObj(upperLayoutJson));
692       if (!Track.TYPE.equals(upperTrack.getElementType())) {
693         return Response.status(Response.Status.BAD_REQUEST).entity("upperTrack element must be of type track").build();
694       }
695       upperLaidOutElement = Optional.ofNullable(new LaidOutElement<Track>((Track) upperTrack, upperLayout));
696     }
697     Optional<LaidOutElement<Attachment>> watermarkLaidOutElement = Optional.<LaidOutElement<Attachment>> empty();
698     if (StringUtils.isNotBlank(watermarkAttachmentXml)) {
699       Layout watermarkLayout = Serializer.layout(JsonObj.jsonObj(watermarkLayoutJson));
700       MediaPackageElement watermarkAttachment = MediaPackageElementParser.getFromXml(watermarkAttachmentXml);
701       if (!Attachment.TYPE.equals(watermarkAttachment.getElementType())) {
702         return Response.status(Response.Status.BAD_REQUEST).entity("watermarkTrack element must be of type track")
703             .build();
704       }
705       watermarkLaidOutElement = Optional.of(new LaidOutElement<Attachment>((Attachment) watermarkAttachment,
706               watermarkLayout));
707     }
708 
709     Dimension compositeTrackSize = Serializer.dimension(JsonObj.jsonObj(compositeSizeJson));
710 
711     try {
712       // Asynchronously composite the specified source elements
713       Job job = composerService.composite(compositeTrackSize, upperLaidOutElement, lowerLaidOutElement,
714               watermarkLaidOutElement, profileId, background, sourceAudioName);
715       return Response.ok().entity(new JaxbJob(job)).build();
716     } catch (EncoderException e) {
717       logger.warn("Unable to composite video: " + e.getMessage());
718       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
719     }
720   }
721 
722   /**
723    * Concat multiple tracks having the same codec to a single track.
724    *
725    * @param sourceTracksXml
726    *          an array of track to concat in order of the array as XML
727    * @param profileId
728    *          The encoding profile to use
729    * @param outputDimension
730    *          The output dimension as JSON
731    * @return A {@link Response} with the resulting track in the response body
732    * @throws Exception
733    */
734   @POST
735   @Path("concat")
736   @Produces(MediaType.TEXT_XML)
737   @RestQuery(
738       name = "concat",
739       description = "Starts a video concating process from multiple videos, based on the specified encoding profile "
740           + "ID and the source tracks",
741       restParameters = {
742           @RestParameter(description = "The source tracks to concat as XML", isRequired = true, name = "sourceTracks",
743               type = Type.TEXT),
744           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
745               type = Type.STRING),
746           @RestParameter(description = "The resolution dimension of the concat video as JSON", isRequired = false,
747               name = "outputDimension", type = Type.STRING),
748           @RestParameter(description = "The  frame rate of the concat video (should be positive, e.g. 25.0). Negative "
749               + "values and zero will cause no FFmpeg fps filter to be used in the filter chain.",
750               isRequired = false, name = "outputFrameRate", type = Type.STRING),
751           @RestParameter(description = "The source files have the same codecs and should not be re-encoded",
752               isRequired = false, name = "sameCodec",type = Type.TEXT, defaultValue = "false")
753       },
754       responses = {
755           @RestResponse(description = "Results in an xml document containing the video track",
756               responseCode = HttpServletResponse.SC_OK),
757           @RestResponse(description = "If required parameters aren't set or if sourceTracks aren't from the type Track "
758               + "or not at least two tracks are present", responseCode = HttpServletResponse.SC_BAD_REQUEST)
759       },
760       returnDescription = "")
761   public Response concat(@FormParam("sourceTracks") String sourceTracksXml, @FormParam("profileId") String profileId,
762           @FormParam("outputDimension") String outputDimension, @FormParam("outputFrameRate") String outputFrameRate,
763           @FormParam("sameCodec") String sameCodec) throws Exception {
764     // Ensure that the POST parameters are present
765     if (StringUtils.isBlank(sourceTracksXml) || StringUtils.isBlank(profileId)) {
766       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTracks and profileId must not be null").build();
767     }
768 
769     // Deserialize the source track
770     List<? extends MediaPackageElement> tracks = MediaPackageElementParser.getArrayFromXml(sourceTracksXml);
771     if (tracks.size() < 2) {
772       return Response.status(Response.Status.BAD_REQUEST).entity("At least two tracks must be set to concat").build();
773     }
774 
775     for (MediaPackageElement elem : tracks) {
776       if (!Track.TYPE.equals(elem.getElementType())) {
777         return Response.status(Response.Status.BAD_REQUEST).entity("sourceTracks must be of type track").build();
778       }
779     }
780     float fps = NumberUtils.toFloat(outputFrameRate, -1.0f);
781     try {
782       // Asynchronously concat the specified tracks together
783       Dimension dimension = null;
784       if (StringUtils.isNotBlank(outputDimension)) {
785         dimension = Serializer.dimension(JsonObj.jsonObj(outputDimension));
786       }
787       boolean hasSameCodec = Boolean.parseBoolean(sameCodec);
788       Job job = null;
789       if (fps > 0) {
790         job = composerService.concat(profileId, dimension, fps, hasSameCodec, tracks.toArray(new Track[tracks.size()]));
791       } else {
792         job = composerService.concat(profileId, dimension, hasSameCodec, tracks.toArray(new Track[tracks.size()]));
793       }
794       return Response.ok().entity(new JaxbJob(job)).build();
795     } catch (EncoderException e) {
796       logger.warn("Unable to concat videos: " + e.getMessage());
797       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
798     }
799   }
800 
801   /**
802    * Transforms an image attachment to a video track
803    *
804    * @param sourceAttachmentXml
805    *          The source image attachment
806    * @param profileId
807    *          The profile to use for encoding
808    * @param timeString
809    *          the length of the resulting video track in seconds
810    * @return A {@link Response} with the resulting track in the response body
811    * @throws Exception
812    */
813   @POST
814   @Path("imagetovideo")
815   @Produces(MediaType.TEXT_XML)
816   @RestQuery(
817       name = "imagetovideo",
818       description = "Starts an image converting process to a video, based on the specified encoding profile ID and the "
819           + "source image attachment",
820       restParameters = {
821           @RestParameter(description = "The resulting video time in seconds", isRequired = false, name = "time",
822               type = Type.STRING, defaultValue = "1"),
823           @RestParameter(description = "The attachment containing the image to convert", isRequired = true,
824               name = "sourceAttachment", type = Type.TEXT),
825           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
826               type = Type.STRING)
827       },
828       responses = {
829           @RestResponse(description = "Results in an xml document containing the video track",
830               responseCode = HttpServletResponse.SC_OK),
831           @RestResponse(description = "If required parameters aren't set or if sourceAttachment isn't from the type "
832               + "Attachment", responseCode = HttpServletResponse.SC_BAD_REQUEST)
833       },
834       returnDescription = "")
835   public Response imageToVideo(@FormParam("sourceAttachment") String sourceAttachmentXml,
836           @FormParam("profileId") String profileId, @FormParam("time") @DefaultValue("1") String timeString)
837           throws Exception {
838     // Ensure that the POST parameters are present
839     if (StringUtils.isBlank(sourceAttachmentXml) || StringUtils.isBlank(profileId)) {
840       return Response.status(Response.Status.BAD_REQUEST).entity("sourceAttachment and profileId must not be null")
841           .build();
842     }
843 
844     // parse time
845     Double time;
846     try {
847       time = Double.parseDouble(timeString);
848     } catch (Exception e) {
849       logger.info("Unable to parse time {} as long value!", timeString);
850       return Response.status(Response.Status.BAD_REQUEST).entity("Could not parse time: invalid format").build();
851     }
852 
853     // Deserialize the source track
854     MediaPackageElement sourceAttachment = MediaPackageElementParser.getFromXml(sourceAttachmentXml);
855     if (!Attachment.TYPE.equals(sourceAttachment.getElementType())) {
856       return Response.status(Response.Status.BAD_REQUEST).entity("sourceAttachment element must be of type attachment")
857           .build();
858     }
859 
860     try {
861       // Asynchronously convert the specified attachment to a video
862       Job job = composerService.imageToVideo((Attachment) sourceAttachment, profileId, time);
863       return Response.ok().entity(new JaxbJob(job)).build();
864     } catch (EncoderException e) {
865       logger.warn("Unable to convert image to video: " + e.getMessage());
866       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
867     }
868   }
869 
870   /**
871    * Converts an image to another format.
872    *
873    * @param sourceImageXml
874    *          The source image
875    * @param profileId
876    *          The profile to use in image conversion
877    * @return A {@link Response} with the resulting image in the response body
878    * @throws Exception
879    */
880   @POST
881   @Path("convertimage")
882   @Produces(MediaType.TEXT_XML)
883   @RestQuery(
884       name = "convertimage",
885       description = "Starts an image conversion process, based on the specified encoding profile ID and the source "
886           + "image",
887       restParameters = {
888           @RestParameter(description = "The original image", isRequired = true, name = "sourceImage", type = Type.TEXT,
889               defaultValue = IMAGE_ATTACHMENT_DEFAULT),
890           @RestParameter(description = "A comma separated list of encoding profiles to use", isRequired = true,
891               name = "profileId", type = Type.STRING, defaultValue = "image-conversion.http")
892       },
893       responses = {
894           @RestResponse(description = "Results in an xml document containing the image attachment",
895               responseCode = HttpServletResponse.SC_OK),
896           @RestResponse(description = "If required parameters aren't set or if sourceImage isn't from the type "
897               + "Attachment", responseCode = HttpServletResponse.SC_BAD_REQUEST)
898       },
899       returnDescription = "")
900   public Response convertImage(@FormParam("sourceImage") String sourceImageXml,
901       @FormParam("profileId") String profileId)
902           throws Exception {
903     // Ensure that the POST parameters are present
904     if (StringUtils.isBlank(sourceImageXml) || StringUtils.isBlank(profileId)) {
905       return Response.status(Response.Status.BAD_REQUEST).entity("sourceImage and profileId must not be null").build();
906     }
907 
908     // Deserialize the source track
909     MediaPackageElement sourceImage = MediaPackageElementParser.getFromXml(sourceImageXml);
910     if (!Attachment.TYPE.equals(sourceImage.getElementType())) {
911       return Response.status(Response.Status.BAD_REQUEST).entity("sourceImage element must be of type track").build();
912     }
913 
914     try {
915       // Asynchronously convert the specified image
916       Job job = composerService.convertImage((Attachment) sourceImage, StringUtils.split(profileId, ','));
917       return Response.ok().entity(new JaxbJob(job)).build();
918     } catch (EncoderException e) {
919       logger.warn("Unable to convert image: " + e.getMessage());
920       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
921     }
922   }
923 
924   /**
925    * Demuxes a track into multiple outputs
926    *
927    * @param sourceTrackAsXml
928    *          The source track
929    * @param profileId
930    *          The profile to use in encoding this track
931    * @return A response containing the job for this encoding job in the response body.
932    * @throws Exception
933    *           - if it fails
934    */
935   @POST
936   @Path("demux")
937   @Produces(MediaType.TEXT_XML)
938   @RestQuery(
939       name = "demux",
940       description = "Starts an demux process that produces multiple outputs, based on the specified encoding profile "
941           + "ID and the track",
942       restParameters = {
943           @RestParameter(description = "The track containing the stream", isRequired = true, name = "sourceTrack",
944               type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
945           @RestParameter(description = "The encoding profile to use", isRequired = true, name = "profileId",
946               type = Type.STRING, defaultValue = "demux.work")
947       },
948       responses = {
949           @RestResponse(description = "Results in an xml document containing the job for the encoding task",
950               responseCode = HttpServletResponse.SC_OK),
951           @RestResponse(description = "If required parameters aren't set or if sourceTrack isn't from the type Track",
952               responseCode = HttpServletResponse.SC_BAD_REQUEST)
953       },
954       returnDescription = "")
955   public Response demux(@FormParam("sourceTrack") String sourceTrackAsXml, @FormParam("profileId") String profileId)
956           throws Exception {
957     // Ensure that the POST parameters are present
958     if (StringUtils.isBlank(sourceTrackAsXml) || StringUtils.isBlank(profileId)) {
959       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack and profileId must not be null").build();
960     }
961 
962     // Deserialize the track
963     MediaPackageElement sourceTrack = MediaPackageElementParser.getFromXml(sourceTrackAsXml);
964     if (!Track.TYPE.equals(sourceTrack.getElementType())) {
965       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element must be of type track").build();
966     }
967 
968     try {
969       // Asynchronously encode the specified tracks
970       Job job = composerService.demux((Track) sourceTrack, profileId);
971       return Response.ok().entity(new JaxbJob(job)).build();
972     } catch (EncoderException e) {
973       logger.warn("Unable to encode the track: " + e);
974       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
975     }
976   }
977 
978   /**
979    * ProcessSmil - encode a video based on descriptions in a smil file into all format in the profileIds
980    *
981    * @param smilAsXml
982    *          - smil describing a list of videos and clips in them to make up one video
983    * @param trackId
984    *          - a paramGroup Id in the smil file describing a track
985    * @param mediaType
986    *          - audio only, video only or both
987    * @param profileIds
988    *          - list of encoding profile ids
989    * @return a job running the process
990    * @throws Exception
991    *           if it fails
992    */
993   @POST
994   @Path("processsmil")
995   @Produces(MediaType.TEXT_XML)
996   @RestQuery(
997       name = "processsmil",
998       description = "Starts an encoding process, based on the tracks and edit points in the smil and specified "
999           + "encoding profile IDs",
1000       restParameters = {
1001           @RestParameter(description = "The smil containing the tracks and edit points", isRequired = true,
1002               name = "smilAsXml", type = Type.TEXT),
1003           @RestParameter(description = "The id (paramgroup) of the track to encode", isRequired = false,
1004               name = "trackId", type = Type.STRING, defaultValue = ""),
1005           @RestParameter(description = "MediaType - v for video only, a for audio only, audiovisual otherwise",
1006               isRequired = false, name = "mediaType", type = Type.STRING, defaultValue = "o"),
1007           @RestParameter(description = "The encoding profiles to use", isRequired = true, name = "profileIds",
1008               type = Type.STRING)
1009       },
1010       responses = {
1011           @RestResponse(description = "Results in an xml document containing the job for the encoding task",
1012               responseCode = HttpServletResponse.SC_OK),
1013           @RestResponse(description = "If required parameters aren't set or if sourceTrack isn't from the type Track",
1014               responseCode = HttpServletResponse.SC_BAD_REQUEST)
1015       },
1016       returnDescription = "")
1017   public Response processSmil(@FormParam("smilAsXml") String smilAsXml, @FormParam("trackId") String trackId,
1018           @FormParam("mediaType") String mediaType, @FormParam("profileIds") String profileIds) throws Exception {
1019     // Ensure that the POST parameters are present
1020     if (StringUtils.isBlank(smilAsXml) || StringUtils.isBlank(profileIds)) {
1021       return Response.status(Response.Status.BAD_REQUEST).entity("smil and profileId must not be null").build();
1022     }
1023 
1024     // Deserialize the data
1025     String[] profiles = StringUtils.split(profileIds, ",");
1026     Smil smil;
1027     try {
1028       smil = smilService.fromXml(smilAsXml).getSmil();
1029     } catch (Exception e) {
1030       return Response.status(Response.Status.BAD_REQUEST).entity("smil must be readable").build();
1031     }
1032 
1033     try {
1034       // Encode the specified tracks
1035       Job job = composerService.processSmil(smil, trackId, mediaType, Arrays.asList(profiles));
1036       return Response.ok().entity(new JaxbJob(job)).build();
1037     } catch (EncoderException e) {
1038       logger.warn("Unable to process the smil: " + e);
1039       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
1040     }
1041   }
1042 
1043   @POST
1044   @Path("multiencode")
1045   @Produces(MediaType.TEXT_XML)
1046   @RestQuery(
1047       name = "multiencode",
1048       description = "Starts an encoding process that produces multiple outputs, based on the specified encoding "
1049           + "profile ID and the track",
1050       restParameters = {
1051           @RestParameter(description = "The track containing the stream", isRequired = true, name = "sourceTrack",
1052               type = Type.TEXT, defaultValue = VIDEO_TRACK_DEFAULT),
1053           @RestParameter(description = "The comma-delimited encoding profiles to use", isRequired = true,
1054               name = "profileIds", type = Type.STRING, defaultValue = "mp4-medium.http,mp4-low.http")
1055       },
1056       responses = {
1057           @RestResponse(description = "Results in an xml document containing the job for the encoding task",
1058               responseCode = HttpServletResponse.SC_OK),
1059           @RestResponse(description = "If required parameters aren't set or if sourceTrack isn't from the type Track",
1060               responseCode = HttpServletResponse.SC_BAD_REQUEST)
1061       },
1062       returnDescription = "")
1063   public Response multiEncode(@FormParam("sourceTrack") String sourceTrackAsXml,
1064           @FormParam("profileIds") String profileIds) throws Exception {
1065     // Ensure that the POST parameters are present
1066     if (StringUtils.isBlank(sourceTrackAsXml) || StringUtils.isBlank(profileIds)) {
1067       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack and profileIds must not be null").build();
1068     }
1069 
1070     // Deserialize the track
1071     MediaPackageElement sourceTrack = MediaPackageElementParser.getFromXml(sourceTrackAsXml);
1072     if (!Track.TYPE.equals(sourceTrack.getElementType())) {
1073       return Response.status(Response.Status.BAD_REQUEST).entity("sourceTrack element must be of type track").build();
1074     }
1075 
1076     try {
1077       // Encode the specified track with the profiles
1078       String[] profiles = StringUtils.split(profileIds, ",");
1079       Job job = composerService.multiEncode((Track) sourceTrack, Arrays.asList(profiles));
1080       return Response.ok().entity(new JaxbJob(job)).build();
1081     } catch (EncoderException e) {
1082       logger.warn("Unable to encode the track: ", e);
1083       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
1084     }
1085   }
1086 
1087   /**
1088    * Synchronously converts an image to another format.
1089    *
1090    * @param sourceImageXml
1091    *          The source image
1092    * @param profileIds
1093    *          The encoding profiles to use in image conversion
1094    * @return A {@link Response} with the resulting image in the response body
1095    * @throws Exception
1096    */
1097   @POST
1098   @Path("convertimagesync")
1099   @Produces(MediaType.TEXT_XML)
1100   @RestQuery(
1101       name = "convertimagesync",
1102       description = "Synchronously converts an image, based on the specified encoding profiles and the source image",
1103       restParameters = {
1104           @RestParameter(description = "The original image", isRequired = true, name = "sourceImage", type = Type.TEXT,
1105               defaultValue = IMAGE_ATTACHMENT_DEFAULT),
1106           @RestParameter(description = "The encoding profiles to use", isRequired = true, name = "profileIds",
1107               type = Type.STRING, defaultValue = "image-conversion.http")
1108       },
1109       responses = {
1110           @RestResponse(description = "Results in an xml document containing the image attachments",
1111               responseCode = HttpServletResponse.SC_OK),
1112           @RestResponse(description = "If required parameters aren't set or if sourceImage isn't from the type "
1113               + "attachment", responseCode = HttpServletResponse.SC_BAD_REQUEST)
1114       },
1115       returnDescription = "")
1116   public Response convertImageSync(@FormParam("sourceImage") String sourceImageXml, @FormParam("profileIds")
1117       String profileIds) throws Exception {
1118     // Ensure that the POST parameters are present
1119     if (StringUtils.isBlank(sourceImageXml) || StringUtils.isBlank(profileIds)) {
1120       return Response.status(Response.Status.BAD_REQUEST).entity("sourceImage and profileIds must not be null").build();
1121     }
1122 
1123     // Deserialize the source track
1124     MediaPackageElement sourceImage = MediaPackageElementParser.getFromXml(sourceImageXml);
1125     if (!Attachment.TYPE.equals(sourceImage.getElementType())) {
1126       return Response.status(Response.Status.BAD_REQUEST).entity("sourceImage element must be of type track").build();
1127     }
1128 
1129     try {
1130       List<Attachment> results = composerService.convertImageSync((Attachment) sourceImage,
1131           StringUtils.split(profileIds, ','));
1132       return Response.ok().entity(MediaPackageElementParser.getArrayAsXml(results)).build();
1133     } catch (EncoderException e) {
1134       logger.warn("Unable to convert image: " + e.getMessage());
1135       return Response.status(Response.Status.INTERNAL_SERVER_ERROR).build();
1136     }
1137   }
1138 
1139   @GET
1140   @Path("profiles.xml")
1141   @Produces(MediaType.TEXT_XML)
1142   @RestQuery(
1143       name = "profiles",
1144       description = "Retrieve the encoding profiles",
1145       responses = {
1146           @RestResponse(description = "Results in an xml document describing the available encoding profiles",
1147               responseCode = HttpServletResponse.SC_OK)
1148       },
1149       returnDescription = "")
1150   public EncodingProfileList listProfiles() {
1151     List<EncodingProfileImpl> list = new ArrayList<EncodingProfileImpl>();
1152     for (EncodingProfile p : composerService.listProfiles()) {
1153       list.add((EncodingProfileImpl) p);
1154     }
1155     return new EncodingProfileList(list);
1156   }
1157 
1158   @GET
1159   @Path("profile/{id}.xml")
1160   @Produces(MediaType.TEXT_XML)
1161   @RestQuery(
1162       name = "profilesID",
1163       description = "Retrieve an encoding profile",
1164       pathParameters = {
1165           @RestParameter(name = "id", description = "the profile ID", isRequired = false,
1166               type = RestParameter.Type.STRING)
1167       },
1168       responses = {
1169           @RestResponse(description = "Results in an xml document describing the requested encoding profile",
1170               responseCode = HttpServletResponse.SC_OK),
1171           @RestResponse(description = "If profile has not been found", responseCode = HttpServletResponse.SC_NOT_FOUND)
1172       },
1173       returnDescription = "")
1174   public Response getProfile(@PathParam("id") String profileId) throws NotFoundException {
1175     EncodingProfileImpl profile = (EncodingProfileImpl) composerService.getProfile(profileId);
1176     if (profile == null) {
1177       throw new NotFoundException();
1178     }
1179     return Response.ok(profile).build();
1180   }
1181 
1182   /**
1183    * {@inheritDoc}
1184    *
1185    * @see org.opencastproject.rest.AbstractJobProducerEndpoint#getService()
1186    */
1187   @Override
1188   public JobProducer getService() {
1189     if (composerService instanceof JobProducer) {
1190       return (JobProducer) composerService;
1191     } else {
1192       return null;
1193     }
1194   }
1195 
1196   /**
1197    * {@inheritDoc}
1198    *
1199    * @see org.opencastproject.rest.AbstractJobProducerEndpoint#getServiceRegistry()
1200    */
1201   @Override
1202   public ServiceRegistry getServiceRegistry() {
1203     return serviceRegistry;
1204   }
1205 
1206   /**
1207    * Parses string containing times in seconds separated by comma.
1208    *
1209    * @param times
1210    *          string to be parsed
1211    * @return array of times in seconds
1212    */
1213   protected double[] parseTimeArray(String times) {
1214     String[] timeStringArray = times.split(";");
1215     List<Double> parsedTimeArray = new LinkedList<Double>();
1216     for (String timeString : timeStringArray) {
1217       String trimmed = StringUtils.trim(timeString);
1218       if (StringUtils.isNotBlank(trimmed)) {
1219         parsedTimeArray.add(Double.parseDouble(timeString));
1220       }
1221     }
1222     double[] timeArray = new double[parsedTimeArray.size()];
1223     for (int i = 0; i < parsedTimeArray.size(); i++) {
1224       timeArray[i] = parsedTimeArray.get(i);
1225     }
1226     return timeArray;
1227   }
1228 
1229 }