// .NET 8+, trusted server/local only. Never include an API key in a shipped game. // Unset ANIMGEN_APPROVE_CREDITS to prepare/quote without starting generation. // Approval is a local preflight check, NOT an atomic server-side spending cap. using System.Net; using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using System.Security.Cryptography; using System.Text.RegularExpressions; internal sealed class SafeFailure(string message) : Exception(message) { } internal sealed class SavedState { public int Version { get; set; } = 1; public string ApiBase { get; set; } = ""; public string AccountId { get; set; } = ""; public string KeyId { get; set; } = ""; public string IdempotencyKey { get; set; } = ""; public JsonObject Request { get; set; } = new(); public string RequestHash { get; set; } = ""; public string? TaskId { get; set; } public long? FirstCreateAt { get; set; } } internal static class Program { private static string? Env(string name) => Environment.GetEnvironmentVariable(name); private static string Safe(string? value) => value != null && Regex.IsMatch(value, "^[A-Za-z0-9_.-]{1,100}$") ? value : "unknown"; private static string Id(string value) => Guid.Parse(value).ToString(); private static string Hash(JsonObject body) => Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(body.ToJsonString()))); private static FileStream PrivateFile(string path) { var options = new FileStreamOptions { Mode = FileMode.CreateNew, Access = FileAccess.Write, Share = FileShare.None }; if (!OperatingSystem.IsWindows()) options.UnixCreateMode = UnixFileMode.UserRead | UnixFileMode.UserWrite; return new FileStream(path, options); } private static async Task Main() { try { return await Run(); } catch (SafeFailure error) { Console.Error.WriteLine(error.Message); return 1; } catch { Console.Error.WriteLine("Workflow failed. Preserve the private state; sensitive details omitted."); return 1; } } private static async Task Run() { var key = Env("ANIMGEN_API_KEY") ?? throw new SafeFailure("Set ANIMGEN_API_KEY privately."); var apiBase = (Env("ANIMGEN_API_BASE") ?? "https://api.animgen.com/v1").TrimEnd('/'); var baseUri = new Uri(apiBase); bool local = baseUri.Host is "localhost" or "127.0.0.1" or "[::1]"; if (baseUri.UserInfo != "" || baseUri.Query != "" || baseUri.Fragment != "" || (baseUri.Scheme != "https" && !(baseUri.Scheme == "http" && local))) throw new SafeFailure("Use a clean HTTPS base URL; HTTP is only for a local mock."); var timeout = double.Parse(Env("ANIMGEN_TIMEOUT_SECONDS") ?? "900", System.Globalization.CultureInfo.InvariantCulture); if (!double.IsFinite(timeout) || timeout <= 0) throw new SafeFailure("Invalid timeout."); var deadline = DateTimeOffset.UtcNow.AddSeconds(timeout); int Remaining() { var ms = (deadline - DateTimeOffset.UtcNow).TotalMilliseconds; if (ms <= 0) throw new SafeFailure("Local deadline reached. Resume the SAME state; remote work may continue."); return (int)Math.Clamp(ms, 1, 30000); } async Task Wait(TimeSpan delay) { if (delay < TimeSpan.FromMilliseconds(50)) delay = TimeSpan.FromMilliseconds(50); if (DateTimeOffset.UtcNow + delay >= deadline) throw new SafeFailure("Retry delay exceeds the local deadline; resume later with the SAME state."); await Task.Delay(delay); } using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = Timeout.InfiniteTimeSpan }; using var downloads = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false }) { Timeout = Timeout.InfiniteTimeSpan }; async Task<(JsonObject Body, TimeSpan Delay)> Api(string path, string method = "GET", JsonObject? body = null, string? idempotency = null) { if (method != "GET" && !path.EndsWith("/quote") && idempotency == null) throw new SafeFailure("This example only retries reads, quotes, and keyed creates."); for (int attempt = 0; attempt < 5; attempt++) { try { using var cancellation = new CancellationTokenSource(Remaining()); using var request = new HttpRequestMessage(new HttpMethod(method), apiBase + path); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", key); if (idempotency != null) request.Headers.Add("Idempotency-Key", idempotency); if (body != null) request.Content = new StringContent(body.ToJsonString(), Encoding.UTF8, "application/json"); using var response = await client.SendAsync(request, cancellation.Token); var value = JsonNode.Parse(await response.Content.ReadAsStringAsync(cancellation.Token))!.AsObject(); var delay = response.Headers.RetryAfter?.Delta ?? (response.Headers.RetryAfter?.Date - DateTimeOffset.UtcNow) ?? TimeSpan.FromSeconds(5); if (response.IsSuccessStatusCode) return (value, delay); var error = value["error"]; var code = error?["code"]?.GetValue(); bool retryable = response.StatusCode == HttpStatusCode.TooManyRequests || (error?["retryable"]?.GetValue() == true && (response.StatusCode == HttpStatusCode.ServiceUnavailable || (response.StatusCode == HttpStatusCode.Conflict && code == "IDEMPOTENCY_IN_PROGRESS"))); if (!retryable || attempt == 4) throw new SafeFailure($"API HTTP {(int)response.StatusCode}: {Safe(code)}; request_id={Safe(error?["request_id"]?.GetValue())}"); await Wait(response.Headers.RetryAfter == null ? TimeSpan.FromSeconds(Math.Pow(2, attempt)) : delay); } catch (Exception error) when (error is HttpRequestException or TaskCanceledException) { if (attempt == 4) throw new SafeFailure("API transport failed. Keep the state, original key identity, and request."); await Wait(TimeSpan.FromSeconds(Math.Pow(2, attempt))); } } throw new SafeFailure("Retry limit reached."); } var statePath = Path.GetFullPath(Env("ANIMGEN_STATE") ?? "animgen-state.json"); SavedState? state = File.Exists(statePath) ? JsonSerializer.Deserialize(await File.ReadAllTextAsync(statePath)) : null; async Task SaveState(bool fresh = false) { var temporary = fresh ? statePath : Path.Combine(Path.GetDirectoryName(statePath)!, $".animgen-state-{Guid.NewGuid()}.tmp"); try { await using (var file = PrivateFile(temporary)) await file.WriteAsync(Encoding.UTF8.GetBytes(JsonSerializer.Serialize(state))); if (!fresh) File.Move(temporary, statePath, true); } finally { if (!fresh && File.Exists(temporary)) File.Delete(temporary); } } var account = (await Api("/account")).Body; if (state == null) { var imagePath = Env("IMAGE_PATH") ?? throw new SafeFailure("Set IMAGE_PATH to prepare a new operation."); var modelId = Env("ANIMGEN_MODEL") ?? throw new SafeFailure("Set ANIMGEN_MODEL from GET /models."); var mime = Path.GetExtension(imagePath).ToLowerInvariant() switch { ".png" => "image/png", ".jpg" or ".jpeg" => "image/jpeg", ".webp" => "image/webp", _ => throw new SafeFailure("Use PNG, JPEG, or WebP.") }; if (new FileInfo(imagePath).Length > 10 * 1024 * 1024) throw new SafeFailure("This inline example limits images to 10 MiB."); var catalog = (await Api("/models")).Body; var model = catalog["data"]!.AsArray().FirstOrDefault(item => item!["id"]!.GetValue() == modelId && item["supports_first_frame"]!.GetValue() && item["modes"]!.AsArray().Any(mode => mode!.GetValue() == "first_frame")) ?? throw new SafeFailure("Choose a current model supporting first-frame input."); var body = new JsonObject { ["input"] = new JsonObject { ["first_frame"] = new JsonObject { ["type"] = "base64", ["media_type"] = mime, ["data"] = Convert.ToBase64String(await File.ReadAllBytesAsync(imagePath)) } }, ["prompt"] = Env("ANIMGEN_PROMPT") ?? "A character runs in place, side view, fixed camera.", ["video"] = new JsonObject { ["model"] = modelId, ["duration_seconds"] = model["default_duration_seconds"]?.DeepClone(), ["resolution"] = model["default_resolution"]?.DeepClone(), ["ratio"] = model["default_ratio"]?.DeepClone() }, ["selection"] = new JsonObject { ["mode"] = "full" }, ["export"] = new JsonObject { ["output_formats"] = new JsonArray("frames_zip"), ["frame_count"] = 24, ["output_width"] = 512, ["output_height"] = 512 }, }; state = new SavedState { ApiBase = apiBase, AccountId = account["id"]!.GetValue(), KeyId = account["api_key"]!["id"]!.GetValue(), Request = body, RequestHash = Hash(body), IdempotencyKey = Guid.NewGuid().ToString() }; await SaveState(true); } if (state.Version != 1 || state.ApiBase != apiBase || state.AccountId != account["id"]!.GetValue() || state.RequestHash != Hash(state.Request)) throw new SafeFailure("State/account/base/request mismatch; recover the original task."); if (state.TaskId == null) { if (state.KeyId != account["api_key"]!["id"]!.GetValue()) throw new SafeFailure("API key identity changed; idempotency does not cross keys."); if (state.FirstCreateAt != null && DateTimeOffset.UtcNow.ToUnixTimeSeconds() - state.FirstCreateAt.Value >= 86400) throw new SafeFailure("Uncertain create is older than 24 hours. Inspect existing tasks before creating again."); if (state.FirstCreateAt == null) { var quote = (await Api("/animations/quote", "POST", state.Request)).Body["credits"]!.GetValue(); var balance = (await Api("/credits/balance")).Body["available"]!.GetValue(); Console.WriteLine($"Quoted credits: {quote}; available: {balance}. Local preflight only, not a server-enforced cap."); var approval = Env("ANIMGEN_APPROVE_CREDITS"); if (approval == null) { Console.WriteLine("No generation started. Review and set ANIMGEN_APPROVE_CREDITS, then resume the SAME state."); return 0; } if (!int.TryParse(approval, out int approved) || approved < 0 || quote > approved) throw new SafeFailure("Quote exceeds a valid explicit local approval. No create sent."); state.FirstCreateAt = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); await SaveState(); } var created = (await Api("/animations", "POST", state.Request, state.IdempotencyKey)).Body; state.TaskId = Id(created["id"]!.GetValue()); await SaveState(); } JsonObject task; string status; while (true) { var result = await Api($"/animations/{Id(state.TaskId)}"); task = result.Body; status = task["status"]!.GetValue(); Console.WriteLine($"Task {state.TaskId}: {Safe(status)}"); if (status is "succeeded" or "failed" or "cancelled") break; if (status is not ("queued" or "running" or "cancelling")) throw new SafeFailure("Unknown task status; preserve the state."); await Wait(result.Delay); } var output = Env("ANIMGEN_OUTPUT") ?? "animgen-output"; Directory.CreateDirectory(output); bool failed = task["outputs"]!.AsArray().Count == 0; foreach (var item in task["outputs"]!.AsArray()) { try { bool saved = false; for (int refresh = 0; refresh < 2 && !saved; refresh++) { var asset = (await Api($"/assets/{Id(item!["id"]!.GetValue())}")).Body; long expected = asset["byte_size"]!.GetValue(); if (expected < 0) throw new SafeFailure("Invalid asset size."); var suffix = asset["mime_type"]!.GetValue() switch { "application/zip" => ".zip", "image/png" => ".png", "application/json" => ".json", "video/mp4" => ".mp4", "video/webm" => ".webm", "video/quicktime" => ".mov", _ => ".bin" }; var target = Path.Combine(output, Id(asset["id"]!.GetValue()) + suffix); if (File.Exists(target)) { if ((File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0 || new FileInfo(target).Length != expected) throw new SafeFailure("Existing output does not match."); saved = true; continue; } var url = new Uri(asset["download_url"]!.GetValue()); HttpResponseMessage? response = null; using var cancellation = new CancellationTokenSource(Remaining()); try { for (int redirects = 0; redirects <= 3; redirects++) { if (url.UserInfo != "" || url.Fragment != "" || (url.Scheme != "https" && !(local && url.GetLeftPart(UriPartial.Authority) == baseUri.GetLeftPart(UriPartial.Authority)))) throw new SafeFailure("Unsafe download URL."); response = await downloads.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellation.Token); // No Bearer credentials. if ((int)response.StatusCode is not (301 or 302 or 303 or 307 or 308)) break; if (redirects == 3 || response.Headers.Location == null) throw new SafeFailure("Unsafe redirect chain."); url = new Uri(url, response.Headers.Location); response.Dispose(); } if (response != null && response.StatusCode is (HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden or HttpStatusCode.NotFound) && refresh == 0) continue; if (response?.IsSuccessStatusCode != true) throw new SafeFailure("Asset transfer failed; URL omitted."); var temporary = Path.Combine(output, $".animgen-download-{Guid.NewGuid()}.part"); try { await using (var file = PrivateFile(temporary)) await using (var stream = await response.Content.ReadAsStreamAsync(cancellation.Token)) { var buffer = new byte[65536]; long bytes = 0; int count; while ((count = await stream.ReadAsync(buffer, cancellation.Token)) > 0) { Remaining(); bytes += count; if (bytes > expected) throw new SafeFailure("Asset exceeds declared size."); await file.WriteAsync(buffer.AsMemory(0, count), cancellation.Token); } if (bytes != expected) throw new SafeFailure("Incomplete download."); } File.Move(temporary, target, false); saved = true; Console.WriteLine($"Saved asset: {target}"); } finally { if (File.Exists(temporary)) File.Delete(temporary); } } finally { response?.Dispose(); } } if (!saved) throw new SafeFailure("Could not save asset."); } catch { failed = true; Console.Error.WriteLine("An asset could not be saved; resume the SAME state. Sensitive details omitted."); } } if (status != "succeeded") Console.Error.WriteLine($"Task incomplete: {Safe(task["error"]?["code"]?.GetValue() ?? status)}. Available outputs were processed."); return status == "succeeded" && !failed ? 0 : 2; } }