Initial commit
This commit is contained in:
@@ -0,0 +1,373 @@
|
||||
@tool
|
||||
extends RefCounted
|
||||
class_name CghAsset
|
||||
## Helpers to read a Strapi asset dict and turn its files[] / green_screen[]
|
||||
## into structured, Godot-relevant download entries.
|
||||
|
||||
static func attrs(asset: Dictionary) -> Dictionary:
|
||||
return asset.get("attributes", asset)
|
||||
|
||||
static func title(asset: Dictionary) -> String:
|
||||
return attrs(asset).get("Title", "Untitled")
|
||||
|
||||
static func id(asset: Dictionary) -> String:
|
||||
var raw = asset.get("id", null)
|
||||
if raw == null:
|
||||
raw = attrs(asset).get("assets_slug", title(asset))
|
||||
# JSON parses numeric ids as float -> str(2139.0) = "2139.0", which breaks the
|
||||
# /assets/<id> URL and the cross-addon manifest. Always render whole ids as ints.
|
||||
if raw is float:
|
||||
return str(int(raw))
|
||||
if raw is int:
|
||||
return str(raw)
|
||||
return str(raw)
|
||||
|
||||
## Thumbnail URL — mirrors AE's AU.thumb() + Blender's _normalize_media_url():
|
||||
## accepts a plain string OR a Strapi media relation, and prepends the CDN base
|
||||
## only for host-relative ("/uploads/…") URLs.
|
||||
static func thumbnail(asset: Dictionary) -> String:
|
||||
var t = attrs(asset).get("thumbnail", "")
|
||||
var url := ""
|
||||
if t is String:
|
||||
url = t
|
||||
elif t is Dictionary:
|
||||
var d = t.get("data", null)
|
||||
if d is Dictionary:
|
||||
var da = d.get("attributes", d)
|
||||
if da is Dictionary:
|
||||
url = str(da.get("url", ""))
|
||||
elif t.has("url"):
|
||||
url = str(t.get("url", ""))
|
||||
return _normalize_media_url(url)
|
||||
|
||||
static func _normalize_media_url(url: String) -> String:
|
||||
var raw := url.strip_edges()
|
||||
if raw == "":
|
||||
return ""
|
||||
if raw.begins_with("//"):
|
||||
return "https:" + raw
|
||||
if raw.begins_with("/"):
|
||||
return "https://cdn.cgheven.com" + raw
|
||||
return raw
|
||||
|
||||
## Category slug — flat-first, exactly like the AE/Blender/DaVinci addons:
|
||||
## the proxy returns a flat `categorie.Slug`, but tolerate nested `data.attributes`.
|
||||
static func _cat_attrs(asset: Dictionary) -> Dictionary:
|
||||
var c = attrs(asset).get("categorie", {})
|
||||
if not (c is Dictionary):
|
||||
return {}
|
||||
var d = c.get("data", c) # nested Strapi shape -> unwrap, else flat
|
||||
if d is Dictionary:
|
||||
var da = d.get("attributes", d)
|
||||
if da is Dictionary:
|
||||
return da
|
||||
return {}
|
||||
|
||||
static func category_slug(asset: Dictionary) -> String:
|
||||
var da := _cat_attrs(asset)
|
||||
return str(da.get("Slug", da.get("slug", "")))
|
||||
|
||||
## True when this asset is a flipbook. CRITICAL: the category slug may be the PARENT
|
||||
## ("flipbooks") OR a SUBCATEGORY ("fire-flipbooks", "muzzle-flashes-flipbooks",
|
||||
## "tornado-fx-flipbooks", "magic-effects-flipbooks", …) — so match the "flipbook" TOKEN,
|
||||
## not exact equality. Before this, subcategory flipbooks silently missed the sprite-sheet
|
||||
## hover animation + PNG-preference + EXR fallback and fell back to the janky still-slideshow.
|
||||
static func is_flipbook(asset: Dictionary) -> bool:
|
||||
return category_slug(asset).to_lower().contains("flipbook")
|
||||
|
||||
## True when this asset is an HDRI (parent "hdri" or a subcategory like "space-hdr").
|
||||
static func is_hdri(asset: Dictionary) -> bool:
|
||||
var s := category_slug(asset).to_lower()
|
||||
return s == "hdri" or s.contains("hdr")
|
||||
|
||||
## Human-readable asset slug, like the Blender addon (assets_slug, fallbacks).
|
||||
static func assets_slug(asset: Dictionary) -> String:
|
||||
var a := attrs(asset)
|
||||
var s = a.get("assets_slug", a.get("Assets_slug", a.get("asset_slug", a.get("slug", ""))))
|
||||
return str(s).strip_edges()
|
||||
|
||||
## Canonical website URL — matches Blender: https://cgheven.com/assets/<assets_slug>.
|
||||
## Falls back to the numeric id only if there's no usable slug.
|
||||
static func web_url(asset: Dictionary) -> String:
|
||||
var slug := assets_slug(asset).split("/")[0].split("?")[0].strip_edges().to_lower()
|
||||
var rx := RegEx.new()
|
||||
rx.compile("^[a-z0-9_-]+$")
|
||||
if slug != "" and rx.search(slug) != null:
|
||||
return CghConfig.web_base() + "/assets/" + slug
|
||||
return CghConfig.web_base() + "/assets/" + id(asset)
|
||||
|
||||
static func category_title(asset: Dictionary) -> String:
|
||||
var da := _cat_attrs(asset)
|
||||
var t := str(da.get("Title", da.get("Name", "")))
|
||||
return t if t != "" else "Asset"
|
||||
|
||||
## Hover-preview image URLs (additional_images[] + previews). Godot can't play
|
||||
## the mp4/webm preview VIDEO, so we cycle these stills as a slideshow instead.
|
||||
static func preview_images(asset: Dictionary) -> Array:
|
||||
var a := attrs(asset)
|
||||
var out := []
|
||||
var add = a.get("additional_images", [])
|
||||
if add is Array:
|
||||
for u in add:
|
||||
if u is String and u != "":
|
||||
out.append(u)
|
||||
var prev = a.get("previews", "")
|
||||
if prev is String and prev != "" and not prev.to_lower().ends_with(".mp4") \
|
||||
and not prev.to_lower().ends_with(".webm") and not prev.to_lower().ends_with(".mov"):
|
||||
out.append(prev)
|
||||
return out
|
||||
|
||||
## Flipbook sprite-sheet grid (cols x rows) parsed from the asset's file names /
|
||||
## previews / thumbnail (e.g. "..._8x8.png"). Vector2i(0,0) when unknown. Lets the card
|
||||
## animate the flipbook on hover by slicing its sprite-sheet thumbnail into frames.
|
||||
static func flipbook_grid(asset: Dictionary) -> Vector2i:
|
||||
# File-name grid — used to slice the REAL downloaded sheet in the importer.
|
||||
var cands := []
|
||||
for e in file_entries(asset):
|
||||
cands.append(str(e.get("filename", "")))
|
||||
var a := attrs(asset)
|
||||
cands.append(str(a.get("previews", "")))
|
||||
cands.append(thumbnail(asset))
|
||||
for c in cands:
|
||||
var g := _grid_from(str(c))
|
||||
if g.x >= 1 and g.y >= 1:
|
||||
return g
|
||||
return Vector2i(0, 0)
|
||||
|
||||
## Grid used to slice the THUMBNAIL for the hover animation. The thumbnail's own layout
|
||||
## can differ from the downloadable sheet, so read the thumbnail/preview URL FIRST and
|
||||
## only fall back to file names. This fixes flipbooks whose hover preview looked wrong
|
||||
## because we were slicing the thumbnail by the (different) full-res sheet's grid.
|
||||
static func flipbook_thumb_grid(asset: Dictionary) -> Vector2i:
|
||||
var a := attrs(asset)
|
||||
var cands := [thumbnail(asset), str(a.get("previews", ""))]
|
||||
for e in file_entries(asset):
|
||||
cands.append(str(e.get("filename", "")))
|
||||
for c in cands:
|
||||
var g := _grid_from(str(c))
|
||||
if g.x >= 1 and g.y >= 1:
|
||||
return g
|
||||
return Vector2i(0, 0)
|
||||
|
||||
## Parse an NxM sprite-sheet grid from a filename/URL. Scans ALL "AxB" tokens and keeps
|
||||
## the last one where BOTH numbers are small (<=32) — a real grid like "_6x6". This skips
|
||||
## pixel dimensions such as "1920x1080" or "_4K_" that used to be grabbed as the grid
|
||||
## (returning the first match only), which produced 36 garbage frames from a resized sheet.
|
||||
static func _grid_from(text: String) -> Vector2i:
|
||||
var rx := RegEx.new()
|
||||
rx.compile("(\\d+)\\s*[xX]\\s*(\\d+)")
|
||||
var best := Vector2i(0, 0)
|
||||
for m in rx.search_all(text):
|
||||
var cols := int(m.get_string(1))
|
||||
var rows := int(m.get_string(2))
|
||||
if cols >= 1 and rows >= 1 and cols <= 32 and rows <= 32:
|
||||
best = Vector2i(cols, rows) # keep the LAST small token (grid is the trailing _NxM)
|
||||
return best
|
||||
|
||||
static func is_new(asset: Dictionary) -> bool:
|
||||
# treat early_access>0 or a releaseDate within ~21 days as "NEW"
|
||||
var a := attrs(asset)
|
||||
return int(a.get("early_access", 0)) > 0
|
||||
|
||||
static func patreon_locked(asset: Dictionary) -> bool:
|
||||
return attrs(asset).get("is_patreon_locked", false) == true
|
||||
|
||||
## Returns an array of file entries:
|
||||
## {url, filename, ext, res, res_rank, format, locked}
|
||||
## Mirrors AE/Blender/DaVinci parseFiles(): per-file CGHLOCKED:: gating, resolution
|
||||
## parsed from the filename, and 3D format detected inside .zip archives. Each file
|
||||
## entry may be a plain URL string OR a Strapi media object {url}.
|
||||
static func file_entries(asset: Dictionary) -> Array:
|
||||
var out := []
|
||||
var a := attrs(asset)
|
||||
for key in ["files", "green_screen"]:
|
||||
var arr = a.get(key, [])
|
||||
if not (arr is Array):
|
||||
continue
|
||||
for raw in arr:
|
||||
var u := ""
|
||||
if raw is String:
|
||||
u = raw
|
||||
elif raw is Dictionary:
|
||||
u = str(raw.get("url", ""))
|
||||
if u == "":
|
||||
continue
|
||||
var locked: bool = u.begins_with(CghConfig.LOCK_PREFIX)
|
||||
var real: String = u.substr(CghConfig.LOCK_PREFIX.length()) if locked else u
|
||||
var fname: String = real.get_file()
|
||||
var q := fname.find("?") # strip any query string
|
||||
if q != -1:
|
||||
fname = fname.substr(0, q)
|
||||
var ext: String = fname.get_extension().to_lower()
|
||||
var res := detect_res(fname)
|
||||
var fmt := ext.to_upper()
|
||||
if ext == "zip" or ext == "rar":
|
||||
var zf := _archive_format(fname) # FBX/GLTF/OBJ/BLEND from the archive name
|
||||
if zf != "":
|
||||
fmt = zf
|
||||
if key == "green_screen":
|
||||
fmt += " (GS)"
|
||||
out.append({
|
||||
"url": ("" if locked else real),
|
||||
"filename": fname,
|
||||
"ext": ext,
|
||||
"res": res,
|
||||
"res_rank": res_rank(res),
|
||||
"format": fmt,
|
||||
"locked": locked,
|
||||
"preview": false,
|
||||
})
|
||||
# 3D models ship only as .zip/.rar archives; the directly Godot-importable model is
|
||||
# the preview .glb in `previews`. Add it so 3D assets are importable (no "No file"),
|
||||
# unless the whole asset is patreon-locked (then it stays Upgrade-only).
|
||||
var prev = a.get("previews", "")
|
||||
if prev is String and prev != "" and not patreon_locked(asset):
|
||||
var pf: String = prev
|
||||
var pq2 := pf.find("?")
|
||||
if pq2 != -1:
|
||||
pf = pf.substr(0, pq2)
|
||||
var pext := pf.get_file().get_extension().to_lower()
|
||||
if pext in ["glb", "gltf", "fbx"]:
|
||||
out.append({
|
||||
"url": prev,
|
||||
"filename": pf.get_file(),
|
||||
"ext": pext,
|
||||
"res": "",
|
||||
"res_rank": 1, # above a real full-res file (rank 0) so that's preferred
|
||||
"format": pext.to_upper(),
|
||||
"locked": false,
|
||||
"preview": true,
|
||||
})
|
||||
return out
|
||||
|
||||
## Format encoded in an archive's filename (CGHEVEN names them _Fbx/_Blend/_Gltf/_Obj).
|
||||
static func _archive_format(fname: String) -> String:
|
||||
var fu := fname.to_upper()
|
||||
if fu.find("_GLTF") != -1 or fu.find("_GLB") != -1:
|
||||
return "GLTF"
|
||||
if fu.find("_FBX") != -1:
|
||||
return "FBX"
|
||||
if fu.find("_OBJ") != -1:
|
||||
return "OBJ"
|
||||
if fu.find("_USDC") != -1 or fu.find("_USDA") != -1 or fu.find("_USDZ") != -1 or fu.find("_USD") != -1:
|
||||
return "USD" # Godot 4.7 has no native USD import -> treat as non-importable
|
||||
if fu.find("_BLEND") != -1: # also matches _BLENDER
|
||||
return "BLEND"
|
||||
return ""
|
||||
|
||||
## Only the entries Godot can actually import:
|
||||
## - direct glb/gltf/fbx/exr/hdr/png/jpg/webp (incl. the preview .glb)
|
||||
## - a .zip Godot can unpack (ZIPReader) UNLESS it's a Blender-only archive (.blend inside)
|
||||
## - never .rar (Godot can't extract it) or a BLEND archive
|
||||
static func importable_entries(asset: Dictionary) -> Array:
|
||||
var direct := CghConfig.EXT_3D + CghConfig.EXT_HDRI + CghConfig.EXT_IMAGE
|
||||
var out := []
|
||||
for e in file_entries(asset):
|
||||
var ext: String = e["ext"]
|
||||
if ext in direct:
|
||||
out.append(e)
|
||||
elif ext == "zip" and not (e["format"] in ["BLEND", "USD"]):
|
||||
# .blend needs Blender; .usd/.usdc has no native Godot 4.7 importer — skip both so
|
||||
# the addon never auto-picks a zip whose only model Godot can't read ("no importable
|
||||
# file inside"). fbx/gltf/glb/obj zips still qualify.
|
||||
out.append(e)
|
||||
return out
|
||||
|
||||
## True if at least one file is unlocked (-> Download, not Upgrade).
|
||||
## Matches AE's hasFreeFile()/lockedForUser(): lock is purely server-driven via the
|
||||
## CGHLOCKED:: marker, so we only trust per-file `locked`. The backend already frees
|
||||
## the lowest available resolution (e.g. 4K is free on a 4K/8K-only asset).
|
||||
static func has_free_file(asset: Dictionary) -> bool:
|
||||
for e in file_entries(asset):
|
||||
if not e["locked"]:
|
||||
return true
|
||||
return false
|
||||
|
||||
## Best free entry: lowest-resolution unlocked importable file. For flipbooks, prefer a
|
||||
## PNG/JPG/WebP sheet over EXR — Godot's EXR loader (tinyexr) can't read some compressions,
|
||||
## so an EXR flipbook fails to import while the PNG version always works.
|
||||
static func best_free_entry(asset: Dictionary) -> Dictionary:
|
||||
var cands := importable_entries(asset).filter(func(e): return not e["locked"])
|
||||
if cands.is_empty():
|
||||
return {}
|
||||
if is_flipbook(asset):
|
||||
var imgs := cands.filter(func(e): return e["ext"] in ["png", "jpg", "jpeg", "webp"])
|
||||
if not imgs.is_empty():
|
||||
cands = imgs
|
||||
# Lowest resolution first; at the SAME resolution prefer a Godot-native model format
|
||||
# (gltf/glb > fbx > obj) so a 3D asset never auto-picks a format Godot imports poorly.
|
||||
cands.sort_custom(func(a, b):
|
||||
if int(a["res_rank"]) != int(b["res_rank"]):
|
||||
return int(a["res_rank"]) < int(b["res_rank"])
|
||||
return _fmt_priority(str(a["format"])) < _fmt_priority(str(b["format"])))
|
||||
return cands[0]
|
||||
|
||||
## Godot-import preference among 3D model formats (lower = better). Non-model formats tie at 3.
|
||||
static func _fmt_priority(fmt: String) -> int:
|
||||
var f := fmt.to_upper()
|
||||
if f == "GLTF" or f == "GLB":
|
||||
return 0
|
||||
if f == "FBX":
|
||||
return 1
|
||||
if f == "OBJ":
|
||||
return 2
|
||||
return 3
|
||||
|
||||
## Lowest-resolution UNLOCKED file of ANY format (even ones Godot can't auto-import,
|
||||
## e.g. .rar). Fallback so a card whose only free file is non-importable still DOWNLOADS
|
||||
## it to the shared folder (user can extract/use it) instead of the click doing nothing.
|
||||
static func best_free_any(asset: Dictionary) -> Dictionary:
|
||||
var cands := file_entries(asset).filter(func(e): return not e["locked"] and e["url"] != "")
|
||||
if cands.is_empty():
|
||||
return {}
|
||||
cands.sort_custom(func(a, b): return a["res_rank"] < b["res_rank"])
|
||||
return cands[0]
|
||||
|
||||
# Resolution detection — ported from the desktop addons (RES_PATTERNS). Dimension
|
||||
# patterns first (most specific), then word/number tokens. First match wins.
|
||||
const _RES_DEFS := [
|
||||
["7680\\s*[x×]\\s*4320", "8K"],
|
||||
["(4096|3840)\\s*[x×]\\s*2160", "4K"],
|
||||
["(2560\\s*[x×]\\s*1440|2048\\s*[x×]\\s*1080)", "2K"],
|
||||
["1920\\s*[x×]\\s*1080", "1080p"],
|
||||
# ALL K resolutions must be recognised. A missing one (e.g. 6K) resolves to "" -> rank 0,
|
||||
# which best_free_entry then treats as the LOWEST/"best" file — so a logged-in (unlocked)
|
||||
# user would auto-pick the 6K DWAA HDRI Godot can't read. Cover 1K..16K explicitly.
|
||||
["(?<![a-z0-9])16\\s*k(?![a-z0-9])|16384", "16K"],
|
||||
["(?<![a-z0-9])12\\s*k(?![a-z0-9])|12288", "12K"],
|
||||
["(?<![a-z0-9])8\\s*k(?![a-z0-9])|8192", "8K"],
|
||||
["(?<![a-z0-9])6\\s*k(?![a-z0-9])|6144", "6K"],
|
||||
["(?<![a-z0-9])5\\s*k(?![a-z0-9])|5120", "5K"],
|
||||
["(?<![a-z0-9])4\\s*k(?![a-z0-9])|4096|uhd", "4K"],
|
||||
["(?<![a-z0-9])3\\s*k(?![a-z0-9])|3072", "3K"],
|
||||
["(?<![a-z0-9])2\\s*k(?![a-z0-9])|2048", "2K"],
|
||||
["(?<![a-z0-9])1\\s*k(?![a-z0-9])|1024", "1K"],
|
||||
["2160", "4K"],
|
||||
["1080", "1080p"],
|
||||
["720", "720p"],
|
||||
["(?<![a-z0-9])hd(?![a-z0-9])", "HD"],
|
||||
]
|
||||
# Low-first rank used to pick the "best free entry" (1K before 4K), like the addons.
|
||||
const _RES_RANK := {
|
||||
"": 0, "PREVIEW": 1, "480P": 2, "720P": 3, "HD": 3,
|
||||
"1080P": 4, "1K": 5, "2K": 6, "3K": 7, "4K": 8, "5K": 9, "6K": 10, "8K": 11, "12K": 12, "16K": 13,
|
||||
}
|
||||
static var _res_rx := [] # [{rx, label}] compiled lazily
|
||||
|
||||
static func _ensure_res_rx() -> void:
|
||||
if not _res_rx.is_empty():
|
||||
return
|
||||
for d in _RES_DEFS:
|
||||
var rx := RegEx.new()
|
||||
rx.compile("(?i)" + d[0]) # case-insensitive
|
||||
_res_rx.append({"rx": rx, "label": d[1]})
|
||||
|
||||
static func detect_res(fname: String) -> String:
|
||||
_ensure_res_rx()
|
||||
for e in _res_rx:
|
||||
if e["rx"].search(fname) != null:
|
||||
return e["label"]
|
||||
return ""
|
||||
|
||||
static func res_rank(label: String) -> int:
|
||||
return int(_RES_RANK.get(label.to_upper(), 0))
|
||||
Reference in New Issue
Block a user