Initial commit

This commit is contained in:
scorpion21211-cmd
2026-10-03 23:29:41 -04:00
commit 965f0d7584
10586 changed files with 1030294 additions and 0 deletions
+373
View File
@@ -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))