1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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
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
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
149 protected String serverUrl;
150
151
152 protected ComposerService composerService = null;
153
154
155 protected ServiceRegistry serviceRegistry = null;
156
157
158 protected SmilService smilService = null;
159
160 @Reference
161 public void setSmilService(SmilService smilService) {
162 this.smilService = smilService;
163 }
164
165
166
167
168
169
170
171 @Reference
172 protected void setServiceRegistry(ServiceRegistry serviceRegistry) {
173 this.serviceRegistry = serviceRegistry;
174 }
175
176
177
178
179
180
181
182 @Reference
183 public void setComposerService(ComposerService composerService) {
184 this.composerService = composerService;
185 }
186
187
188
189
190
191
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
203
204
205
206
207
208
209
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
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
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
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
255
256
257
258
259
260
261
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
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
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
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
304
305
306
307
308
309
310
311
312
313
314
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
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
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
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
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
382
383
384
385
386
387
388
389
390
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
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
442 job = composerService.mux(sourceTracks, profileId);
443 } else {
444
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
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
467
468
469
470
471
472
473
474
475
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
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
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
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
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
545
546
547
548
549
550
551
552
553
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
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
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
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
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
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
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
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
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
724
725
726
727
728
729
730
731
732
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
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
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
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
803
804
805
806
807
808
809
810
811
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
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
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
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
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
872
873
874
875
876
877
878
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
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
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
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
926
927
928
929
930
931
932
933
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
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
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
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
980
981
982
983
984
985
986
987
988
989
990
991
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
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
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
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
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
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
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
1089
1090
1091
1092
1093
1094
1095
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
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
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
1184
1185
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
1198
1199
1200
1201 @Override
1202 public ServiceRegistry getServiceRegistry() {
1203 return serviceRegistry;
1204 }
1205
1206
1207
1208
1209
1210
1211
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 }