Files
Brachistochrone/addons/cgheven/core/asset_model.gd
T
scorpion21211-cmd e24bbbda5d Initial commit
2026-10-03 23:29:41 -04:00

374 lines
15 KiB
GDScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@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))