diff --git a/docs/03-apis/api-v2/editing-values.md b/docs/03-apis/api-v2/editing-values.md index 80b7a18416..8da27b0800 100644 --- a/docs/03-apis/api-v2/editing-values.md +++ b/docs/03-apis/api-v2/editing-values.md @@ -233,7 +233,9 @@ DSP-API v2 currently supports using Sipi to store the following types of files: * Images: JPEG, JPEG2000, TIFF, or PNG which are stored internally as JPEG2000 * Documents: PDF -* Text files: XML or CSV +* Audio: MPEG, MP4, or Waveform audio file format (.wav, .x-wav, .vnd.wave) +* Text files: TXT, XML, or CSV +* Video files: MP4 Support for other types of files will be added in the future. diff --git a/knora-ontologies/knora-base.ttl b/knora-ontologies/knora-base.ttl index 2d5c77c4d6..b546c58914 100644 --- a/knora-ontologies/knora-base.ttl +++ b/knora-ontologies/knora-base.ttl @@ -33,7 +33,7 @@ :attachedToProject knora-admin:SystemProject ; - :ontologyVersion "knora-base v11" . + :ontologyVersion "knora-base v12" . @@ -2159,7 +2159,7 @@ ] , [ rdf:type owl:Restriction ; owl:onProperty :fps ; - owl:cardinality "1"^^xsd:nonNegativeInteger + owl:maxCardinality "1"^^xsd:nonNegativeInteger ] , [ rdf:type owl:Restriction ; owl:onProperty :duration ; diff --git a/sipi/scripts/file_info.lua b/sipi/scripts/file_info.lua index 82c83e832b..1a9d40c21f 100644 --- a/sipi/scripts/file_info.lua +++ b/sipi/scripts/file_info.lua @@ -24,6 +24,7 @@ TEXT = "text" IMAGE = "image" DOCUMENT = "document" AUDIO = "audio" +VIDEO = "video" ------------------------------------------------------------------------------- -- Mimetype constants @@ -52,6 +53,7 @@ local APPLICATION_ZIP = "application/zip" local APPLICATION_TAR = "application/x-tar" local APPLICATION_ISO = "application/x-iso9660-image" local APPLICATION_GZIP = "application/gzip" +local VIDEO_MP4 = "video/mp4" local image_mime_types = { @@ -89,6 +91,10 @@ local document_mime_types = { APPLICATION_PPTX } +local video_mime_types = { + VIDEO_MP4 +} + local audio_extensions = { "mp3", "mp4", @@ -117,6 +123,10 @@ local document_extensions = { "pptx" } +local video_extensions = { + "mp4" +} + function make_image_file_info(extension) return { media_type = IMAGE, @@ -135,6 +145,13 @@ function make_audio_file_info(extension) end end +function make_video_file_info(extension) + return { + media_type = VIDEO, + extension = extension + } +end + function make_text_file_info(extension) if not table.contains(text_extensions, extension) then return nil @@ -176,6 +193,8 @@ function get_file_info(filename, mimetype) return make_image_file_info(extension) elseif table.contains(audio_mime_types, mimetype) then return make_audio_file_info(extension) + elseif table.contains(video_mime_types, mimetype) then + return make_video_file_info(extension) elseif table.contains(text_mime_types, mimetype) then return make_text_file_info(extension) elseif table.contains(document_mime_types, mimetype) then diff --git a/test_data/test_route/files/testVideo.mp4 b/test_data/test_route/files/testVideo.mp4 new file mode 100644 index 0000000000..6734a39ca7 Binary files /dev/null and b/test_data/test_route/files/testVideo.mp4 differ diff --git a/test_data/test_route/files/testVideo2.mp4 b/test_data/test_route/files/testVideo2.mp4 new file mode 100644 index 0000000000..94ab066ce5 Binary files /dev/null and b/test_data/test_route/files/testVideo2.mp4 differ diff --git a/webapi/src/main/resources/application.conf b/webapi/src/main/resources/application.conf index 0bdb03f3df..01b0a7718c 100644 --- a/webapi/src/main/resources/application.conf +++ b/webapi/src/main/resources/application.conf @@ -413,7 +413,7 @@ app { "application/x-iso9660-image", ] text-mime-types = ["application/xml", "text/xml", "text/csv", "text/plain"] - video-mime-types = [] + video-mime-types = ["video/mp4"] audio-mime-types = ["audio/mpeg", "audio/mp4", "audio/wav", "audio/x-wav", "audio/vnd.wave"] } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala b/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala index e10f0fd05c..e2035679fe 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/store/sipimessages/SipiMessages.scala @@ -60,7 +60,8 @@ case class GetFileMetadataResponse(originalFilename: Option[String], width: Option[Int], height: Option[Int], pageCount: Option[Int], - duration: Option[BigDecimal]) + duration: Option[BigDecimal], + fps: Option[BigDecimal]) /** * Asks Sipi to move a file from temporary to permanent storage. diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala index 05c2d2f9e1..082da56969 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/ConstructResponseUtilV2.scala @@ -1098,6 +1098,22 @@ object ConstructResponseUtilV2 { .map(definedDuration => BigDecimal(definedDuration)), comment = valueCommentOption )) + case OntologyConstants.KnoraBase.MovingImageFileValue => + FastFuture.successful( + MovingImageFileValueContentV2( + ontologySchema = InternalSchema, + fileValue = fileValue, + dimX = valueObject.requireIntObject(OntologyConstants.KnoraBase.DimX.toSmartIri), + dimY = valueObject.requireIntObject(OntologyConstants.KnoraBase.DimY.toSmartIri), + fps = valueObject + .maybeStringObject(OntologyConstants.KnoraBase.Fps.toSmartIri) + .map(definedFps => BigDecimal(definedFps)), + duration = valueObject + .maybeStringObject(OntologyConstants.KnoraBase.Duration.toSmartIri) + .map(definedDuration => BigDecimal(definedDuration)), + comment = valueCommentOption + ) + ) case _ => throw InconsistentRepositoryDataException(s"Unexpected file value type: $valueType") } diff --git a/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala b/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala index 2e53127d21..15a6aa58dc 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/util/ValueUtilV1.scala @@ -90,6 +90,8 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { makeTextFileValue(valueProps, projectShortcode, responderManager, userProfile) case OntologyConstants.KnoraBase.AudioFileValue => makeAudioFileValue(valueProps, projectShortcode, responderManager, userProfile) + case OntologyConstants.KnoraBase.MovingImageFileValue => + makeVideoFileValue(valueProps, projectShortcode, responderManager, userProfile) case OntologyConstants.KnoraBase.DocumentFileValue => makeDocumentFileValue(valueProps, projectShortcode, responderManager, userProfile) case OntologyConstants.KnoraBase.LinkValue => makeLinkValue(valueProps, responderManager, userProfile) @@ -140,6 +142,16 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { s"${settings.externalSipiIIIFGetUrl}/${audioFileValue.projectShortcode}/${audioFileValue.internalFilename}/file" } + /** + * Creates a URL for accessing a video file via Sipi. + * + * @param videoFileValue the file value representing the video file. + * @return a Sipi URL. + */ + def makeSipiVideoFileGetUrlFromFilename(videoFileValue: MovingImageFileValueV1): String = { + s"${settings.externalSipiIIIFGetUrl}/${videoFileValue.projectShortcode}/${videoFileValue.internalFilename}/file" + } + // A Map of MIME types to Knora API v1 binary format name. private val mimeType2V1Format = new ErrorHandlingMap( Map( @@ -162,13 +174,15 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { "application/xml" -> "XML", "text/xml" -> "XML", "text/csv" -> "CSV", + "text/plain" -> "TEXT", "application/zip" -> "ZIP", "application/x-compressed-zip" -> "ZIP", "audio/mpeg" -> "AUDIO", "audio/mp4" -> "AUDIO", "audio/wav" -> "AUDIO", "audio/x-wav" -> "AUDIO", - "audio/vnd.wave" -> "AUDIO" + "audio/vnd.wave" -> "AUDIO", + "video/mp4" -> "VIDEO" ), { key: String => s"Unknown MIME type: $key" } @@ -215,6 +229,13 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { path = makeSipiAudioFileGetUrlFromFilename(audioFileValue) ) + case videoFileValue: MovingImageFileValueV1 => + LocationV1( + format_name = mimeType2V1Format(videoFileValue.internalMimeType), + origname = videoFileValue.originalFilename, + path = makeSipiVideoFileGetUrlFromFilename(videoFileValue) + ) + case otherType => throw NotImplementedException(s"Type not yet implemented: ${otherType.valueTypeIri}") } } @@ -388,6 +409,8 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { case _: AudioFileValueV1 => basicObjectResponse + case _: MovingImageFileValueV1 => basicObjectResponse + case _: HierarchicalListValueV1 => basicObjectResponse case _: ColorValueV1 => basicObjectResponse @@ -409,7 +432,7 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { case _: UriValueV1 => basicObjectResponse case other => - throw new Exception(s"Resource creation response format not implemented for value type ${other.valueTypeIri}") // TODO: implement remaining types. + throw new Exception(s"Resource creation response format not implemented for value type ${other.valueTypeIri}") } ResourceCreateValueResponseV1( @@ -878,7 +901,7 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { * Converts a [[ValueProps]] into a [[AudioFileValueV1]]. * * @param valueProps a [[ValueProps]] representing the SPARQL query results to be converted. - * @return a [[DocumentFileValueV1]]. + * @return a [[AudioFileValueV1]]. */ private def makeAudioFileValue( valueProps: ValueProps, @@ -899,6 +922,36 @@ class ValueUtilV1(private val settings: KnoraSettingsImpl) { )) } + /** + * Converts a [[ValueProps]] into a [[MovingImageFileValueV1]]. + * + * @param valueProps a [[ValueProps]] representing the SPARQL query results to be converted. + * @return a [[MovingImageFileValueV1]]. + */ + private def makeVideoFileValue( + valueProps: ValueProps, + projectShortcode: String, + responderManager: ActorRef, + userProfile: UserADM)(implicit timeout: Timeout, executionContext: ExecutionContext): Future[ApiValueV1] = { + val predicates = valueProps.literalData + + Future( + MovingImageFileValueV1( + internalMimeType = predicates(OntologyConstants.KnoraBase.InternalMimeType).literals.head, + internalFilename = predicates(OntologyConstants.KnoraBase.InternalFilename).literals.head, + originalFilename = predicates.get(OntologyConstants.KnoraBase.OriginalFilename).map(_.literals.head), + projectShortcode = projectShortcode, + dimX = predicates(OntologyConstants.KnoraBase.DimX).literals.head.toInt, + dimY = predicates(OntologyConstants.KnoraBase.DimY).literals.head.toInt, + fps = predicates.get(OntologyConstants.KnoraBase.Fps).map { giveLiteralValue => + BigDecimal(giveLiteralValue.literals.head) + }, + duration = predicates + .get(OntologyConstants.KnoraBase.Duration) + .map(valueLiterals => BigDecimal(valueLiterals.literals.head)) + )) + } + /** * Converts a [[ValueProps]] into a [[LinkValueV1]]. * diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala b/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala index ce76c7a403..779db23ebe 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v1/responder/valuemessages/ValueMessagesV1.scala @@ -1698,7 +1698,11 @@ case class MovingImageFileValueV1(internalMimeType: String, internalFilename: String, originalFilename: Option[String], originalMimeType: Option[String] = None, - projectShortcode: String) + projectShortcode: String, + dimX: Int, + dimY: Int, + fps: Option[BigDecimal] = None, + duration: Option[BigDecimal] = None) extends FileValueV1 { def valueTypeIri: IRI = OntologyConstants.KnoraBase.MovingImageFileValue @@ -1736,7 +1740,19 @@ case class MovingImageFileValueV1(internalMimeType: String, } override def toFileValueContentV2: FileValueContentV2 = { - throw NotImplementedException("Moving image file values are not supported in Knora API v1") + MovingImageFileValueContentV2( + ontologySchema = InternalSchema, + fileValue = FileValueV2( + internalFilename = internalFilename, + internalMimeType = internalMimeType, + originalFilename = originalFilename, + originalMimeType = Some(internalMimeType) + ), + dimX = dimX, + dimY = dimY, + fps = fps, + duration = duration + ) } } @@ -1879,7 +1895,7 @@ object ApiValueV1JsonProtocol extends SprayJsonSupport with DefaultJsonProtocol implicit val documentFileValueV1Format: JsonFormat[DocumentFileValueV1] = jsonFormat8(DocumentFileValueV1) implicit val textFileValueV1Format: JsonFormat[TextFileValueV1] = jsonFormat5(TextFileValueV1) implicit val audioFileValueV1Format: JsonFormat[AudioFileValueV1] = jsonFormat6(AudioFileValueV1) - implicit val movingImageFileValueV1Format: JsonFormat[MovingImageFileValueV1] = jsonFormat5(MovingImageFileValueV1) + implicit val movingImageFileValueV1Format: JsonFormat[MovingImageFileValueV1] = jsonFormat9(MovingImageFileValueV1) implicit val valueVersionV1Format: JsonFormat[ValueVersionV1] = jsonFormat3(ValueVersionV1) implicit val linkValueV1Format: JsonFormat[LinkValueV1] = jsonFormat4(LinkValueV1) implicit val valueVersionHistoryGetResponseV1Format: RootJsonFormat[ValueVersionHistoryGetResponseV1] = jsonFormat1( diff --git a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala index e0519207c4..6b1a2097d1 100644 --- a/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala +++ b/webapi/src/main/scala/org/knora/webapi/messages/v2/responder/valuemessages/ValueMessagesV2.scala @@ -1348,6 +1348,17 @@ object ValueContentV2 extends ValueContentReaderV2[ValueContentV2] { log = log ) + case OntologyConstants.KnoraApiV2Complex.MovingImageFileValue => + MovingImageFileValueContentV2.fromJsonLDObject( + jsonLDObject = jsonLDObject, + requestingUser = requestingUser, + responderManager = responderManager, + storeManager = storeManager, + featureFactoryConfig = featureFactoryConfig, + settings = settings, + log = log + ) + case other => throw NotImplementedException(s"Parsing of JSON-LD value type not implemented: $other") } @@ -3566,6 +3577,120 @@ object AudioFileValueContentV2 extends ValueContentReaderV2[AudioFileValueConten } } +/** + * Represents video file metadata. + * + * @param fileValue the basic metadata about the file value. + * @param dimX the with of the the image in pixels. + * @param dimY the height of the the image in pixels. + * @param fps the frame rate of the video. + * @param duration the duration of the video file in seconds. + * @param comment a comment on this [[MovingImageFileValueContentV2]], if any. + */ +case class MovingImageFileValueContentV2(ontologySchema: OntologySchema, + fileValue: FileValueV2, + dimX: Int, + dimY: Int, + fps: Option[BigDecimal] = None, + duration: Option[BigDecimal] = None, + comment: Option[String] = None) + extends FileValueContentV2 { + override def valueType: SmartIri = { + implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + OntologyConstants.KnoraBase.MovingImageFileValue.toSmartIri.toOntologySchema(ontologySchema) + } + + override def valueHasString: String = fileValue.internalFilename + + override def toOntologySchema(targetSchema: OntologySchema): MovingImageFileValueContentV2 = + copy(ontologySchema = targetSchema) + + override def toJsonLDValue(targetSchema: ApiV2Schema, + projectADM: ProjectADM, + settings: KnoraSettingsImpl, + schemaOptions: Set[SchemaOption]): JsonLDValue = { + val fileUrl: String = s"${settings.externalSipiBaseUrl}/${projectADM.shortcode}/${fileValue.internalFilename}/file" + + targetSchema match { + case ApiV2Simple => toJsonLDValueInSimpleSchema(fileUrl) + + case ApiV2Complex => + JsonLDObject( + toJsonLDObjectMapInComplexSchema(fileUrl) ++ Map( + OntologyConstants.KnoraApiV2Complex.MovingImageFileValueHasDimX -> JsonLDInt(dimX), + OntologyConstants.KnoraApiV2Complex.MovingImageFileValueHasDimY -> JsonLDInt(dimY) + )) + } + } + + override def unescape: ValueContentV2 = { + copy(comment = comment.map(commentStr => stringFormatter.fromSparqlEncodedString(commentStr))) + } + + override def wouldDuplicateOtherValue(that: ValueContentV2): Boolean = { + that match { + case thatVideoFile: MovingImageFileValueContentV2 => + fileValue == thatVideoFile.fileValue + + case _ => throw AssertionException(s"Can't compare a <$valueType> to a <${that.valueType}>") + } + } + + override def wouldDuplicateCurrentVersion(currentVersion: ValueContentV2): Boolean = { + currentVersion match { + case thatVideoFile: MovingImageFileValueContentV2 => + fileValue == thatVideoFile.fileValue && + comment == thatVideoFile.comment + + case _ => throw AssertionException(s"Can't compare a <$valueType> to a <${currentVersion.valueType}>") + } + } +} + +/** + * Constructs [[MovingImageFileValueContentV2]] objects based on JSON-LD input. + */ +object MovingImageFileValueContentV2 extends ValueContentReaderV2[MovingImageFileValueContentV2] { + override def fromJsonLDObject(jsonLDObject: JsonLDObject, + requestingUser: UserADM, + responderManager: ActorRef, + storeManager: ActorRef, + featureFactoryConfig: FeatureFactoryConfig, + settings: KnoraSettingsImpl, + log: LoggingAdapter)( + implicit timeout: Timeout, + executionContext: ExecutionContext): Future[MovingImageFileValueContentV2] = { + implicit val stringFormatter: StringFormatter = StringFormatter.getGeneralInstance + + for { + fileValueWithSipiMetadata <- FileValueWithSipiMetadata.fromJsonLDObject( + jsonLDObject = jsonLDObject, + requestingUser = requestingUser, + responderManager = responderManager, + storeManager = storeManager, + settings = settings, + log = log + ) + + _ = if (!settings.videoMimeTypes.contains(fileValueWithSipiMetadata.fileValue.internalMimeType)) { + throw BadRequestException( + s"File ${fileValueWithSipiMetadata.fileValue.internalFilename} has MIME type ${fileValueWithSipiMetadata.fileValue.internalMimeType}, which is not supported for video files") + } + } yield + MovingImageFileValueContentV2( + ontologySchema = ApiV2Complex, + fileValue = fileValueWithSipiMetadata.fileValue, + duration = fileValueWithSipiMetadata.sipiFileMetadata.duration, + dimX = fileValueWithSipiMetadata.sipiFileMetadata.width + .getOrElse(throw SipiException(s"Sipi did not return the video width")), + dimY = fileValueWithSipiMetadata.sipiFileMetadata.height + .getOrElse(throw SipiException(s"Sipi did not return the video height")), + fps = fileValueWithSipiMetadata.sipiFileMetadata.fps, + comment = getComment(jsonLDObject) + ) + } +} + /** * Represents a Knora link value. * diff --git a/webapi/src/main/scala/org/knora/webapi/package.scala b/webapi/src/main/scala/org/knora/webapi/package.scala index 4aa76d272d..eefe02b918 100644 --- a/webapi/src/main/scala/org/knora/webapi/package.scala +++ b/webapi/src/main/scala/org/knora/webapi/package.scala @@ -25,7 +25,7 @@ package object webapi { * The version of `knora-base` and of the other built-in ontologies that this version of Knora requires. * Must be the same as the object of `knora-base:ontologyVersion` in the `knora-base` ontology being used. */ - val KnoraBaseVersion: String = "knora-base v11" + val KnoraBaseVersion: String = "knora-base v12" /** * `IRI` is a synonym for `String`, used to improve code readability. diff --git a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala index 3cb226b073..fb974b38c7 100644 --- a/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala +++ b/webapi/src/main/scala/org/knora/webapi/routing/RouteUtilV1.scala @@ -34,6 +34,7 @@ import org.knora.webapi.messages.store.sipimessages.GetFileMetadataResponse import org.knora.webapi.messages.util.standoff.StandoffTagUtilV2 import org.knora.webapi.messages.util.standoff.StandoffTagUtilV2.TextWithStandoffTagsV2 import org.knora.webapi.messages.v1.responder.valuemessages.{ + MovingImageFileValueV1, AudioFileValueV1, DocumentFileValueV1, FileValueV1, @@ -284,10 +285,6 @@ object RouteUtilV1 { * MIME types used in Sipi to store audio files. */ private val audioMimeTypes: Set[String] = Set( - "application/xml", - "text/xml", - "text/csv", - "text/plain", "audio/mpeg", "audio/mp4", "audio/wav", @@ -295,6 +292,13 @@ object RouteUtilV1 { "audio/vnd.wave" ) + /** + * MIME types used in Sipi to store video files. + */ + private val videoMimeTypes: Set[String] = Set( + "video/mp4" + ) + /** * Converts file metadata from Sipi into a [[FileValueV1]]. * @@ -345,6 +349,19 @@ object RouteUtilV1 { projectShortcode = projectShortcode, duration = fileMetadataResponse.duration ) + } else if (videoMimeTypes.contains(fileMetadataResponse.internalMimeType)) { + MovingImageFileValueV1( + internalFilename = filename, + internalMimeType = fileMetadataResponse.internalMimeType, + originalFilename = fileMetadataResponse.originalFilename, + originalMimeType = fileMetadataResponse.originalMimeType, + projectShortcode = projectShortcode, + duration = fileMetadataResponse.duration, + fps = fileMetadataResponse.fps, + dimX = fileMetadataResponse.width.getOrElse(throw SipiException(s"Sipi did not return the width of the video")), + dimY = + fileMetadataResponse.height.getOrElse(throw SipiException(s"Sipi did not return the height of the video")) + ) } else { throw BadRequestException(s"MIME type ${fileMetadataResponse.internalMimeType} not supported in Knora API v1") } diff --git a/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala b/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala index d5ba47a335..58548cf2d2 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/iiif/SipiConnector.scala @@ -99,7 +99,8 @@ class SipiConnector extends Actor with ActorLogging { width: Option[Int], height: Option[Int], numpages: Option[Int], - duration: Option[BigDecimal]) { + duration: Option[BigDecimal], + fps: Option[BigDecimal]) { if (originalFilename.contains("")) { throw SipiException(s"Sipi returned an empty originalFilename") } @@ -110,7 +111,7 @@ class SipiConnector extends Actor with ActorLogging { } object SipiKnoraJsonResponseProtocol extends SprayJsonSupport with DefaultJsonProtocol { - implicit val sipiKnoraJsonResponseFormat: RootJsonFormat[SipiKnoraJsonResponse] = jsonFormat7(SipiKnoraJsonResponse) + implicit val sipiKnoraJsonResponseFormat: RootJsonFormat[SipiKnoraJsonResponse] = jsonFormat8(SipiKnoraJsonResponse) } /** @@ -136,7 +137,8 @@ class SipiConnector extends Actor with ActorLogging { width = sipiResponse.width, height = sipiResponse.height, pageCount = sipiResponse.numpages, - duration = sipiResponse.duration + duration = sipiResponse.duration, + fps = sipiResponse.fps ) } diff --git a/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala b/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala index ded46d9f26..2c6f0e3277 100644 --- a/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala +++ b/webapi/src/main/scala/org/knora/webapi/store/triplestore/upgrade/RepositoryUpdatePlan.scala @@ -34,7 +34,8 @@ object RepositoryUpdatePlan { PluginForKnoraBaseVersion(versionNumber = 8, plugin = new UpgradePluginPR1615(featureFactoryConfig)), PluginForKnoraBaseVersion(versionNumber = 9, plugin = new UpgradePluginPR1746(featureFactoryConfig, log)), PluginForKnoraBaseVersion(versionNumber = 10, plugin = new NoopPlugin), // PR 1808 - PluginForKnoraBaseVersion(versionNumber = 11, plugin = new NoopPlugin) // PR 1813 + PluginForKnoraBaseVersion(versionNumber = 11, plugin = new NoopPlugin), // PR 1813 + PluginForKnoraBaseVersion(versionNumber = 12, plugin = new NoopPlugin) // PR 1891 ) /** diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt index 39e0eeebcc..8feb884e12 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/addValueVersion.scala.txt @@ -294,6 +294,45 @@ DELETE { } } + case videoFileValue: MovingImageFileValueV1 => { + ?newValue knora-base:internalFilename """@videoFileValue.internalFilename""" ; + knora-base:internalMimeType """@videoFileValue.internalMimeType""" ; + knora-base:dimX @videoFileValue.dimX ; + knora-base:dimY @videoFileValue.dimY . + + @videoFileValue.originalFilename match { + case Some(definedOriginalFilename) => { + ?newValue knora-base:originalFilename """@definedOriginalFilename""" . + } + + case None => {} + } + + @videoFileValue.originalMimeType match { + case Some(definedOriginalMimeType) => { + ?newValue knora-base:originalMimeType """@definedOriginalMimeType""" . + } + + case None => {} + } + + @videoFileValue.duration match { + case Some(definedDuration) => { + ?newValue knora-base:duration """@definedDuration""" . + } + + case None => {} + } + + @videoFileValue.fps match { + case Some(definedFps) => { + newValue knora-base:fps """@definedFps""" . + } + + case None => {} + } + } + case documentFileValue: DocumentFileValueV1 => { ?newValue knora-base:internalFilename """@documentFileValue.internalFilename""" . ?newValue knora-base:internalMimeType """@documentFileValue.internalMimeType""" . diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt index a4fe7a5be3..01cd46e7d2 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v1/generateInsertStatementsForCreateValue.scala.txt @@ -250,6 +250,45 @@ } } + case videoFileValue: MovingImageFileValueV1 => { + <@newValueIri> knora-base:internalFilename """@videoFileValue.internalFilename""" ; + knora-base:internalMimeType """@videoFileValue.internalMimeType""" ; + knora-base:dimX @videoFileValue.dimX ; + knora-base:dimY @videoFileValue.dimY . + + @videoFileValue.originalFilename match { + case Some(definedOriginalFilename) => { + <@newValueIri> knora-base:originalFilename """@definedOriginalFilename""" . + } + + case None => {} + } + + @videoFileValue.originalMimeType match { + case Some(definedOriginalMimeType) => { + <@newValueIri> knora-base:originalMimeType """@definedOriginalMimeType""" . + } + + case None => {} + } + + @videoFileValue.duration match { + case Some(definedDuration) => { + <@newValueIri> knora-base:duration """@definedDuration""" . + } + + case None => {} + } + + @videoFileValue.fps match { + case Some(definedFps) => { + <@newValueIri> knora-base:duration """@definedFps""" . + } + + case None => {} + } + } + case documentFileValue: DocumentFileValueV1 => { <@newValueIri> knora-base:internalFilename """@documentFileValue.internalFilename""" ; knora-base:internalMimeType """@documentFileValue.internalMimeType""" . diff --git a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt index 296e5f6d3a..3e5845afd9 100644 --- a/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt +++ b/webapi/src/main/twirl/org/knora/webapi/messages/twirl/queries/sparql/v2/generateInsertStatementsForValueContent.scala.txt @@ -216,6 +216,25 @@ } + case videoFileValue: MovingImageFileValueContentV2 => { + <@newValueIri> knora-base:dimX @videoFileValue.dimX ; + knora-base:dimY @videoFileValue.dimY . + @videoFileValue.duration match { + case Some(definedDuration) => { + <@newValueIri> knora-base:duration @definedDuration . + } + + case None => {} + } + @videoFileValue.fps match { + case Some(definedFps) => { + <@newValueIri> knora-base:fps @definedFps . + } + + case None => {} + } + } + case _ => {} } } diff --git a/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala b/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala index faca248890..25f8b295d5 100644 --- a/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/it/v1/KnoraSipiIntegrationV1ITSpec.scala @@ -81,6 +81,7 @@ class KnoraSipiIntegrationV1ITSpec private val pdfResourceIri = new MutableTestIri private val zipResourceIri = new MutableTestIri private val wavResourceIri = new MutableTestIri + private val videoResourceIri = new MutableTestIri private val minimalPdfOriginalFilename = "minimal.pdf" private val pathToMinimalPdf = s"test_data/test_route/files/$minimalPdfOriginalFilename" @@ -104,6 +105,12 @@ class KnoraSipiIntegrationV1ITSpec private val testWavOriginalFilename = "test.wav" private val pathToTestWav = s"test_data/test_route/files/$testWavOriginalFilename" + private val testVideoOriginalFilename = "testVideo.mp4" + private val pathToTestVideo = s"test_data/test_route/files/$testVideoOriginalFilename" + + private val testVideo2OriginalFilename = "testVideo2.mp4" + private val pathToTestVideo2 = s"test_data/test_route/files/$testVideoOriginalFilename" + /** * Adds the IRI of a XSL transformation to the given mapping. * @@ -1003,5 +1010,96 @@ class KnoraSipiIntegrationV1ITSpec val sipiGetRequest = Get(wavUrl) ~> addCredentials(BasicHttpCredentials(userEmail, password)) checkResponseOK(sipiGetRequest) } + + //TODO: activate the following two tests after video support is implemented in sipi + "create a resource with a video file attached" ignore { + // Upload the video file to Sipi. + val zipUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToTestVideo, mimeType = MediaTypes.`video/mp4`)) + ) + + val uploadedVideoFile: SipiUploadResponseEntry = zipUploadResponse.uploadedFiles.head + uploadedVideoFile.originalFilename should ===(testVideoOriginalFilename) + + // Create a resource for the video file. + val createVideoResourceParams = JsObject( + Map( + "restype_id" -> JsString("http://www.knora.org/ontology/knora-base#MovingImageRepresentation"), + "label" -> JsString("Wav file"), + "project_id" -> JsString("http://rdfh.ch/projects/0001"), + "properties" -> JsObject(), + "file" -> JsString(uploadedVideoFile.internalFilename) + ) + ) + + // Send the JSON in a POST request to the Knora API server. + val createVideoResourceRequest: HttpRequest = Post( + baseApiUrl + "/v1/resources", + HttpEntity(ContentTypes.`application/json`, createVideoResourceParams.compactPrint)) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + val createVideoResourceResponseJson: JsObject = getResponseJson(createVideoResourceRequest) + + // get the IRI of the audio file resource + val resourceIri: String = createVideoResourceResponseJson.fields.get("res_id") match { + case Some(JsString(res_id: String)) => res_id + case _ => throw InvalidApiJsonException("member 'res_id' was expected") + } + + videoResourceIri.set(resourceIri) + + // Request the video file resource from the Knora API server. + val videoResourceRequest = Get(baseApiUrl + "/v1/resources/" + URLEncoder.encode(resourceIri, "UTF-8")) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + val videoResourceResponse: JsObject = getResponseJson(videoResourceRequest) + val locdata = videoResourceResponse.fields("resinfo").asJsObject.fields("locdata").asJsObject + val videoUrl = + locdata.fields("path").asInstanceOf[JsString].value.replace("http://0.0.0.0:1024", baseInternalSipiUrl) + + // Request the file from Sipi. + val sipiGetRequest = Get(videoUrl) ~> addCredentials(BasicHttpCredentials(userEmail, password)) + checkResponseOK(sipiGetRequest) + } + + "change the video file attached to a resource" ignore { + // Upload the file to Sipi. + val sipiUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToTestVideo2, mimeType = MediaTypes.`video/mp4`)) + ) + + val uploadedFile: SipiUploadResponseEntry = sipiUploadResponse.uploadedFiles.head + uploadedFile.originalFilename should ===(testVideo2OriginalFilename) + + // JSON describing the new file to Knora. + val knoraParams = JsObject( + Map( + "file" -> JsString(s"${uploadedFile.internalFilename}") + ) + ) + + // Send the JSON in a PUT request to the Knora API server. + val knoraPutRequest = Put( + baseApiUrl + "/v1/filevalue/" + URLEncoder.encode(wavResourceIri.get, "UTF-8"), + HttpEntity(ContentTypes.`application/json`, knoraParams.compactPrint)) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + checkResponseOK(knoraPutRequest) + + // Request the document resource from the Knora API server. + val videoResourceRequest = Get(baseApiUrl + "/v1/resources/" + URLEncoder.encode(videoResourceIri.get, "UTF-8")) ~> addCredentials( + BasicHttpCredentials(userEmail, password)) + + val videoResourceResponse: JsObject = getResponseJson(videoResourceRequest) + val locdata = videoResourceResponse.fields("resinfo").asJsObject.fields("locdata").asJsObject + val videoUrl = + locdata.fields("path").asInstanceOf[JsString].value.replace("http://0.0.0.0:1024", baseInternalSipiUrl) + + // Request the file from Sipi. + val sipiGetRequest = Get(videoUrl) ~> addCredentials(BasicHttpCredentials(userEmail, password)) + checkResponseOK(sipiGetRequest) + } } } diff --git a/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala b/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala index fc3c79f98b..8c9e0a36b0 100644 --- a/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala +++ b/webapi/src/test/scala/org/knora/webapi/it/v2/KnoraSipiIntegrationV2ITSpec.scala @@ -72,6 +72,9 @@ class KnoraSipiIntegrationV2ITSpec private val wavResourceIri = new MutableTestIri private val wavValueIri = new MutableTestIri + private val videoResourceIri = new MutableTestIri + private val videoValueIri = new MutableTestIri + private val marblesOriginalFilename = "marbles.tif" private val pathToMarbles = s"test_data/test_route/images/$marblesOriginalFilename" private val marblesWidth = 1419 @@ -119,6 +122,12 @@ class KnoraSipiIntegrationV2ITSpec private val testWavOriginalFilename = "test.wav" private val pathToTestWav = s"test_data/test_route/files/$testWavOriginalFilename" + private val testVideoOriginalFilename = "testVideo.mp4" + private val pathToTestVideo = s"test_data/test_route/files/$testVideoOriginalFilename" + + private val testVideo2OriginalFilename = "test.wav" + private val pathToTestVideo2 = s"test_data/test_route/files/$testVideo2OriginalFilename" + /** * Represents the information that Knora returns about an image file value that was created. * @@ -161,6 +170,23 @@ class KnoraSipiIntegrationV2ITSpec */ case class SavedAudioFile(internalFilename: String, url: String, duration: Option[BigDecimal]) + /** + * Represents the information that Knora returns about a video file value that was created. + * + * @param internalFilename the file's internal filename. + * @param url the file's URL. + * @param width the video's width in pixels. + * @param height the video's height in pixels. + * @param duration the duration of the video in seconds. + * @param fps the frame rate of the video in seconds. + */ + case class SavedVideoFile(internalFilename: String, + url: String, + dimX: Int, + dimY: Int, + duration: Option[BigDecimal], + fps: Option[BigDecimal]) + /** * Given a JSON-LD document representing a resource, returns a JSON-LD array containing the values of the specified * property. @@ -284,10 +310,10 @@ class KnoraSipiIntegrationV2ITSpec } /** - * Given a JSON-LD object representing a Knora text file value, returns a [[SavedTextFile]] containing the same information. + * Given a JSON-LD object representing a Knora audio file value, returns a [[SavedAudioFile]] containing the same information. * - * @param savedValue a JSON-LD object representing a Knora document file value. - * @return a [[SavedTextFile]] containing the same information. + * @param savedValue a JSON-LD object representing a Knora audio file value. + * @return a [[SavedAudioFile]] containing the same information. */ private def savedValueToSavedAudioFile(savedValue: JsonLDObject): SavedAudioFile = { val internalFilename = savedValue.requireString(OntologyConstants.KnoraApiV2Complex.FileValueHasFilename) @@ -311,6 +337,46 @@ class KnoraSipiIntegrationV2ITSpec ) } + /** + * Given a JSON-LD object representing a Knora video file value, returns a [[SavedVideoFile]] containing the same information. + * + * @param savedValue a JSON-LD object representing a Knora video file value. + * @return a [[SavedVideoFile]] containing the same information. + */ + private def savedValueToSavedVideoFile(savedValue: JsonLDObject): SavedVideoFile = { + val internalFilename = savedValue.requireString(OntologyConstants.KnoraApiV2Complex.FileValueHasFilename) + + val url: String = savedValue.requireDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.FileValueAsUrl, + expectedDatatype = OntologyConstants.Xsd.Uri.toSmartIri, + validationFun = stringFormatter.toSparqlEncodedString + ) + + val dimY = savedValue.requireInt(OntologyConstants.KnoraApiV2Complex.MovingImageFileValueHasDimY) + val dimX = savedValue.requireInt(OntologyConstants.KnoraApiV2Complex.MovingImageFileValueHasDimX) + + val duration: Option[BigDecimal] = savedValue.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.AudioFileValueHasDuration, + expectedDatatype = OntologyConstants.Xsd.Decimal.toSmartIri, + validationFun = stringFormatter.validateBigDecimal + ) + + val fps: Option[BigDecimal] = savedValue.maybeDatatypeValueInObject( + key = OntologyConstants.KnoraApiV2Complex.MovingImageFileValueHasFps, + expectedDatatype = OntologyConstants.Xsd.Decimal.toSmartIri, + validationFun = stringFormatter.validateBigDecimal + ) + + SavedVideoFile( + internalFilename = internalFilename, + url = url, + dimX = dimX, + dimY = dimY, + duration = duration, + fps = fps + ) + } + "The Knora/Sipi integration" should { var loginToken: String = "" @@ -1205,5 +1271,129 @@ class KnoraSipiIntegrationV2ITSpec val sipiGetFileRequest = Get(savedAudioFile.url.replace("http://0.0.0.0:1024", baseInternalSipiUrl)) checkResponseOK(sipiGetFileRequest) } + + //TODO: activate the following two tests after support of video files is added to sipi + "create a resource with a video file" ignore { + // Upload the file to Sipi. + val sipiUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToTestVideo, mimeType = MediaTypes.`video/mp4`)) + ) + + val uploadedFile: SipiUploadResponseEntry = sipiUploadResponse.uploadedFiles.head + uploadedFile.originalFilename should ===(testVideoOriginalFilename) + + // Ask Knora to create the resource. + + val jsonLdEntity = + s"""{ + | "@type" : "knora-api:MovingImageRepresentation", + | "knora-api:hasMovingImageFileValue" : { + | "@type" : "knora-api:MovingImageFileValue", + | "knora-api:fileValueHasFilename" : "${uploadedFile.internalFilename}" + | }, + | "knora-api:attachedToProject" : { + | "@id" : "http://rdfh.ch/projects/0001" + | }, + | "rdfs:label" : "test video representation", + | "@context" : { + | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", + | "xsd" : "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin + + val request = Post(s"$baseApiUrl/v2/resources", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials( + BasicHttpCredentials(anythingUserEmail, password)) + val responseJsonDoc: JsonLDDocument = getResponseJsonLD(request) + videoResourceIri.set(responseJsonDoc.body.requireIDAsKnoraDataIri.toString) + + // Get the resource from Knora. + val knoraGetRequest = Get(s"$baseApiUrl/v2/resources/${URLEncoder.encode(videoResourceIri.get, "UTF-8")}") + val resource: JsonLDDocument = getResponseJsonLD(knoraGetRequest) + assert( + resource.requireTypeAsKnoraTypeIri.toString == "http://api.knora.org/ontology/knora-api/v2#MovingImageRepresentation") + + // Get the new file value from the resource. + + val savedValues: JsonLDArray = getValuesFromResource( + resource = resource, + propertyIriInResult = OntologyConstants.KnoraApiV2Complex.HasMovingImageFileValue.toSmartIri + ) + + val savedValue: JsonLDValue = if (savedValues.value.size == 1) { + savedValues.value.head + } else { + throw AssertionException(s"Expected one file value, got ${savedValues.value.size}") + } + + val savedValueObj: JsonLDObject = savedValue match { + case jsonLDObject: JsonLDObject => jsonLDObject + case other => throw AssertionException(s"Invalid value object: $other") + } + + videoValueIri.set(savedValueObj.requireIDAsKnoraDataIri.toString) + + val savedVideoFile: SavedVideoFile = savedValueToSavedVideoFile(savedValueObj) + assert(savedVideoFile.internalFilename == uploadedFile.internalFilename) + + // Request the permanently stored file from Sipi. + val sipiGetFileRequest = Get(savedVideoFile.url.replace("http://0.0.0.0:1024", baseInternalSipiUrl)) + checkResponseOK(sipiGetFileRequest) + } + + "change a video file value" ignore { + // Upload the file to Sipi. + val sipiUploadResponse: SipiUploadResponse = uploadToSipi( + loginToken = loginToken, + filesToUpload = Seq(FileToUpload(path = pathToTestVideo2, mimeType = MediaTypes.`video/mp4`)) + ) + + val uploadedFile: SipiUploadResponseEntry = sipiUploadResponse.uploadedFiles.head + uploadedFile.originalFilename should ===(testVideo2OriginalFilename) + + // Ask Knora to update the value. + + val jsonLdEntity = + s"""{ + | "@id" : "${videoResourceIri.get}", + | "@type" : "knora-api:MovingImageRepresentation", + | "knora-api:hasMovingImageFileValue" : { + | "@type" : "knora-api:MovingImageFileValue", + | "@id" : "${videoValueIri.get}", + | "knora-api:fileValueHasFilename" : "${uploadedFile.internalFilename}" + | }, + | "@context" : { + | "rdf" : "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + | "knora-api" : "http://api.knora.org/ontology/knora-api/v2#", + | "rdfs" : "http://www.w3.org/2000/01/rdf-schema#", + | "xsd" : "http://www.w3.org/2001/XMLSchema#" + | } + |}""".stripMargin + + val request = Put(s"$baseApiUrl/v2/values", HttpEntity(RdfMediaTypes.`application/ld+json`, jsonLdEntity)) ~> addCredentials( + BasicHttpCredentials(anythingUserEmail, password)) + val responseJsonDoc: JsonLDDocument = getResponseJsonLD(request) + videoValueIri.set(responseJsonDoc.body.requireIDAsKnoraDataIri.toString) + + // Get the resource from Knora. + val knoraGetRequest = Get(s"$baseApiUrl/v2/resources/${URLEncoder.encode(videoResourceIri.get, "UTF-8")}") + val resource = getResponseJsonLD(knoraGetRequest) + + // Get the new file value from the resource. + val savedValue: JsonLDObject = getValueFromResource( + resource = resource, + propertyIriInResult = OntologyConstants.KnoraApiV2Complex.HasMovingImageFileValue.toSmartIri, + expectedValueIri = videoValueIri.get + ) + + val savedVideoFile: SavedVideoFile = savedValueToSavedVideoFile(savedValue) + assert(savedVideoFile.internalFilename == uploadedFile.internalFilename) + + // Request the permanently stored file from Sipi. + val sipiGetFileRequest = Get(savedVideoFile.url.replace("http://0.0.0.0:1024", baseInternalSipiUrl)) + checkResponseOK(sipiGetFileRequest) + } } } diff --git a/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala b/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala index 3315defd95..dd45791009 100644 --- a/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala +++ b/webapi/src/test/scala/org/knora/webapi/store/iiif/MockSipiConnector.scala @@ -71,7 +71,8 @@ class MockSipiConnector extends Actor with ActorLogging { width = Some(512), height = Some(256), pageCount = None, - duration = None + duration = None, + fps = None ) }