374 lines
15 KiB
GDScript
374 lines
15 KiB
GDScript
@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))
|