Skip to content

Commit

Permalink
Add support for XMP DCMI license data via KHR_xmp_json_ld (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
aaronfranke authored Apr 15, 2024
1 parent 057d8b1 commit 4bb762e
Show file tree
Hide file tree
Showing 7 changed files with 461 additions and 1 deletion.
24 changes: 24 additions & 0 deletions addons/gltf_khr_xmp_copyright/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.

Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.

In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.

For more information, please refer to <https://unlicense.org>
9 changes: 9 additions & 0 deletions addons/gltf_khr_xmp_copyright/gltf_khr_xmp_plugin.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
@tool
extends EditorPlugin


func _enter_tree() -> void:
# NOTE: Be sure to also instance and register these at runtime if you want
# the extensions at runtime. This editor plugin script won't run in games.
var ext = GLTFDocumentExtensionKHR_XMP.new()
GLTFDocument.register_gltf_document_extension(ext)
7 changes: 7 additions & 0 deletions addons/gltf_khr_xmp_copyright/plugin.cfg
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[plugin]

name="glTF KHR_xmp_json_ld Copyright"
description="Godot implementation of the glTF KHR_xmp_json_ld extension for DCMI copyright metadata. Requires Godot 4.3 or newer."
author="Aaron Franke"
version="4.3"
script="gltf_khr_xmp_plugin.gd"
120 changes: 120 additions & 0 deletions addons/gltf_khr_xmp_copyright/xmp/dcmi_license_metadata.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
@tool
## Stores DCMI XMP license info and metadata as defined by Khronos and DCMI.
## List of elements: http://purl.org/dc/elements/1.1/
## https://github.com/KhronosGroup/glTF/tree/main/extensions/2.0/Khronos/KHR_xmp_json_ld
class_name DCMILicenseMetadata
extends XMPMetadataBase

# TODO: On export: format

const CONTEXT_URL: String = "http://purl.org/dc/elements/1.1/"

@export_group("License")
## License name. Examples: "MIT License", "CC BY-SA 4.0", "All Rights Reserved"
@export_placeholder("Ex: MIT, BSD, CC0, etc") var rights: String = ""
## Primary authors. Example: John Smith <jsmith@email.com>
@export var creator: PackedStringArray = []
## Secondary authors. Example: John Smith <jsmith@email.com>
@export var contributor: PackedStringArray = []
## Company names. Example: Smith Co.
@export var publisher: PackedStringArray = []

@export_group("Content")
## Example: Rainbow Godette Model
@export_placeholder("Name of this GLTF file") var title: String = ""
## Example: Detailed Godette model with added sunshine, lollipops, and rainbows.
@export_placeholder("Description of this GLTF file") var description: String = ""
## Examples: Architecture, Automotive, Botany
@export var subject: PackedStringArray = []
## Examples: Character, Building, Vehicle, Plant, Prop
@export var type: PackedStringArray = []

@export_group("Locale")
## Location. Example: Bay Area, CA, USA
@export_placeholder("Location/area/city/country") var coverage: String = ""
## Examples: 2014-02-09, 2014-02-09T22:10:30
@export_placeholder("YYYY-MM-DD and/or hh:mm:ss") var date: String = ""
## ISO 639-2 code (ex: en), ISO 639-3 code (ex: eng), or culture ID (ex: en_US).
@export_placeholder("en, eng, en_US, etc") var language: String = ""

@export_group("Reference")
## Example: ISBN-13:978-0802144423
@export_placeholder("DOI, ISBN, URN, etc") var identifier: String = ""
## Example: https://your.website/file.glb
@export_placeholder("URL to this GLTF on the web") var source: String = ""
## Websites. Example: https://godotengine.org/
@export var relation: PackedStringArray = []


func to_dictionary() -> Dictionary:
var dcmi_dict: Dictionary = _dcmi_properties_to_dictionary()
if dcmi_dict.is_empty():
# If there was no data, don't add @context, just return an empty dict.
return dcmi_dict
var ret: Dictionary = {
"@context": {
"dc": "http://purl.org/dc/elements/1.1/",
},
"@id": "",
}
ret.merge(dcmi_dict)
return ret


func _dcmi_properties_to_dictionary() -> Dictionary:
var dcmi_dict: Dictionary = {}
if not contributor.is_empty():
dcmi_dict["dc:contributor"] = { "@set": contributor }
if not coverage.is_empty():
dcmi_dict["dc:coverage"] = coverage
if not creator.is_empty():
dcmi_dict["dc:creator"] = { "@list": creator }
if not date.is_empty():
dcmi_dict["dc:date"] = date
if not description.is_empty():
dcmi_dict["dc:description"] = description
if not identifier.is_empty():
dcmi_dict["dc:identifier"] = identifier
if not language.is_empty():
dcmi_dict["dc:language"] = language
if not publisher.is_empty():
dcmi_dict["dc:publisher"] = { "@set": publisher }
if not relation.is_empty():
dcmi_dict["dc:relation"] = { "@set": relation }
if not rights.is_empty():
dcmi_dict["dc:rights"] = rights
if not source.is_empty():
dcmi_dict["dc:source"] = source
if not subject.is_empty():
dcmi_dict["dc:subject"] = { "@set": subject }
if not title.is_empty():
dcmi_dict["dc:title"] = title
if not type.is_empty():
dcmi_dict["dc:type"] = { "@set": type }
return dcmi_dict


static func from_dictionary(xmp_packet: Dictionary) -> XMPMetadataBase:
var context_url: String = xmp_packet["@context"]["dc"]
if context_url != DCMILicenseMetadata.CONTEXT_URL:
push_warning("GLTF KHR XMP: DCMI metadata had a URL of '" + context_url + "' but expected '" + DCMILicenseMetadata.CONTEXT_URL + "'. Attempting to parse anyway.")
var ret := DCMILicenseMetadata.new()
ret.xmp_json_ld = xmp_packet
_dcmi_properties_from_dictionary(ret, xmp_packet)
return ret


const _SINGLE_VALUES: PackedStringArray = ["coverage", "date", "description", "identifier", "language", "rights", "source", "title"]
const _ARRAY_VALUES: PackedStringArray = ["contributor", "creator", "publisher", "relation", "subject", "type"]

static func _dcmi_properties_from_dictionary(dcmi_data: DCMILicenseMetadata, xmp_packet: Dictionary) -> void:
for single_value_name in _SINGLE_VALUES:
var dcmi_prefixed: String = "dc:" + single_value_name
if xmp_packet.has(dcmi_prefixed):
var extracted = extract_single_value_from_json_ld(xmp_packet[dcmi_prefixed])
dcmi_data.set(single_value_name, String(extracted))
for array_value_name in _ARRAY_VALUES:
var dcmi_prefixed: String = "dc:" + array_value_name
if xmp_packet.has(dcmi_prefixed):
var extracted = extract_array_from_json_ld(xmp_packet[dcmi_prefixed])
dcmi_data.set(array_value_name, PackedStringArray(extracted))
176 changes: 176 additions & 0 deletions addons/gltf_khr_xmp_copyright/xmp/khr_xmp_doc_ext.gd
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
@tool
class_name GLTFDocumentExtensionKHR_XMP
extends GLTFDocumentExtension


## Applies to the whole document when exporting.
@export var dcmi_license_metadata := DCMILicenseMetadata.new()


# Import process.
func _import_preflight(gltf_state: GLTFState, extensions_used: PackedStringArray) -> Error:
if not extensions_used.has("KHR_xmp_json_ld"):
return ERR_SKIP
var state_json: Dictionary = gltf_state.json
if not state_json.has("extensions"):
return ERR_INVALID_DATA
var doc_extensions: Dictionary = state_json["extensions"]
if not doc_extensions.has("KHR_xmp_json_ld"):
return ERR_INVALID_DATA
var khr_xmp_ext: Dictionary = doc_extensions["KHR_xmp_json_ld"]
if not khr_xmp_ext.has("packets"):
return ERR_INVALID_DATA
var khr_xmp_packets: Array = khr_xmp_ext["packets"]
var parsed_khr_xmp_data: Array = []
for xmp_packet in khr_xmp_packets:
var xmp: XMPMetadataBase = _parse_xmp_packet(gltf_state, xmp_packet)
if xmp == null:
return ERR_INVALID_DATA
parsed_khr_xmp_data.append(xmp)
gltf_state.set_additional_data(&"KHR_xmp_json_ld", parsed_khr_xmp_data)
return OK


func _parse_xmp_packet(gltf_state: GLTFState, xmp_packet: Dictionary) -> XMPMetadataBase:
if not xmp_packet.has("@context"):
return null
var xmp_context: Dictionary = xmp_packet["@context"]
var xmp_metadata: XMPMetadataBase = null
for context_prefix in xmp_context:
if context_prefix == "dc":
xmp_metadata = DCMILicenseMetadata.from_dictionary(xmp_packet)
elif context_prefix == "rdf":
# RDF allows specifying language alternatives, it combines together
# with another context, we don't need to do anything special here.
pass
else:
# XMP is an open-ended standard, any data structure can be stored
# in it. Show a warning when we encounter something unrecognized,
# but the JSON data will still be available in an XMPMetadataBase.
push_warning("GLTF KHR XMP: Unrecognized context prefix '" + context_prefix + "'.")
if xmp_metadata == null:
# No specific class wants to handle this, so just return the base class.
xmp_metadata = XMPMetadataBase.from_dictionary(xmp_packet)
return xmp_metadata


func _get_supported_extensions() -> PackedStringArray:
return PackedStringArray(["KHR_xmp_json_ld"])


func _import_post(gltf_state: GLTFState, root: Node) -> Error:
var asset_json: Dictionary = gltf_state.json["asset"]
if asset_json.has("extensions"):
var asset_extensions: Dictionary = asset_json["extensions"]
if asset_extensions.has("KHR_xmp_json_ld"):
var asset_xmp: Dictionary = asset_extensions["KHR_xmp_json_ld"]
if asset_xmp.has("packet"):
var packet: int = int(asset_xmp["packet"])
var xmp_array: Array = gltf_state.get_additional_data("KHR_xmp_json_ld")
root.set_meta("KHR_xmp_json_ld", xmp_array[packet])
return OK


# Export process.
func _export_preflight(gltf_state: GLTFState, root: Node) -> Error:
var dcmi_dict: Dictionary = dcmi_license_metadata.to_dictionary()
if dcmi_dict.is_empty():
return OK
dcmi_dict["dc:format"] = "model/gltf"
# Add the DCMI XMP JSON dictionary to the document extensions.
var state_packets: Array = _get_or_create_state_packets_in_state(gltf_state)
state_packets.append(dcmi_dict)
return OK


func _export_preserialize(gltf_state: GLTFState) -> Error:
var state_packets = _get_state_packets_in_state_if_present(gltf_state)
if state_packets == null or state_packets.is_empty():
return OK
var first_packet: Dictionary = state_packets[0]
if first_packet.has("dc:format"):
if gltf_state.filename.ends_with(".gltf"):
first_packet["dc:format"] = "model/gltf+json"
else: # If .glb, it's binary. If empty, it's a buffer, also binary.
first_packet["dc:format"] = "model/gltf-binary"
return OK


func _export_node(gltf_state: GLTFState, gltf_node: GLTFNode, node_json: Dictionary, node: Node) -> Error:
if not node.has_meta(&"dcmi_license_metadata"):
return OK
var dcmi_data: DCMILicenseMetadata = node.get_meta(&"dcmi_license_metadata")
var dcmi_dict = dcmi_data.to_dictionary()
if dcmi_dict.is_empty():
return OK
# Insert the DCMI dictionary in the state packets and reference it on the node.
var state_packets: Array = _get_or_create_state_packets_in_state(gltf_state)
var node_extensions: Dictionary
# TODO: = node_json.get_or_set_default("extensions", {})
if node_json.has("extensions"):
node_extensions = node_json["extensions"]
else:
node_extensions = {}
node_json["extensions"] = node_extensions
node_extensions["KHR_xmp_json_ld"] = {
"packet": state_packets.size()
}
state_packets.append(dcmi_dict)
return OK


func _export_post(gltf_state: GLTFState) -> Error:
var state_json: Dictionary = gltf_state.json
var state_packets = _get_state_packets_in_state_if_present(gltf_state)
if state_packets == null or state_packets.is_empty():
return OK
if not state_packets[0].has("dc:format"):
return OK
# Reference the DCMI XMP JSON dictionary in the asset.
var asset: Dictionary = state_json["asset"]
var asset_extensions: Dictionary
# TODO: = asset.get_or_set_default("extensions", {})
if asset.has("extensions"):
asset_extensions = asset["extensions"]
else:
asset_extensions = {}
asset["extensions"] = asset_extensions
asset_extensions["KHR_xmp_json_ld"] = {
"packet": 0
}
return OK


func _get_state_packets_in_state_if_present(gltf_state: GLTFState): # -> Array?
var state_json: Dictionary = gltf_state.json
if not state_json.has("extensions"):
return null
var state_extensions: Dictionary = state_json["extensions"]
if not state_extensions.has("KHR_xmp_json_ld"):
return null
var khr_xmp_ext: Dictionary = state_extensions["KHR_xmp_json_ld"]
return khr_xmp_ext["packets"]


func _get_or_create_state_packets_in_state(gltf_state: GLTFState) -> Array:
var state_json = gltf_state.get_json()
var state_extensions: Dictionary
if state_json.has("extensions"):
state_extensions = state_json["extensions"]
else:
state_extensions = {}
state_json["extensions"] = state_extensions
var omi_physics_joint_doc_ext: Dictionary
if state_extensions.has("KHR_xmp_json_ld"):
omi_physics_joint_doc_ext = state_extensions["KHR_xmp_json_ld"]
else:
omi_physics_joint_doc_ext = {}
state_extensions["KHR_xmp_json_ld"] = omi_physics_joint_doc_ext
gltf_state.add_used_extension("KHR_xmp_json_ld", false)
var state_packets: Array
if omi_physics_joint_doc_ext.has("packets"):
state_packets = omi_physics_joint_doc_ext["packets"]
else:
state_packets = []
omi_physics_joint_doc_ext["packets"] = state_packets
return state_packets
Loading

0 comments on commit 4bb762e

Please sign in to comment.