--[[ Tripoanim - type a motion, get a KeyframeSequence on the selected rig. Install: copy this file to %LOCALAPPDATA%\Roblox\Plugins\Tripoanim.lua Open https://tripointgames.com/tripoanim/app, create a pairing code under Conectar Studio, and paste it here. No local service is needed in cloud mode. Developers can select local mode and run: python service/app.py The flow, and why it has two buttons: type the motion -> [Consultar preco] -> [Gerar (3 creditos)] Generating costs real credits, so the plugin cannot ask for one directly. It asks the service what the clip would cost, shows that, and only the second button spends - and it can only spend because the first call handed back a one-use token. The gate is the service's (see service/app.py); this widget is just the honest face of it. Generation outlives Studio's ~30 s HTTP timeout, so the spend returns a job id and this polls it. WHERE THE POSE TREE COMES FROM The service sends poses named after PARTS (`LowerTorso`, `LeftUpperArm`) plus the hierarchy to nest them in - a flat Pose list does not animate. The tree is the service's and not read off the rig on purpose: measured on six R15 rigs in this place on 2026-09-04, a layered-clothing rig attaches its shoulder and neck constraints to BONES inside the mesh (`LeftClavicle`, `Chest`), so a tree derived from the rig's own Part0/Part1 graph would hang the arms and head off names no Pose can bind to. What the rig IS asked is what actually binds: a Pose binds to the joint whose Part1 carries its name, so this collects those names and reports any pose that will not land. All six rigs expose the same fifteen - and never HumanoidRootPart, which anchors the tree and drives nothing. That check matters because the failure it catches looks exactly like a healthy import: full timeline, correct rotations, nothing moves. ]] -- The distributable is a plugin script, not a runtime game script. if not plugin then return end local ChangeHistoryService = game:GetService("ChangeHistoryService") local HttpService = game:GetService("HttpService") local KeyframeSequenceProvider = game:GetService("KeyframeSequenceProvider") local Selection = game:GetService("Selection") local RunService = game:GetService("RunService") -- BEGIN EMBEDDED RIG CAPTURE local RigCapture = (function() -- Read-only R15 measurement. Kept as a module for Studio regression tests; -- The package builder preserves this readable source without remote modules. local Capture = {} local parents = { HumanoidRootPart = false, LowerTorso = "HumanoidRootPart", UpperTorso = "LowerTorso", Head = "UpperTorso", LeftUpperArm = "UpperTorso", LeftLowerArm = "LeftUpperArm", LeftHand = "LeftLowerArm", RightUpperArm = "UpperTorso", RightLowerArm = "RightUpperArm", RightHand = "RightLowerArm", LeftUpperLeg = "LowerTorso", LeftLowerLeg = "LeftUpperLeg", LeftFoot = "LeftLowerLeg", RightUpperLeg = "LowerTorso", RightLowerLeg = "RightUpperLeg", RightFoot = "RightLowerLeg", } local order = {"HumanoidRootPart", "LowerTorso", "UpperTorso", "Head", "LeftUpperArm", "LeftLowerArm", "LeftHand", "RightUpperArm", "RightLowerArm", "RightHand", "LeftUpperLeg", "LeftLowerLeg", "LeftFoot", "RightUpperLeg", "RightLowerLeg", "RightFoot"} local function array(cf) return {cf:GetComponents()} end local function vector(v) return {v.X, v.Y, v.Z} end local function attachmentFrame(attachment) local offset = CFrame.identity local node = attachment -- Bone.CFrame is the bind transform. Bone.Transform and the model's current -- animation/world placement must never become the next generation's rest pose. while node and not node:IsA("BasePart") do if not node:IsA("Attachment") then return nil end offset = node.CFrame * offset node = node.Parent end return node, offset end function Capture.measure(rig) local humanoid = rig and rig:FindFirstChildOfClass("Humanoid") if not humanoid or humanoid.RigType ~= Enum.HumanoidRigType.R15 then return nil, "Selecione um personagem R15 com Humanoid." end local found, joints, parts, rest = {}, {}, {}, {HumanoidRootPart = CFrame.identity} for _, name in ipairs(order) do local part = rig:FindFirstChild(name) if not part or not part:IsA("BasePart") then return nil, "Parte R15 ausente: " .. name end found[name] = part end for _, joint in ipairs(rig:GetDescendants()) do local p0, p1, c0, c1 if joint:IsA("Motor6D") then p0, p1, c0, c1 = joint.Part0, joint.Part1, joint.C0, joint.C1 elseif joint:IsA("AnimationConstraint") and joint.Attachment0 and joint.Attachment1 then p0, c0 = attachmentFrame(joint.Attachment0) p1, c1 = attachmentFrame(joint.Attachment1) end if p0 and p1 and found[p1.Name] == p1 then if found[parents[p1.Name]] ~= p0 or joints[p1.Name] then return nil, "Encaixe R15 ambíguo: " .. p1.Name end joints[p1.Name] = {parent = p0.Name, c0 = array(c0), c1 = array(c1), kind = joint.ClassName} end end for _, name in ipairs(order) do local joint = joints[name] if parents[name] then if not joint then return nil, "Junta R15 ausente: " .. name end rest[name] = rest[joint.parent] * CFrame.new(table.unpack(joint.c0)) * CFrame.new(table.unpack(joint.c1)):Inverse() end local p = found[name] parts[name] = {size = vector(p.Size), rest = array(rest[name]), color = vector(Vector3.new(p.Color.R, p.Color.G, p.Color.B))} end local scales = {} for _, value in ipairs(humanoid:GetChildren()) do if value:IsA("NumberValue") and value.Name:match("Scale$") then scales[value.Name] = value.Value end end return {schema = "tripoanim.r15.v1", name = rig.Name, units = "studs", order = order, parts = parts, joints = joints, hip_height = humanoid.HipHeight, scales = scales} end function Capture.fingerprint(profile) local values = {profile.name, profile.hip_height} for _, name in ipairs(order) do local part, joint = profile.parts[name], profile.joints[name] table.insert(values, {name, part.size, part.rest, part.color, joint and joint.c0 or {}, joint and joint.c1 or {}}) end return game:GetService("HttpService"):JSONEncode(values) end return Capture end)() -- END EMBEDDED RIG CAPTURE -- BEGIN EMBEDDED ROOT TRACK local RootTrack = (function() -- Pure root-motion sidecar operations. This file deliberately does not read -- Studio services so its timestamp sampling and unit conversion can be tested -- independently of a place. local RootTrack = {} local function finite(value) return type(value) == "number" and value == value and math.abs(value) < math.huge end local function sample(rows, time, read, shortestAngle) if type(rows) ~= "table" or #rows == 0 or not finite(time) then return nil end local first, last = rows[1], rows[#rows] if type(first.t) ~= "number" or type(last.t) ~= "number" then return nil end if time <= first.t then local value = read(first) return finite(value) and value or nil end if time >= last.t then local value = read(last) return finite(value) and value or nil end for index = 1, #rows - 1 do local a, b = rows[index], rows[index + 1] if not finite(a.t) or not finite(b.t) then return nil end if time >= a.t and time <= b.t then local av, bv = read(a), read(b) if not finite(av) or not finite(bv) then return nil end local span = b.t - a.t if span <= 0 then return av end local alpha = (time - a.t) / span if shortestAngle then -- Never turn almost a full circle while crossing the -pi/pi seam. local delta = (bv - av + math.pi) % (2 * math.pi) - math.pi return av + delta * alpha end return av + (bv - av) * alpha end end local value = read(last) return finite(value) and value or nil end function RootTrack.channelAt(rows, time, read) return sample(rows, time, read, false) end function RootTrack.angleAt(rows, time, read) return sample(rows, time, read, true) end function RootTrack.poseAt(clip, time) local path = clip and clip.rootPath if type(path) ~= "table" or #path == 0 then return nil end local function coordinate(axis) return RootTrack.channelAt(path, time, function(row) return type(row.pos) == "table" and row.pos[axis] or nil end) or 0 end return { x = coordinate("x"), y = coordinate("y"), z = coordinate("z"), yaw = RootTrack.angleAt(path, time, function(row) return row.yaw end) or 0, height = RootTrack.channelAt(clip.visualRootHeight, time, function(row) return row.y end), } end -- Returns fresh channel tables. The caller may safely retry after failure: -- neither the clip nor its source sidecar is modified here. function RootTrack.convertMetres(track, rootPath, visualRootHeight, target) if type(track) ~= "table" or track.units == nil or track.units == "studs" then return {rootPath = rootPath, visualRootHeight = visualRootHeight, track = track} end if track.units ~= "metres" or type(track.sourceMeasurement) ~= "table" then return nil, "root motion tem unidades incompativeis; reexporte com medidas do rig" end if type(rootPath) ~= "table" or type(visualRootHeight) ~= "table" then return nil, "root motion sem os dois canais obrigatorios" end target = target or {} local sourceLeg = tonumber(track.sourceMeasurement.legLength) local sourceRest = tonumber(track.sourceMeasurement.restRootHeight) local targetLeg, targetRest = tonumber(target.legLength), tonumber(target.restRootHeight) if not finite(sourceLeg) or sourceLeg <= 1e-6 or not finite(sourceRest) then return nil, "root motion em metros nao informa perna/altura de repouso" end if not finite(targetLeg) or targetLeg <= 1e-6 or not finite(targetRest) then return nil, "nao foi possivel medir a escala do Avatar R15" end local ratio = targetLeg / sourceLeg if not finite(ratio) or ratio <= 0 then return nil, "escala de root motion invalida" end local convertedPath, convertedHeight = {}, {} for _, row in ipairs(rootPath) do local pos = row.pos if type(row) ~= "table" or not finite(row.t) or type(pos) ~= "table" or not finite(pos.x) or not finite(pos.y) or not finite(pos.z) or not finite(row.yaw) then return nil, "rootPath invalido" end convertedPath[#convertedPath + 1] = { t = row.t, pos = {x = pos.x * ratio, y = pos.y * ratio, z = pos.z * ratio}, yaw = row.yaw, } end for _, row in ipairs(visualRootHeight) do if type(row) ~= "table" or not finite(row.t) or not finite(row.y) then return nil, "visualRootHeight invalido" end convertedHeight[#convertedHeight + 1] = { t = row.t, y = targetRest + (row.y - sourceRest) * ratio, } end return { rootPath = convertedPath, visualRootHeight = convertedHeight, track = { units = "studs", sourceMeasurement = track.sourceMeasurement, targetMeasurement = {legLength = targetLeg, restRootHeight = targetRest}, conversion = {mode = "target_leg_ratio", ratio = ratio}, }, } end return RootTrack end)() -- END EMBEDDED ROOT TRACK local BRAND = "TripoAnim" local BASE = { ["local"] = "http://127.0.0.1:8765", cloud = "https://kxxwyqmmcbkowxettxvs.supabase.co/functions/v1", } -- The project's anon key. It is PUBLIC by design - the website ships the same -- value in its own bundle - and it is here only to satisfy the platform's JWT -- gate. The plugin's real identity is the device token in x-tripoanim-token. -- Paste the project's legacy anon JWT from the website's .env. local ANON_KEY = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imt4eHd5cW1tY2Jrb3d4ZXR0eHZzIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzQ4NTkxNjMsImV4cCI6MjA5MDQzNTE2M30.bTgiJZvmgOA9A48zEs4A-Ld3nhK0N4M7H-w6VCwHaVw" local POLL_SECONDS = 1.5 -- Generation continues on the server without us, so this is not a deadline on -- the clip - only on how long the widget keeps watching. local POLL_GIVE_UP = 420 -- BEGIN REMOTE VALIDATION -- JSON is data only. Validate it before allocating any Studio instances. -- These pure functions stay isolated for the Studio regression harness. local Validation = (function() local V = { MAX_BYTES = 4 * 1024 * 1024, MAX_FRAMES = 2000 } local parents = { LowerTorso = "HumanoidRootPart", UpperTorso = "LowerTorso", Head = "UpperTorso", LeftUpperArm = "UpperTorso", LeftLowerArm = "LeftUpperArm", LeftHand = "LeftLowerArm", RightUpperArm = "UpperTorso", RightLowerArm = "RightUpperArm", RightHand = "RightLowerArm", LeftUpperLeg = "LowerTorso", LeftLowerLeg = "LeftUpperLeg", LeftFoot = "LeftLowerLeg", RightUpperLeg = "LowerTorso", RightLowerLeg = "RightUpperLeg", RightFoot = "RightLowerLeg", } function V.number(value, low, high) return type(value) == "number" and value == value and value >= low and value <= high end function V.text(value, fallback, limit) if type(value) ~= "string" or #value > (limit or 800) or value:find("[%z\1-\8\11\12\14-\31]") then return fallback end return value end function V.uuid(value) if type(value) ~= "string" or #value ~= 36 then return false end return value:match("^%x%x%x%x%x%x%x%x%-%x%x%x%x%-[1-5]%x%x%x%-[89aAbB]%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x$") ~= nil end function V.token(value) return type(value) == "string" and #value == 43 and value:match("^[%w_-]+$") ~= nil end function V.id(value, mode) return mode == "cloud" and V.uuid(value) or (mode == "local" and type(value) == "string" and #value > 0 and #value <= 80 and value:match("^[%w_-]+$") ~= nil) end function V.array(value, maximum, minimum) if type(value) ~= "table" then return false end local count = 0 for index in pairs(value) do if not V.number(index, 1, maximum) or index % 1 ~= 0 then return false end count += 1 end return count >= (minimum or 0) and count <= maximum and count == #value end function V.signedUrl(url) if type(url) ~= "string" or #url > 4096 then return false end -- Fixed HTTPS origin, bucket, UUID owner and filename. Reject redirects, -- userinfo, alternate ports, fragments and encoded traversal entirely. local owner, file, token = url:match("^https://kxxwyqmmcbkowxettxvs%.supabase%.co/storage/v1/object/sign/tripoanim%-clips/([%x%-]+)/([%x%-]+)%.json%?token=([%w_.%-]+)$") return V.uuid(owner) and V.uuid(file) and type(token) == "string" and #token > 0 end function V.quote(quote, mode) if type(quote) ~= "table" or not V.id(quote.quote_id, mode) or not V.number(quote.credits, 0, 10000000) or quote.credits % 1 ~= 0 or not V.number(quote.duration, 2, 10) or not V.text(quote.prompt, nil, 1600) or (quote.notes ~= nil and not V.array(quote.notes, 20)) then return false end for _, note in ipairs(quote.notes or {}) do if not V.text(note, nil, 800) then return false end end return (quote.tier == nil or V.text(quote.tier, nil, 80) ~= nil) and (quote.rig == nil or V.text(quote.rig, nil, 80) ~= nil) end function V.job(job, mode) if type(job) ~= "table" or type(job.status) ~= "string" or not ({queued = true, running = true, done = true, error = true})[job.status] or (job.clip_id ~= nil and not V.id(job.clip_id, mode)) then return false end return (job.stage == nil or V.text(job.stage, nil, 120) ~= nil) and (job.error == nil or V.text(job.error, nil, 500) ~= nil) end local function transform(cf) if not V.array(cf, 12, 12) then return false end for index, value in ipairs(cf) do if not V.number(value, index <= 3 and -10000 or -1.01, index <= 3 and 10000 or 1.01) then return false end end -- Rotation rows must be orthonormal and right handed; small rounding -- differences in old clips remain valid. for row = 0, 2 do local a = 4 + row * 3 if math.abs(cf[a]^2 + cf[a+1]^2 + cf[a+2]^2 - 1) > 0.02 then return false end for other = row + 1, 2 do local b = 4 + other * 3 if math.abs(cf[a]*cf[b] + cf[a+1]*cf[b+1] + cf[a+2]*cf[b+2]) > 0.02 then return false end end end local determinant = cf[4]*(cf[8]*cf[12]-cf[9]*cf[11]) - cf[5]*(cf[7]*cf[12]-cf[9]*cf[10]) + cf[6]*(cf[7]*cf[11]-cf[8]*cf[10]) return math.abs(determinant - 1) <= 0.03 end function V.clip(clip) local invalid = "clipe R15 inválido ou incompatível; baixe novamente pela biblioteca" if type(clip) ~= "table" or not V.text(clip.name, nil) or clip.name == "" or not V.number(clip.fps, 1, 120) or not V.number(clip.duration, 0.001, 60) or (clip.output_format ~= nil and clip.output_format ~= "roblox") or (clip.root ~= nil and clip.root ~= "HumanoidRootPart") or (clip.loop ~= nil and type(clip.loop) ~= "boolean") or type(clip.hierarchy) ~= "table" or not V.array(clip.keyframes, V.MAX_FRAMES, 1) then return nil, invalid end local hierarchyCount = 0 for child, parent in pairs(clip.hierarchy) do if parents[child] ~= parent then return nil, invalid end hierarchyCount += 1 end if hierarchyCount ~= 15 then return nil, invalid end local previous = -1 local drivesJoint = false for _, frame in ipairs(clip.keyframes) do if type(frame) ~= "table" or not V.number(frame.time, 0, clip.duration + 0.001) or frame.time <= previous or not V.array(frame.poses, 16, 1) then return nil, invalid end previous = frame.time local seen = {} for _, pose in ipairs(frame.poses) do if type(pose) ~= "table" or type(pose.part) ~= "string" or seen[pose.part] or (pose.part ~= "HumanoidRootPart" and not parents[pose.part]) or not transform(pose.cf) or (pose.weight ~= nil and not V.number(pose.weight, 0, 1)) then return nil, invalid end seen[pose.part] = true if parents[pose.part] and (pose.weight == nil or pose.weight > 0) then drivesJoint = true end end end if not drivesJoint then return nil, invalid end -- Keep only validated values read by the importer or its status labels. local result = { name = clip.name, fps = clip.fps, duration = clip.duration, root = "HumanoidRootPart", hierarchy = clip.hierarchy, keyframes = clip.keyframes, loop = clip.loop == true, qa = V.text(clip.qa, "-", 500), plant = V.text(clip.plant, "-", 4000), problems = {} } if clip.problems ~= nil then if not V.array(clip.problems, 32) then return nil, invalid end for _, problem in ipairs(clip.problems) do if not V.text(problem, nil, 500) then return nil, invalid end table.insert(result.problems, problem) end end if clip.rootPath ~= nil then if not V.array(clip.rootPath, V.MAX_FRAMES) then return nil, invalid end local previousRoot = -1 result.rootPath = {} for _, row in ipairs(clip.rootPath) do local pos = type(row) == "table" and row.pos if not V.number(row and row.t, 0, clip.duration + .001) or row.t <= previousRoot or type(pos) ~= "table" or not V.number(pos.x, -10000, 10000) or not V.number(pos.y, -10000, 10000) or not V.number(pos.z, -10000, 10000) or not V.number(row.yaw, -10000, 10000) then return nil, invalid end previousRoot = row.t table.insert(result.rootPath, {t = row.t, pos = {x = pos.x, y = pos.y, z = pos.z}, yaw = row.yaw}) end if clip.visualRootHeight ~= nil then if not V.array(clip.visualRootHeight, V.MAX_FRAMES) then return nil, invalid end local previousHeight = -1 result.visualRootHeight = {} for _, row in ipairs(clip.visualRootHeight) do if type(row) ~= "table" or not V.number(row.t, 0, clip.duration + .001) or row.t <= previousHeight or not V.number(row.y, -10000, 10000) then return nil, invalid end previousHeight = row.t table.insert(result.visualRootHeight, {t = row.t, y = row.y}) end end if clip.rootTrack ~= nil then if type(clip.rootTrack) ~= "table" or (clip.rootTrack.units ~= "studs" and clip.rootTrack.units ~= "metres") then return nil, invalid end local source = clip.rootTrack.sourceMeasurement if clip.rootTrack.units == "metres" and (type(source) ~= "table" or not V.number(source.legLength, .000001, 10000) or not V.number(source.restRootHeight, -10000, 10000)) then return nil, invalid end result.rootTrack = {units = clip.rootTrack.units, sourceMeasurement = source and {legLength = source.legLength, restRootHeight = source.restRootHeight} or nil} end result.rootYawInAnimation = clip.rootYawInAnimation == true end return result end return V end)() -- END REMOTE VALIDATION -- Two transports, one caller. The widget says api("quote") and never learns -- which mode is active. Local mode is deliberately identical to what it always -- was, so `python service/app.py` stays a first-class development target. local ROUTES = { ["local"] = { quote = { "POST", "/v1/quote" }, generate = { "POST", "/v1/clips" }, job = { "GET", "/v1/jobs/%s" }, clip = { "GET", "/v1/clips/%s" }, health = { "GET", "/v1/health" }, }, cloud = { quote = { "POST", "/tripoanim-quote" }, generate = { "POST", "/tripoanim-generate" }, job = { "POST", "/tripoanim-jobs", "job_id" }, clip = { "POST", "/tripoanim-clips", "clip_id" }, inbox = { "POST", "/tripoanim-clips" }, claim = { "POST", "/tripoanim-pair-claim" }, characters = { "POST", "/tripoanim-characters" }, metric = { "POST", "/tripoanim-attribution" }, }, } -- --------------------------------------------------------------------------- -- service -- --------------------------------------------------------------------------- local onAuthLost --- Returns decoded body, or nil plus a message. Never throws. -- `path` is relative in a transport mode and absolute for a signed Storage URL; -- authenticated cloud calls add three transport headers while local keeps only -- the Content-Type header it has always sent. local function request(method, path, body, headers, mode) -- Studio owns the plugin's per-domain HTTP permission prompt. Do not -- change the open place's HttpEnabled setting just to connect a plugin. local url = mode and (BASE[mode] .. path) or path local requestHeaders = { ["Content-Type"] = "application/json" } for key, value in pairs(headers or {}) do requestHeaders[key] = value end local ok, response = pcall(function() return HttpService:RequestAsync({ Url = url, Method = method, Headers = requestHeaders, -- An EMPTY Lua table encodes as "[]", not "{}" - the two are the -- same value in Lua and HttpService guesses array. The inbox call -- sends exactly {} and tripoanim-clips rejects an array outright, -- so every "Buscar clipes prontos" answered "Invalid JSON body". Body = body and (next(body) == nil and "{}" or HttpService:JSONEncode(body)) or nil, }) end) if not ok then if mode == "local" then return nil, "serviço local indisponível; confira se o serviço de desenvolvimento está rodando" end -- Raw HTTP errors may contain a signed URL or device headers. return nil, "não foi possível conectar; confira a rede e a permissão HTTP do plugin no Studio" end if type(response) ~= "table" or not Validation.number(response.StatusCode, 100, 599) or type(response.Body) ~= "string" then return nil, "resposta HTTP inválida" end local status = response.StatusCode if status < 200 or status >= 300 then local errors = { [401] = "conexão inválida ou revogada; gere um novo código no site", [403] = "acesso indisponível; confira a conta ou solicite o clipe novamente", [404] = "código ou clipe não encontrado nesta conta", [429] = "muitas solicitações; aguarde e tente novamente" } return nil, errors[status] or ("o serviço respondeu HTTP " .. status .. "; tente novamente"), status end if #response.Body > Validation.MAX_BYTES then return nil, "resposta maior que 4 MiB; o clipe não foi importado", status end local decoded local decodedOk = pcall(function() decoded = HttpService:JSONDecode(response.Body) end) if not decodedOk or type(decoded) ~= "table" then return nil, ("the service answered %d with something that is not JSON") :format(response.StatusCode), response.StatusCode end return decoded, nil, response.StatusCode end --- Which transport is active. Local until the user pairs and switches. local function readSetting(name) local ok, value = pcall(function() return plugin:GetSetting(name) end) return ok and value or nil end local function currentMode() return readSetting("tripoanim_mode") == "local" and "local" or "cloud" end local rejectedToken local function deviceToken() local token = readSetting("tripoanim_token") return Validation.token(token) and token ~= rejectedToken and token or nil end --- One named call in whichever mode is active. `arg` fills a %s in the path, -- which only local mode uses (cloud puts every id in the POST body so nothing -- lands in an access log). local function api(name, body, arg, forceMode) local mode = forceMode or currentMode() local route = ROUTES[mode][name] if not route then return nil, ("%s nao existe no modo %s"):format(name, mode) end local method, path, idField = route[1], route[2], route[3] if arg ~= nil then if path:find("%%s") then if type(arg) ~= "string" or #arg > 80 or not arg:match("^[%w_-]+$") then return nil, "ID local inválido" end path = path:format(arg) elseif idField then if not Validation.uuid(arg) then return nil, "copie o ID completo da biblioteca" end body = { [idField] = arg } end end if mode == "local" then -- The development service has always received its explicit tier. Keeping -- that field here prevents a cloud-only body change from altering it. if name == "quote" then body = { prompt = body.prompt, duration = body.duration, mode = "swift", rig_profile = body.rig_profile } end return request(method, path, body, nil, mode) end if ANON_KEY == "PASTE_THE_SUPABASE_ANON_KEY_HERE" then return nil, "cole a chave anon do projeto em ANON_KEY, no topo do plugin" end local headers = { apikey = ANON_KEY, Authorization = "Bearer " .. ANON_KEY } local token = deviceToken() -- Pair claim is the only pre-identity call. Every other cloud function must -- carry all three headers rather than making an anonymous request it cannot -- authorize. if name ~= "claim" and not token then return nil, "conecte este Studio antes de usar a nuvem" end if name ~= "claim" then headers["x-tripoanim-token"] = token end local data, why, status = request(method, path, body or {}, headers, mode) if status == 401 and name ~= "claim" and token == deviceToken() then rejectedToken = token pcall(function() plugin:SetSetting("tripoanim_token", nil) end) if onAuthLost then onAuthLost() end end return data, why, status end --- A signed Storage URL carries its own credential, so it must be fetched with -- NO Supabase headers. This is the second request of the two-step clip fetch. local function fetchSigned(url) if not Validation.signedUrl(url) then return nil, "URL de clipe inválida; solicite o clipe novamente" end return request("GET", url, nil, nil) end -- A best-effort, device-authenticated confirmation records only that this -- account imported this already-authorized clip. It does not delay editing, -- include the animation document, or report any payment/saldo information. local function recordImported(clipId) if currentMode() ~= "cloud" or not Validation.uuid(clipId) then return end task.spawn(function() api("metric", { action = "plugin_import_confirmed", clip_id = clipId }) end) end --- The inbox, normalized once so its shape cannot leak further. -- tripoanim-clips answers a BARE ARRAY of clip rows whose id column is `id`, -- while every reader here wants entries keyed `clip_id`. Both differences bit -- separately: the array was read as `listing.clips` and came back nil, and the -- entries were read as `entry.clip_id` and came back nil again. One adapter, -- three call sites protected. local function inboxEntries() local listing, why = api("inbox", {}) if not listing then return nil, why end local rows = listing if type(listing.clips) == "table" then rows = listing.clips end if not Validation.array(rows, 20) then return nil, "o servico devolveu uma caixa de entrada invalida" end local entries = {} for _, row in ipairs(rows) do if type(row) ~= "table" then return nil, "caixa de entrada inválida" end local id = row.clip_id or row.id if not Validation.uuid(id) or (row.job_id ~= nil and not Validation.uuid(row.job_id)) or (row.name ~= nil and not Validation.text(row.name, nil)) or not Validation.number(row.bytes, 0, 8 * 1024 * 1024) or not Validation.number(row.duration, 0, 60) then return nil, "caixa de entrada inválida" end if id then table.insert(entries, { clip_id = id, job_id = row.job_id, name = row.name, bytes = row.bytes, duration = row.duration, keyframes = row.keyframes, created_at = row.created_at, }) end end return entries end --- Which clip a finished job produced. -- Compatibility fallback for older services that do not return clip_id yet. local function clipIdForJob(jobId) local entries, why = inboxEntries() if not entries then return nil, why end for _, entry in ipairs(entries) do if entry.job_id == jobId then return entry.clip_id end end return nil, "o clipe terminou mas ainda nao apareceu na caixa de entrada; " .. "use Buscar clipes prontos em alguns segundos" end --- The clip document itself. -- Local mode serves it directly. Cloud needs two hops on purpose: the function -- returns a short-lived signed URL and Storage serves the ~350 KiB body, so the -- document never passes through an Edge Function. local function fetchClip(clipId, forceMode) local mode = forceMode or currentMode() if mode == "local" then local clip, why = api("clip", nil, clipId, mode) if not clip then return nil, why end return Validation.clip(clip) end if not Validation.uuid(clipId) then return nil, "copie o ID completo da biblioteca" end local pointer, why = api("clip", nil, clipId, mode) if not pointer then return nil, why end if not Validation.signedUrl(pointer.url) then return nil, "o servico nao devolveu uma URL assinada" end -- A 4 s clip is ~350 KiB; 10 s is ~875 KiB. Warn on something unexpected -- rather than letting JSONDecode fail opaquely on a huge body. if not Validation.number(pointer.bytes, 1, Validation.MAX_BYTES) or pointer.bytes % 1 ~= 0 then return nil, "tamanho de clipe inválido ou maior que 4 MiB" end local clip, clipWhy = fetchSigned(pointer.url) if not clip then return nil, clipWhy end return Validation.clip(clip) end --- Clips already imported on THIS machine. -- The local set, not a server flag, is the truth about "already imported" - so -- an import that failed halfway stays retryable. local function importedSet() local raw = readSetting("tripoanim_imported") local set = {} if type(raw) == "string" and raw ~= "" and #raw <= 4096 then local ok, list = pcall(function() return HttpService:JSONDecode(raw) end) if ok and Validation.array(list, 50) then for _, id in ipairs(list) do if Validation.uuid(id) then set[id] = true end end end end return set end local function markImported(clipId) local set = importedSet() if set[clipId] then return end local list = { clipId } for id in pairs(set) do if #list < 50 then table.insert(list, id) end end pcall(function() plugin:SetSetting("tripoanim_imported", HttpService:JSONEncode(list)) end) end -- --------------------------------------------------------------------------- -- the rig -- --------------------------------------------------------------------------- local function isRig(model) if not model or not model:IsA("Model") then return false end return model:FindFirstChildOfClass("Humanoid") ~= nil or model:FindFirstChild("HumanoidRootPart") ~= nil end --- The selected rig, the rig the selection sits inside, or the only one around. local function pickRig() for _, instance in ipairs(Selection:Get()) do local node = instance while node and node ~= game do if isRig(node) then return node end node = node.Parent end end local found = nil for _, child in ipairs(workspace:GetChildren()) do if isRig(child) then if found then return nil, "more than one rig in Workspace - select the one you want" end found = child end end if not found then return nil, "no rig found - select an R15 model" end return found end --- The names a Pose can actually bind to on this rig: every joint's Part1. -- Motor6D exposes Part0/Part1 directly; AnimationConstraint - the current -- avatar skeleton, which is what these rigs use - reaches its parts through -- Attachment0/Attachment1. local function bindableNames(rig) local names, jointCount = {}, 0 for _, descendant in ipairs(rig:GetDescendants()) do local part1 -- Motor6D and AnimationConstraint only. A Weld also has Part0/Part1 and -- would put accessories into the skeleton. if descendant:IsA("Motor6D") then part1 = descendant.Part1 elseif descendant:IsA("AnimationConstraint") then part1 = descendant.Attachment1 and descendant.Attachment1.Parent while part1 and part1 ~= rig and not part1:IsA("BasePart") do part1 = part1.Parent end end if part1 and part1:IsA("BasePart") then names[part1.Name] = true jointCount += 1 end end return names, jointCount end -- --------------------------------------------------------------------------- -- building the clip -- --------------------------------------------------------------------------- local IDENTITY = CFrame.new() local function toCFrame(cf) -- [x, y, z, R00..R22], row-major: the layout rbxanim.mat_to_cf writes and -- the one the .rbxmx that plays correctly carries. return CFrame.new(cf[1], cf[2], cf[3], cf[4], cf[5], cf[6], cf[7], cf[8], cf[9], cf[10], cf[11], cf[12]) end -- FBX sidecars express travel in metres. Measure the selected Avatar and -- convert only when the source includes its own measurements; the result is a -- new validated clip, so a failed import can be retried without double scale. local function convertRootTrackForRig(clip, profile) if not clip.rootPath then return clip end local track = clip.rootTrack if not track or track.units == nil or track.units == "studs" then return clip end local function restPosition(name) return CFrame.new(table.unpack(profile.parts[name].rest)).Position end local function leg(side) return (restPosition(side .. "UpperLeg") - restPosition(side .. "LowerLeg")).Magnitude + (restPosition(side .. "LowerLeg") - restPosition(side .. "Foot")).Magnitude end local targetLeg = (leg("Left") + leg("Right")) * .5 local floor = math.huge for _, name in ipairs({"LeftFoot", "RightFoot"}) do floor = math.min(floor, restPosition(name).Y - profile.parts[name].size[2] * .5) end local converted, why = RootTrack.convertMetres(track, clip.rootPath, clip.visualRootHeight or {}, {legLength = targetLeg, restRootHeight = -floor}) if not converted then return nil, why end clip.rootPath, clip.visualRootHeight, clip.rootTrack = converted.rootPath, converted.visualRootHeight, converted.track return clip end --- One KeyframeSequence, nested the way the service says to nest it. -- Parts the service did not send on a frame become identity anchors with -- Weight 0, so the tree stays whole without those joints being driven. local function buildSequence(clip, rig) local validated, invalidWhy = Validation.clip(clip) if not validated then return nil, invalidWhy end clip = validated local profile, rigWhy = RigCapture.measure(rig) if not profile then return nil, rigWhy end local converted, conversionWhy = convertRootTrackForRig(clip, profile) if not converted then return nil, conversionWhy end clip = converted local bindable, jointCount = bindableNames(rig) if jointCount == 0 then return nil, ("%s has no Motor6D or AnimationConstraint joints - "):format(rig.Name) .. "this is not an animatable rig" end local root = clip.root or "HumanoidRootPart" if type(clip.hierarchy) ~= "table" then return nil, "the service sent no pose hierarchy; a flat Pose list does not animate" end local childrenOf = {} for child, parent in pairs(clip.hierarchy) do childrenOf[parent] = childrenOf[parent] or {} table.insert(childrenOf[parent], child) end for _, list in pairs(childrenOf) do table.sort(list) end local sequence = Instance.new("KeyframeSequence") sequence.Name = clip.name sequence.Loop = clip.loop and true or false sequence.Priority = Enum.AnimationPriority.Action if clip.rootPath then sequence:SetAttribute("rootPathJson", HttpService:JSONEncode(clip.rootPath)) sequence:SetAttribute("visualRootHeightJson", HttpService:JSONEncode(clip.visualRootHeight or {})) sequence:SetAttribute("rootTrackUnits", clip.rootTrack and clip.rootTrack.units or "studs") end -- The root anchors the tree and binds to nothing on any R15 rig, so it is -- not a warning that it does not appear in the bindable set. local unbound, bound = {}, {} for _, frame in ipairs(clip.keyframes) do for _, pose in ipairs(frame.poses) do if pose.part ~= root then if bindable[pose.part] then bound[pose.part] = true else unbound[pose.part] = true end end end end for index, frame in ipairs(clip.keyframes) do local byPart = {} for _, pose in ipairs(frame.poses) do byPart[pose.part] = pose end local keyframe = Instance.new("Keyframe") keyframe.Time = frame.time local function addPose(partName, parentInstance) local sent = byPart[partName] local pose = Instance.new("Pose") pose.Name = partName pose.CFrame = sent and toCFrame(sent.cf) or IDENTITY -- Weight 0 says "this joint is not driven on this frame", which is -- what an anchor has to say; a Weight 1 identity would actively -- pin the joint to its rest pose. pose.Weight = sent and (sent.weight or 1) or 0 pose.EasingStyle = Enum.PoseEasingStyle.Linear pose.EasingDirection = Enum.PoseEasingDirection.In pose.Parent = parentInstance for _, child in ipairs(childrenOf[partName] or {}) do addPose(child, pose) end end addPose(root, keyframe) keyframe.Parent = sequence -- A 4-second clip is ~2000 instances. Yielding keeps Studio responsive -- instead of freezing it for the second that takes. if index % 20 == 0 then task.wait() end end local missing, posed = {}, 0 for part in pairs(unbound) do table.insert(missing, part) end for _ in pairs(bound) do posed += 1 end table.sort(missing) return sequence, nil, { root = root, joints = jointCount, posed = posed, missing = missing }, clip end local function isAnimationSaveContainer(instance) return instance and (instance:IsA("Model") or instance:IsA("Folder")) end -- The rig has no saves yet: create the legacy folder, which the Animation -- Editor offers to migrate the first time it opens the rig. local CREATE_ANIM_SAVES = {} --- The container the Animation Editor lists for this rig. -- Since 2024 the editor keeps a rig's clips in ServerStorage.RBX_ANIMSAVES and -- leaves an ObjectValue named AnimSaves on the rig pointing there (mesh R15 -- avatars saved by a current Studio have this). A clip written beside that -- value would never appear in the editor's load list, so follow the pointer. local function resolveSaveContainer(rig) local saves = rig:FindFirstChild("AnimSaves") if not saves then return CREATE_ANIM_SAVES end if isAnimationSaveContainer(saves) then return saves end if saves:IsA("ObjectValue") then local store = game:GetService("ServerStorage"):FindFirstChild("RBX_ANIMSAVES") local target = saves.Value if store and target and target.Parent == store and (target:IsA("ObjectValue") or isAnimationSaveContainer(target)) then return target end return nil, "o AnimSaves deste rig não aponta para ServerStorage.RBX_ANIMSAVES; abra o rig uma vez no Animation Editor e tente de novo" end return nil, "AnimSaves deste rig não é uma pasta de animações" end local function saveToAnimSaves(rig, sequence, container) local saves = container if saves == CREATE_ANIM_SAVES then saves = Instance.new("Model") saves.Name = "AnimSaves" saves.Parent = rig end -- A second clip under the same name is a different take, not a replacement. local name, suffix = sequence.Name, 1 while saves:FindFirstChild(sequence.Name) do suffix += 1 sequence.Name = ("%s_%d"):format(name, suffix) end sequence.Parent = saves return saves end -- --------------------------------------------------------------------------- -- widget -- --------------------------------------------------------------------------- local toolbar = plugin:CreateToolbar(BRAND) local button = toolbar:CreateButton(BRAND, "Importe animações R15 criadas no TripoAnim", "rbxasset://textures/AnimationEditor/button_control_play.png") button.ClickableWhenViewportHidden = true local widget = plugin:CreateDockWidgetPluginGui("AnimGen", DockWidgetPluginGuiInfo.new( Enum.InitialDockState.Float, false, false, 360, 520, 300, 420)) widget.Title = BRAND local theme = settings().Studio.Theme local function color(name) return theme:GetColor(Enum.StudioStyleGuideColor[name]) end local background = Instance.new("Frame") background.Size = UDim2.fromScale(1, 1) background.BackgroundColor3 = color("MainBackground") background.BorderSizePixel = 0 background.Parent = widget local scroll = Instance.new("ScrollingFrame") scroll.Size = UDim2.new(1, -16, 1, -16) scroll.Position = UDim2.fromOffset(8, 8) scroll.BackgroundTransparency = 1 scroll.BorderSizePixel = 0 scroll.ScrollBarThickness = 6 scroll.CanvasSize = UDim2.new() scroll.AutomaticCanvasSize = Enum.AutomaticSize.Y scroll.Parent = background local layout = Instance.new("UIListLayout") layout.Padding = UDim.new(0, 6) layout.SortOrder = Enum.SortOrder.LayoutOrder layout.Parent = scroll local order = 0 local function nextOrder() order += 1 return order end local function label(text, muted) local instance = Instance.new("TextLabel") instance.Size = UDim2.new(1, 0, 0, 0) instance.AutomaticSize = Enum.AutomaticSize.Y instance.BackgroundTransparency = 1 instance.Font = Enum.Font.SourceSans instance.TextSize = 14 instance.TextXAlignment = Enum.TextXAlignment.Left instance.TextYAlignment = Enum.TextYAlignment.Top instance.TextWrapped = true instance.TextColor3 = color(muted and "DimmedText" or "MainText") instance.Text = text instance.LayoutOrder = nextOrder() instance.Parent = scroll return instance end local function makeButton(text) local instance = Instance.new("TextButton") instance.Size = UDim2.new(1, 0, 0, 30) instance.BackgroundColor3 = color("Button") instance.BorderColor3 = color("ButtonBorder") instance.TextColor3 = color("ButtonText") instance.Font = Enum.Font.SourceSansSemibold instance.TextSize = 15 instance.Text = text instance.AutoButtonColor = true instance.LayoutOrder = nextOrder() instance.Parent = scroll return instance end local function setEnabled(instance, enabled) instance.Active = enabled instance.AutoButtonColor = enabled instance.TextColor3 = color(enabled and "ButtonText" or "DimmedText") end local rigLabel = label("rig: -") local syncRigButton = makeButton("Enviar rig ao site") label("Medimos peças e encaixes do R15 selecionado. Enviar o rig não gasta créditos de animação.", true) label("Descreva o movimento", true) -- THE TEXT FIELD. This is what the whole plugin is wrapped around. local promptBox = Instance.new("TextBox") promptBox.Size = UDim2.new(1, 0, 0, 80) promptBox.BackgroundColor3 = color("InputFieldBackground") promptBox.BorderColor3 = color("InputFieldBorder") promptBox.TextColor3 = color("MainText") promptBox.PlaceholderText = "a character waves hello with the right hand, feet planted" promptBox.PlaceholderColor3 = color("DimmedText") promptBox.Font = Enum.Font.SourceSans promptBox.TextSize = 14 promptBox.Text = "" promptBox.ClearTextOnFocus = false promptBox.MultiLine = true promptBox.TextWrapped = true promptBox.TextXAlignment = Enum.TextXAlignment.Left promptBox.TextYAlignment = Enum.TextYAlignment.Top promptBox.LayoutOrder = nextOrder() promptBox.Parent = scroll label("Duracao em segundos (2 a 10)", true) local durationBox = Instance.new("TextBox") durationBox.Size = UDim2.new(1, 0, 0, 26) durationBox.BackgroundColor3 = color("InputFieldBackground") durationBox.BorderColor3 = color("InputFieldBorder") durationBox.TextColor3 = color("MainText") durationBox.Font = Enum.Font.SourceSans durationBox.TextSize = 14 durationBox.Text = "4" durationBox.ClearTextOnFocus = false durationBox.LayoutOrder = nextOrder() durationBox.Parent = scroll local quoteButton = makeButton("Consultar preco") local costLabel = label("", true) local generateButton = makeButton("Gerar") local statusLabel = label("") local detailLabel = label("", true) local previewButton = makeButton("Pre-visualizar") previewButton.Visible = false local modeButton = makeButton("Modo: local") local codeLabel = label("Codigo de pareamento (gere no site)", true) local codeBox = Instance.new("TextBox") codeBox.Size = UDim2.new(1, 0, 0, 26) codeBox.BackgroundColor3 = color("InputFieldBackground") codeBox.BorderColor3 = color("InputFieldBorder") codeBox.TextColor3 = color("MainText") codeBox.PlaceholderText = "ABCD-EFGH" codeBox.PlaceholderColor3 = color("DimmedText") codeBox.Font = Enum.Font.SourceSans codeBox.TextSize = 14 codeBox.Text = "" codeBox.ClearTextOnFocus = false codeBox.LayoutOrder = nextOrder() codeBox.Parent = scroll local pairButton = makeButton("Conectar") local inboxButton = makeButton("Buscar clipes prontos") local clipIdLabel = label("Clipe antigo ou outra copia? Cole o ID da biblioteca no site.", true) local clipIdBox = codeBox:Clone() clipIdBox.PlaceholderText = "ID do clipe (copiado no site)" clipIdBox.Text = "" clipIdBox.LayoutOrder = nextOrder() clipIdBox.Parent = scroll local importIdButton = makeButton("Importar pelo ID") local inboxLabel = label("", true) local inboxList = Instance.new("Frame") inboxList.Size = UDim2.new(1, 0, 0, 0) inboxList.AutomaticSize = Enum.AutomaticSize.Y inboxList.BackgroundTransparency = 1 inboxList.LayoutOrder = nextOrder() inboxList.Parent = scroll local inboxLayout = Instance.new("UIListLayout") inboxLayout.Padding = UDim.new(0, 4) inboxLayout.SortOrder = Enum.SortOrder.LayoutOrder inboxLayout.Parent = inboxList --- Remove stale per-clip choices before replacing the inbox listing. local function clearInboxOffers() for _, child in ipairs(inboxList:GetChildren()) do if child:IsA("TextButton") then child:Destroy() end end end --- Number of clips still offered after successful imports are removed. local function inboxOfferCount() local count = 0 for _, child in ipairs(inboxList:GetChildren()) do if child:IsA("TextButton") then count += 1 end end return count end -- --------------------------------------------------------------------------- -- behaviour -- --------------------------------------------------------------------------- -- Forward declaration: the mode and pairing buttons refresh the health line, -- which is defined further down with the rest of the service plumbing. local refreshHealthDeferred local pendingQuote = nil -- the one-use token, or nil local lastSequence = nil local lastClip = nil local lastRig = nil local importClip local busy = false local previewing = false local unloaded = false local stopPreview local function refreshRig() local rig, why = pickRig() rigLabel.Text = rig and ("rig: " .. rig:GetFullName()) or ("rig: " .. (why or "-")) rigLabel.TextColor3 = color(rig and "MainText" or "WarningText") return rig, why end local function setQuote(quote) pendingQuote = quote if quote then costLabel.Text = ("%s %s -> %g creditos\n\"%s\" %gs") :format(BRAND, quote.tier or "", quote.credits, quote.prompt, quote.duration) generateButton.Text = ("Gerar (%g creditos)"):format(quote.credits) else costLabel.Text = "" generateButton.Text = "Gerar" end setEnabled(generateButton, quote ~= nil and not busy) end local function setBusy(state, message) busy = state local ready = currentMode() ~= "cloud" or deviceToken() ~= nil setEnabled(quoteButton, not state and ready) setEnabled(generateButton, not state and ready and pendingQuote ~= nil) setEnabled(modeButton, not state) setEnabled(pairButton, not state) setEnabled(inboxButton, not state) setEnabled(importIdButton, not state) setEnabled(syncRigButton, not state and currentMode() == "cloud" and ready) for _, child in ipairs(inboxList:GetChildren()) do if child:IsA("TextButton") then setEnabled(child, not state) end end if message then statusLabel.Text = message statusLabel.TextColor3 = color("MainText") end end local function fail(message) statusLabel.Text = Validation.text(message, "operação indisponível; tente novamente", 1600) statusLabel.TextColor3 = color("ErrorText") end setQuote(nil) refreshRig() Selection.SelectionChanged:Connect(function() local selected = refreshRig() if pendingQuote and pendingQuote._rig ~= selected then setQuote(nil) end end) local rigCache = setmetatable({}, {__mode = "k"}) local function syncRig(rig) local profile, why = RigCapture.measure(rig) if not profile then return nil, why end local fingerprint = RigCapture.fingerprint(profile) local cached = rigCache[rig] if cached and cached.fingerprint == fingerprint and cached.device == deviceToken() then return cached.id, nil, fingerprint, profile end local created, createWhy = api("characters", {action = "studio", name = rig.Name:sub(1,100), profile = profile}) if not created then return nil, createWhy end if not Validation.uuid(created.character_id) then return nil, "resposta de personagem inválida" end local waited = 0 while waited < 90 do local result, getWhy = api("characters", {action = "get", character_id = created.character_id}) if not result then return nil, getWhy end local character = result.character if type(character) ~= "table" or type(character.status) ~= "string" then return nil, "resposta de personagem inválida" end if character.status == "ready" then rigCache[rig] = {id = created.character_id, fingerprint = fingerprint, device = deviceToken()} return created.character_id, nil, fingerprint, profile end if character.status == "error" then return nil, Validation.text(character.error, "não foi possível medir o rig", 500) end task.wait(1.5); waited += 1.5 end return nil, "O perfil continua em análise. Aguarde e tente novamente." end syncRigButton.MouseButton1Click:Connect(function() if busy or currentMode() ~= "cloud" then return end local rig, why = pickRig() if not rig then return fail(why) end setQuote(nil); setBusy(true, "medindo e enviando seu R15...") local id, syncWhy = syncRig(rig) setBusy(false) if not id then return fail(syncWhy) end statusLabel.Text = "Rig enviado. No site, atualize a lista de personagens e selecione " .. rig.Name end) quoteButton.MouseButton1Click:Connect(function() if busy then return end local prompt = promptBox.Text if prompt:gsub("%s", "") == "" then return fail("escreva o movimento primeiro") end setBusy(true, "consultando...") setQuote(nil) -- Cloud refuses the local tier field, while api restores it for the -- development service. Keeping that difference out of this handler is why -- a mode switch cannot leak one transport's body into the other. local body = { prompt = prompt, duration = tonumber(durationBox.Text) or 4 } local rig, rigWhy = pickRig() if not rig then setBusy(false); return fail(rigWhy) end local profile, profileWhy = RigCapture.measure(rig) if not profile then setBusy(false); return fail(profileWhy) end local fingerprint = RigCapture.fingerprint(profile) if currentMode() == "cloud" then local id, syncWhy = syncRig(rig) if not id then setBusy(false); return fail(syncWhy) end body.character_id = id body.output_format = "roblox"; body.root_motion = "in_place" else body.rig_profile = profile end local quote, why = api("quote", body) setBusy(false) if not quote then return fail(why) end if not Validation.quote(quote, currentMode()) then return fail("orçamento inválido; consulte o preço novamente") end statusLabel.Text = "nada foi gasto - confirme para gerar" statusLabel.TextColor3 = color("MainText") detailLabel.Text = table.concat(quote.notes or {}, "\n") .. "\n" .. (quote.rig or "") quote._rig = rig; quote._rigFingerprint = fingerprint setQuote(quote) end) generateButton.MouseButton1Click:Connect(function() if busy or not pendingQuote then return end local rig, why = refreshRig() if not rig then return fail(why or "selecione um rig antes de gastar creditos") end local profile = RigCapture.measure(rig) if not profile or pendingQuote._rig ~= rig or RigCapture.fingerprint(profile) ~= pendingQuote._rigFingerprint then setQuote(nil) return fail("O rig ou suas proporções mudaram. Consulte o preço novamente.") end local spent = pendingQuote.credits local quoteId = pendingQuote.quote_id local transport = currentMode() setQuote(nil) -- one use, on this side too setBusy(true, ("gastando %g creditos..."):format(spent)) previewButton.Visible = false task.spawn(function() local job, jobWhy = api("generate", { quote_id = quoteId }, nil, transport) if not job then setBusy(false) return fail(jobWhy) end if not Validation.id(job.job_id, transport) then setBusy(false); return fail("resposta de geração inválida; confira a biblioteca") end local waited = 0 local state while waited < POLL_GIVE_UP and not unloaded do task.wait(POLL_SECONDS) -- Hidden cloud widgets spend no place-wide HTTP budget. The task stays -- alive so reopening can resume watching the same server-side job. if transport ~= "cloud" or widget.Enabled then waited += POLL_SECONDS local polled, pollWhy = api("job", nil, job.job_id, transport) if not polled then setBusy(false) return fail(pollWhy) end if not Validation.job(polled, transport) then setBusy(false); return fail("resposta de geração inválida; confira a biblioteca") end state = polled statusLabel.Text = ("%s... %.0fs"):format(state.stage or state.status, waited) if state.status == "done" or state.status == "error" then break end end end if unloaded then return end if not state or state.status ~= "done" then setBusy(false) local fallback if transport == "local" then fallback = "a geracao nao terminou" elseif state and state.status == "error" then fallback = "a geracao falhou" else -- Hosted, the server keeps going without us, so "nao terminou" -- implies a lost credit that was never lost. fallback = "a geracao continua no servidor - use Buscar clipes prontos" end local message = state and (state.error or fallback) or fallback -- The credit may already be spent. Where the raw clip landed is the -- one thing worth knowing when the conversion is what failed. if state and transport == "local" and Validation.text(state.work_dir, nil, 800) then message = message .. "\nclipe bruto em " .. state.work_dir end return fail(message) end -- The current hosted job response resolves the clip directly, including -- clips outside the latest 20 inbox items. Keep the old-service fallback. local clipId = state.clip_id if transport == "cloud" and not clipId then local resolved, idWhy = clipIdForJob(job.job_id) if not resolved then setBusy(false) return fail(idWhy) end clipId = resolved end local clip, clipWhy = fetchClip(clipId, transport) if not clip then setBusy(false) return fail(clipWhy) end statusLabel.Text = "montando o KeyframeSequence..." local sequence, buildWhy, report, appliedClip = importClip(clip, rig) if not sequence then setBusy(false) return fail(buildWhy) end if transport == "cloud" then markImported(clipId) recordImported(clipId) end lastSequence = sequence lastClip = appliedClip or clip lastRig = rig previewButton.Visible = true setBusy(false) statusLabel.Text = ("salvo em %s"):format(sequence:GetFullName()) statusLabel.TextColor3 = color("MainText") local lines = { ("%d keyframes, %.2fs @ %gfps, QA %s") :format(#clip.keyframes, clip.duration, clip.fps, clip.qa or "-"), ("%d juntas no rig, %d poses vao ligar"):format(report.joints, report.posed), ("apoio dos pes: %s"):format(clip.plant or "-"), } if #report.missing > 0 then table.insert(lines, "NAO LIGA NESTE RIG (fica parado): " .. table.concat(report.missing, ", ")) end for _, problem in ipairs(clip.problems or {}) do table.insert(lines, "PROBLEMA: " .. problem) end if clip.rootPath then table.insert(lines, ("root motion em rootPath (%d amostras) - "):format(#clip.rootPath) .. "a prévia aplica o deslocamento; a sequência preserva o sidecar") end detailLabel.Text = table.concat(lines, "\n") detailLabel.TextColor3 = color( (#report.missing > 0 or #(clip.problems or {}) > 0) and "WarningText" or "DimmedText") end) end) --- Every joint an animation writes to, so the rig can be put back. local function jointsOf(rig) local list = {} for _, descendant in ipairs(rig:GetDescendants()) do if descendant:IsA("Motor6D") or descendant:IsA("AnimationConstraint") then table.insert(list, descendant) end end return list end -- Root tracks have their own irregular timestamps. The extracted module uses -- shortest-angle interpolation for yaw, so a turn crossing -pi/pi never spins -- the Avatar almost one full rotation. local function rootPoseAt(clip, time) return RootTrack.poseAt(clip, time) end --- Play a clip in Edit mode, by hand. -- Two things bite here, and both were measured on 2026-09-04 rather than -- guessed. First, RegisterKeyframeSequence returns a BARE HASH and that hash is -- the AnimationId - prefixing it with rbxasset:// throws and rbxassetid:// -- silently resolves to nothing. Second, and this is why the first attempt -- played nothing at all: Edit mode never advances a track. `Play()` sets -- IsPlaying and then nothing moves, because no simulation step is running to -- call StepAnimations. So this drives it frame by frame. local function playPreview(clip, sequence, rig) local humanoid = rig:FindFirstChildOfClass("Humanoid") local animator = humanoid and humanoid:FindFirstChildOfClass("Animator") if not animator then previewing = false setBusy(false) return fail("o rig nao tem Animator - abra o Animation Editor no clipe") end -- Put the rig back exactly where it was when the preview ends. local joints, before = jointsOf(rig), {} for _, joint in ipairs(joints) do local got, value = pcall(function() return joint.Transform end) if got then before[joint] = value end end local rootPart = rig:FindFirstChild("HumanoidRootPart") local rootBefore = rootPart and rootPart.CFrame local rootStart = rootPoseAt(clip, 0) local function applyRoot(time) if not rootPart or not rootBefore or not rootStart then return end local current = rootPoseAt(clip, time) if not current then return end local height = (current.height or rootStart.height or 0) - (rootStart.height or 0) local yaw = clip.rootYawInAnimation == false and (current.yaw - rootStart.yaw) or 0 -- Always apply from the original frame. A dropped cadence, a repeat, or -- a failed preview must never accumulate travel. rootPart.CFrame = rootBefore * CFrame.new(current.x - rootStart.x, current.y - rootStart.y + height, current.z - rootStart.z) * CFrame.Angles(0, yaw, 0) end local track, animation local function cleanup() previewing = false if track then pcall(function() track:Stop(0); track:Destroy() end) end if animation then pcall(function() animation:Destroy() end) end if rootPart and rootBefore then pcall(function() rootPart.CFrame = rootBefore end) end for joint, value in pairs(before) do pcall(function() joint.Transform = value end) end end stopPreview = cleanup local ok = pcall(function() animation = Instance.new("Animation") animation.AnimationId = KeyframeSequenceProvider:RegisterKeyframeSequence(sequence) track = animator:LoadAnimation(animation) if not previewing or unloaded then return end track:Play(0) local waited = 0 while previewing and track.Length <= 0 and waited < 3 do task.wait(0.05) waited += 0.05 end if track.Length <= 0 and previewing then error("preview unavailable", 0) end local step, elapsed = 1 / clip.fps, 0 while previewing and not unloaded and elapsed < math.min(track.Length, clip.duration + 1) do animator:StepAnimations(step) elapsed += step applyRoot(elapsed) task.wait(step) end end) cleanup() stopPreview = nil if not unloaded then previewButton.Text = "Pre-visualizar" setBusy(false) if not ok then fail("não foi possível reproduzir a prévia; abra o clipe no Animation Editor") end end end previewButton.MouseButton1Click:Connect(function() if previewing then previewing = false -- the loop notices and restores the rig return end if busy then return end if RunService:IsRunning() then return fail("pare o teste do jogo antes de visualizar a animação") end if not lastSequence or not lastSequence.Parent then return fail("nada para pre-visualizar") end local rig = refreshRig() if not rig or rig ~= lastRig then return fail("selecione o rig do clipe") end previewing = true previewButton.Text = "Parar" setBusy(true) task.spawn(playPreview, lastClip, lastSequence, rig) end) --- Pairing is always reachable; connecting switches to cloud mode only after -- the code has been claimed successfully. local function refreshMode() local cloud = currentMode() == "cloud" local paired = deviceToken() ~= nil modeButton.Text = cloud and ("Modo: nuvem (" .. BRAND .. ")") or "Modo: local" codeLabel.Visible = not paired codeBox.Visible = not paired pairButton.Visible = true pairButton.Text = paired and "Desconectar" or "Conectar" inboxButton.Visible = cloud and paired clipIdLabel.Visible = cloud and paired clipIdBox.Visible = cloud and paired importIdButton.Visible = cloud and paired syncRigButton.Visible = cloud setEnabled(syncRigButton, not busy and cloud and paired) inboxList.Visible = cloud and paired setEnabled(quoteButton, not busy and (not cloud or paired)) setEnabled(generateButton, not busy and (not cloud or paired) and pendingQuote ~= nil) if not cloud then inboxLabel.Text = "" end end onAuthLost = function() setQuote(nil) clearInboxOffers() inboxLabel.Text = "" refreshMode() fail("conexão inválida ou revogada; gere um novo código no site") end modeButton.MouseButton1Click:Connect(function() if busy then return end local saved = pcall(function() plugin:SetSetting("tripoanim_mode", currentMode() == "cloud" and "local" or "cloud") end) if not saved then return fail("não foi possível salvar o modo neste Studio") end -- Quote ids belong to the transport that issued them and cannot cross modes. setQuote(nil) refreshMode() task.spawn(refreshHealthDeferred) end) pairButton.MouseButton1Click:Connect(function() if busy then return end if deviceToken() then local saved = pcall(function() plugin:SetSetting("tripoanim_token", nil) end) if not saved then return fail("não foi possível desconectar; revogue o dispositivo pelo site") end setQuote(nil) clearInboxOffers() inboxLabel.Text = "" refreshMode() statusLabel.Text = "desconectado deste Studio; para revogar o dispositivo, use Conectar Studio no site" statusLabel.TextColor3 = color("MainText") task.spawn(refreshHealthDeferred) return end local code = codeBox.Text:upper():gsub("[^A-Z0-9]", "") if #code ~= 8 then return fail("cole o codigo de 8 caracteres gerado no site") end -- Pairing has to reach the cloud even while the plugin is still local, so -- the mode is overridden for this one call rather than switched blindly. setBusy(true, "conectando este Studio...") local claimed, why, statusCode = api("claim", { code = code }, nil, "cloud") setBusy(false) if not claimed or not Validation.token(claimed.token) then -- The endpoint answers an identical 404 for unknown, expired and -- already-claimed, so the message must not pretend to know which. if statusCode == 404 then return fail("codigo invalido ou expirado - gere outro no site") end return fail(why or "nao foi possivel conectar este Studio") end -- plugin:SetSetting is unencrypted local Studio state, so this token is -- exactly as safe as the machine. That is why it is scoped to the -- tripoanim_* tables and revocable from the site, and never a site login. local saved = pcall(function() plugin:SetSetting("tripoanim_token", claimed.token) plugin:SetSetting("tripoanim_mode", "cloud") end) if not saved then pcall(function() plugin:SetSetting("tripoanim_token", nil) end) return fail("não foi possível salvar a conexão; revogue o dispositivo no site e tente um novo código") end setQuote(nil) codeBox.Text = "" clearInboxOffers() refreshMode() statusLabel.Text = "pareado - este Studio agora recebe seus clipes" statusLabel.TextColor3 = color("MainText") task.spawn(refreshHealthDeferred) end) --- Import one ready clip into the selected rig. Shared with the generate flow. importClip = function(clip, rig) if RunService:IsRunning() then return nil, "pare o teste do jogo antes de importar a animação" end if unloaded or not rig:IsDescendantOf(workspace) then return nil, "selecione um R15 no Workspace antes de importar" end local container, saveWhy = resolveSaveContainer(rig) if not container then return nil, saveWhy end local sequence, buildWhy, report, appliedClip = buildSequence(clip, rig) if not sequence then return nil, buildWhy end -- Building yields. Re-check the original target before touching the place. if unloaded or not rig:IsDescendantOf(workspace) or resolveSaveContainer(rig) ~= container then sequence:Destroy() return nil, "o rig ou a pasta de animações mudou durante a importação; selecione o R15 e tente novamente" end local recording = ChangeHistoryService:TryBeginRecording(BRAND .. ": importar animação") if not recording then sequence:Destroy() return nil, "o Studio está ocupado com outra edição; tente importar novamente" end local saved = pcall(function() assert(saveToAnimSaves(rig, sequence, container)) ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Commit) end) if not saved then pcall(function() ChangeHistoryService:FinishRecording(recording, Enum.FinishRecordingOperation.Cancel) end) sequence:Destroy() return nil, "não foi possível salvar o clipe; a importação pode ser tentada novamente" end Selection:Set({ sequence }) return sequence, nil, report, appliedClip end --- Download and import only the clip the user chose. local function importInboxEntry(entry, offerButton) if busy then return end if offerButton and importedSet()[entry.clip_id] then offerButton:Destroy() return end local rig, why = refreshRig() if not rig then return fail(why or "selecione o rig antes de importar") end setBusy(true, ("baixando %s..."):format(entry.name or entry.clip_id)) task.spawn(function() local clip, clipWhy = fetchClip(entry.clip_id, "cloud") if not clip then setBusy(false) return fail(clipWhy) end local sequence, importWhy, _, appliedClip = importClip(clip, rig) if not sequence then setBusy(false) return fail(importWhy) end markImported(entry.clip_id) recordImported(entry.clip_id) lastSequence = sequence lastClip = appliedClip or clip lastRig = rig previewButton.Visible = true if offerButton then offerButton:Destroy() end local remaining = inboxOfferCount() inboxLabel.Text = remaining == 0 and "nenhum clipe novo" or ("%d clipe(s) prontos para importar"):format(remaining) inboxLabel.TextColor3 = color(remaining == 0 and "DimmedText" or "MainText") setBusy(false) statusLabel.Text = ("importado em %s"):format(sequence:GetFullName()) statusLabel.TextColor3 = color("MainText") end) end importIdButton.MouseButton1Click:Connect(function() if busy or currentMode() ~= "cloud" or not deviceToken() then return end local id = clipIdBox.Text:match("^%s*(.-)%s*$") if not Validation.uuid(id) then return fail("copie o ID completo do clipe na biblioteca do site") end -- The authenticated server checks ownership. This also permits importing a -- previously imported clip onto a different selected rig. importInboxEntry({ clip_id = id }, nil) end) --- The inbox. This is what makes "you can close Studio" true: generation runs -- on the server, and the plugin asks later what is waiting. local function checkInbox(quiet) if currentMode() ~= "cloud" or not deviceToken() then return end local queriedToken = deviceToken() local entries, why = inboxEntries() if queriedToken ~= deviceToken() or currentMode() ~= "cloud" or unloaded then return end if not entries then if not quiet then fail(why) end return end detailLabel.Text = BRAND .. " conectado; clipes prontos consultados" detailLabel.TextColor3 = color("DimmedText") local done = importedSet() local pending = {} for _, entry in ipairs(entries) do if entry.clip_id and not done[entry.clip_id] then table.insert(pending, entry) end end clearInboxOffers() if #pending == 0 then inboxLabel.Text = "nenhum clipe novo" inboxLabel.TextColor3 = color("DimmedText") return pending end inboxLabel.Text = ("%d clipe(s) prontos para importar"):format(#pending) inboxLabel.TextColor3 = color("MainText") for _, entry in ipairs(pending) do local offered = entry local offerButton = makeButton(("Importar %s (%.1fs, %d KiB)"):format( entry.name or "?", tonumber(entry.duration) or 0, math.floor((tonumber(entry.bytes) or 0) / 1024))) offerButton.Parent = inboxList offerButton.MouseButton1Click:Connect(function() importInboxEntry(offered, offerButton) end) end return pending end inboxButton.MouseButton1Click:Connect(function() if busy then return end setBusy(true, "buscando clipes prontos...") task.spawn(function() local pending = checkInbox(false) if pending then setBusy(false, #pending > 0 and "escolha um clipe para importar" or "") else setBusy(false) end end) end) local function refreshHealth() if currentMode() == "cloud" then if not deviceToken() then detailLabel.Text = "nao pareado - gere um codigo no site e cole acima" detailLabel.TextColor3 = color("WarningText") return end -- Opening a widget must not create quote rows or use quote rate limits. -- checkInbox performs the authenticated read and reports its result. detailLabel.Text = "conexão salva; use Buscar clipes prontos para verificar" detailLabel.TextColor3 = color("DimmedText") return end local health = api("health") if not health then detailLabel.Text = ("servico offline - rode `python service/app.py` (%s)") :format(BASE["local"]) detailLabel.TextColor3 = color("WarningText") return end local connected = type(health.providers) == "table" and health.providers[BRAND:lower()] detailLabel.Text = ("servico ok - %s %s\n%s"):format( BRAND, connected and "conectado" or "SEM CHAVE", Validation.text(health.rig, "", 120)) detailLabel.TextColor3 = color("DimmedText") end refreshHealthDeferred = refreshHealth refreshMode() button.Click:Connect(function() widget.Enabled = not widget.Enabled end) widget:GetPropertyChangedSignal("Enabled"):Connect(function() if not widget.Enabled and previewing then previewing = false if stopPreview then stopPreview() end end if widget.Enabled then refreshRig() refreshMode() task.spawn(refreshHealth) -- Checking on open is what lets a clip generated while Studio was -- closed be waiting when it reopens. task.spawn(function() checkInbox(true) end) end end) plugin.Unloading:Connect(function() unloaded = true previewing = false if stopPreview then stopPreview() end end) if widget.Enabled then task.spawn(refreshHealth) task.spawn(function() checkInbox(true) end) end