函数/New-Reve图像.ps1

function New-Reve图像 {
    <#
    .SYNOPSIS
        通过 Reve 2.1 中转站(Atlas Cloud 统一协议)生成图像。
    .DESCRIPTION
        走 Atlas Cloud 统一图像接口(POST /api/v1/model/generateImage)调用 Reve 2.1。
        任务为异步提交:优先使用同步模式(enable_sync_mode)等待结果;若中转站
        未完成即返回,将自动轮询 prediction 接口直到出图或超时。
        密钥、基础地址与模型以 DPAPI 加密记住。

        模型变体自动选择(未显式指定 -模型 时):
        - 无 -参考图:使用 reve-ai/reve-2.1/text-to-image(纯文生图)。
        - 1 张 -参考图:使用 reve-ai/reve-2.1/edit(图像编辑),提示词为编辑指令。
        - 2~6 张 -参考图:使用 reve-ai/reve-2.1/remix(多图融合),提示词中用
          <frame>0</frame>、<frame>1</frame> 等引用对应序号的参考图。

        Atlas Cloud 的 effects 效果后处理(颗粒、光斑等 51 种)暂未开放参数。
    .PARAMETER 提示词
        图像描述(最长 4000 字符)。与 -提示文件 二选一。
    .PARAMETER 提示文件
        提示词文本文件路径。与 -提示词 二选一。
    .PARAMETER 密钥
        交互式输入密钥开关。指定此开关时会提示输入。
    .PARAMETER 密钥值
        API 令牌明文。未指定时查找已记住的值。
    .PARAMETER 基础地址
        中转站 Base URL。默认 https://api.atlascloud.ai。
    .PARAMETER 模型
        完整模型 ID。未指定时按参考图数量自动选择:
        0 张 → text-to-image,1 张 → edit,2 张以上 → remix。
    .PARAMETER 参考图
        参考图像路径或 URL,1~6 张。
        1 张时走 edit(编辑指令式提示词);2 张以上走 remix,
        提示词中用 <frame>N</frame> 引用(N 从 0 开始)。
    .PARAMETER 宽高比
        auto | 4:1 | 3:1 | 21:9 | 2:1 | 17:9 | 16:9 | 3:2 | 4:3 | 5:4 | 1:1 | 4:5 | 3:4 | 2:3 | 9:16 | 1:2 | 1:3 | 1:4。默认 auto。
    .PARAMETER 去背景
        移除背景,仅保留中央主体并输出透明背景。
    .PARAMETER 输出路径
        输出文件路径。默认时间戳 PNG。扩展名决定 output_format(.jpg/.jpeg → jpeg,.webp → webp,其余 png);-去背景 时强制 png。
    .PARAMETER 超时秒数
        单次 HTTP 请求的超时秒数(提交与轮询各自独立计时),默认 500。
        注意:轮询与下载为无限重试,不设总时长上限;此参数仅约束单个请求。
    .PARAMETER 轮询间隔秒数
        轮询 prediction 接口的间隔,默认 3。
    .EXAMPLE
        New-Reve图像 -提示词 "水彩柴犬" -密钥值 'sk-xxx'
    .EXAMPLE
        New-Reve图像 -提示词 "让人物穿上宇航服" -参考图 .\照片.jpg
    .EXAMPLE
        New-Reve图像 -提示词 "<frame>0</frame> 中的人物穿上宇航服,站在 <frame>1</frame> 的场景里" -参考图 .\人物.jpg, .\场景.jpg
    .EXAMPLE
        New-Reve图像 -提示词 "logo 图标" -宽高比 1:1 -去背景
    #>

    [CmdletBinding(DefaultParameterSetName = '提示词')]
    param(
        [Parameter(Mandatory = $true, Position = 0, ParameterSetName = '提示词')]
        [ValidateNotNullOrEmpty()]
        [string]$提示词,

        [Parameter(Mandatory = $true, ParameterSetName = '提示文件')]
        [ValidateNotNullOrEmpty()]
        [string]$提示文件,

        [Parameter()]
        [switch]$密钥,

        [Parameter()][string]$密钥值,
        [Parameter()][string]$基础地址,
        [Parameter()][string]$模型,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string[]]$参考图,

        [Parameter()][string]$宽高比 = 'auto',

        [Parameter()]
        [switch]$去背景,

        [Parameter()]
        [string]$输出路径 = ".\reve_$(Get-Date -Format 'yyyyMMdd_HHmmss').png",

        [Parameter()][int]$超时秒数 = 500,

        [Parameter()][ValidateRange(1, 30)]
        [int]$轮询间隔秒数 = 3
    )

    $配置路径 = Join-Path $env:LOCALAPPDATA 'Image-generation-cli\AI图像生成-Reve配置.xml'

    # 提示词
    if ($PSCmdlet.ParameterSetName -eq '提示文件') {
        $提示词 = Read-提示文件 -路径 $提示文件
    }
    if ($提示词.Length -gt 4000) {
        throw "提示词过长:$($提示词.Length) 字符,Reve 上限为 4000 字符。"
    }

    # 凭据
    if ($密钥.IsPresent -and -not $密钥值) {
        $安全密钥 = Read-Host -Prompt '请输入 API 密钥' -AsSecureString
        $密钥值 = [System.Net.NetworkCredential]::new([string]::Empty, $安全密钥).Password
    }
    $记住的配置 = Import-记住的配置 -配置路径 $配置路径

    # 基础地址默认值
    if (-not $基础地址 -and -not ($记住的配置 -and $记住的配置.基础地址)) {
        $基础地址 = 'https://api.atlascloud.ai'
    }

    # 模型变体自动选择(未显式指定 -模型 时):0 张 → text-to-image,1 张 → edit,2 张以上 → remix
    if (-not $MyInvocation.BoundParameters.ContainsKey('模型')) {
        if (-not $参考图) { $期望变体 = 'text-to-image' }
        elseif (@($参考图).Count -eq 1) { $期望变体 = 'edit' }
        else { $期望变体 = 'remix' }
        if ($记住的配置 -and $记住的配置.模型) {
            if ($记住的配置.模型 -notlike ('*/' + $期望变体)) {
                $旧模型 = $记住的配置.模型
                $记住的配置.模型 = $旧模型 -replace '/(remix|text-to-image|edit)$', ('/' + $期望变体)
                Write-Host "参考图情况变化,模型自动切换:$旧模型 → $($记住的配置.模型)" -ForegroundColor Yellow
            }
        }
        else {
            $模型 = 'reve-ai/reve-2.1/' + $期望变体
        }
    }

    $凭据 = Resolve-配置凭据 -参数密钥 $密钥值 -参数基础地址 $基础地址 -参数模型 $模型 `
        -记住的配置 $记住的配置 -配置路径 $配置路径

    # 参考图:URL 直接透传,本地文件转 base64(单张 ≤40MB,总计 ≤100MB)
    $图像值列表 = [System.Collections.Generic.List[string]]::new()
    if ($参考图) {
        $总数据量 = [long]0
        foreach ($单张 in $参考图) {
            if ($单张 -match '^https?://') {
                $图像值列表.Add($单张)
            }
            else {
                $数据 = Get-图像数据 -来源 $单张
                if ($数据.字节.Length -gt 41943040) { throw "参考图过大(上限 40MB):$单张" }
                $总数据量 += $数据.字节.Length
                $图像值列表.Add([Convert]::ToBase64String($数据.字节))
            }
        }
        if ($总数据量 -gt 104857600) { throw "参考图总大小超过 100MB 上限。" }
    }

    # 输出格式由扩展名决定;-去背景 需要透明通道,强制 png
    $输出扩展名 = [System.IO.Path]::GetExtension($输出路径).ToLowerInvariant()
    $输出格式 = switch ($输出扩展名) {
        '.jpg'  { 'jpeg' }
        '.jpeg' { 'jpeg' }
        '.webp' { 'webp' }
        default { 'png' }
    }
    if ($去背景.IsPresent -and $输出格式 -eq 'jpeg') {
        $输出格式 = 'png'
        $输出路径 = [System.IO.Path]::ChangeExtension($输出路径, '.png')
        Write-Warning "去背景需要透明通道,输出已改为 png:$输出路径"
    }

    # 端点
    if ($凭据.基础地址 -match '/api/v\d+/?$') { $api基础 = $凭据.基础地址.TrimEnd('/') }
    else { $api基础 = $凭据.基础地址.TrimEnd('/') + '/api/v1' }
    $提交端点 = "$api基础/model/generateImage"

    # 请求体(Atlas Cloud 统一图像协议)
    # 不启用 enable_sync_mode:同步等待会让同一连接挂起数十秒等出图,期间易被网络切断;
    # 异步提交立即返回任务 ID,下方轮询循环负责等到出图。
    # enable_base64_output=false(URL 模式):成果以 CDN URL 持久化,下载中断后只需
    # 重新下载同一 URL,不必重新生成(重新生成会再次计费)。
    $请求体对象 = [ordered]@{
        model                = $凭据.模型
        prompt               = $提示词
        aspect_ratio         = $宽高比
        output_format        = $输出格式
        enable_base64_output = $false
    }
    if ($图像值列表.Count -gt 0) {
        if ($凭据.模型 -like '*/edit') {
            # Edit 变体使用单数字段 image(URL 或 base64 字符串)
            $请求体对象['image'] = $图像值列表[0]
        }
        else {
            $图像值数组 = $图像值列表.ToArray()
            $请求体对象['images'] = $图像值数组
        }
    }
    if ($去背景.IsPresent) { $请求体对象['remove_background'] = $true }

    $请求体 = $请求体对象 | ConvertTo-Json -Depth 10

    Write-Host "正在调用 $($凭据.模型) 生成图像(原生 4K,通常需要数十秒)..." -ForegroundColor Cyan

    # 用 HttpClient 提交:参考图 base64 后请求体可达数十 MB,
    # Invoke-RestMethod(HttpWebRequest)默认的 Expect 100-continue 在大体积上传时易被服务端断开。
    # 密钥验证失败(401,实测密钥过期时返回此码)时不直接失败退出:
    # 立即交互式提示输入新密钥,然后重试提交。
    Add-Type -AssemblyName System.Net.Http
    while ($true) {
        $客户端 = [System.Net.Http.HttpClient]::new()
        $客户端.Timeout = [TimeSpan]::FromSeconds($超时秒数)
        $客户端.DefaultRequestHeaders.Authorization =
            [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer', $凭据.密钥明文)
        $内容 = [System.Net.Http.StringContent]::new($请求体, [System.Text.Encoding]::UTF8, 'application/json')
        try {
            $响应消息 = $客户端.PostAsync($提交端点, $内容).GetAwaiter().GetResult()
            $响应文本 = $响应消息.Content.ReadAsStringAsync().GetAwaiter().GetResult()
            if (-not $响应消息.IsSuccessStatusCode) {
                $状态码 = [int]$响应消息.StatusCode
                if ($状态码 -eq 401) {
                    Write-Warning "密钥验证失败(HTTP $状态码):$响应文本"
                    $新安全密钥 = Read-Host -Prompt '请重新输入正确的 API 密钥以立即重试(留空则取消)' -AsSecureString
                    if ($新安全密钥.Length -eq 0) {
                        throw [System.Management.Automation.RuntimeException]"未输入密钥,请求已取消。"
                    }
                    $凭据.安全密钥对象 = $新安全密钥
                    $凭据.密钥明文 = [System.Net.NetworkCredential]::new([string]::Empty, $新安全密钥).Password
                    $凭据.密钥来源 = '交互输入'
                    Write-Host "使用新密钥重试提交 ..." -ForegroundColor Yellow
                    continue
                }
                throw [System.Management.Automation.RuntimeException]"HTTP $状态码:$响应文本"
            }
            $响应 = $响应文本 | ConvertFrom-Json
            break
        }
        catch [System.Management.Automation.MethodInvocationException] {
            $详情 = Get-NetException详情 -Exception $_.Exception
            throw [System.Management.Automation.RuntimeException]("API 请求失败:$详情")
        }
        catch [System.Management.Automation.RuntimeException] { throw }
        catch { throw "API 请求失败:$($_.Exception.Message)" }
        finally { $内容.Dispose(); $客户端.Dispose() }
    }

    # 记住凭据
    if ($凭据.密钥来源 -ne '已记住' -or $凭据.基础地址来源 -ne '已记住' -or $凭据.模型来源 -ne '已记住') {
        try {
            Save-配置 -安全密钥 $凭据.安全密钥对象 -保存基础地址 $凭据.基础地址 -保存模型 $凭据.模型 -配置路径 $配置路径
            Write-Host "密钥、基础地址与模型已记住:$配置路径" -ForegroundColor DarkGray
        }
        catch { Write-Warning "配置保存失败:$($_.Exception.Message)" }
    }

    # 响应外层 code 检查
    $code属性 = $响应.PSObject.Properties['code']
    if ($code属性 -and $code属性.Value -ne 0 -and $code属性.Value -ne 200) {
        $message属性 = $响应.PSObject.Properties['message']
        $message = if ($message属性 -and $message属性.Value) { $message属性.Value } else { '' }
        throw "API 返回失败(code=$($code属性.Value)):$message"
    }
    $data属性 = $响应.PSObject.Properties['data']
    $数据 = if ($data属性) { $data属性.Value } else { $null }
    if (-not $数据) {
        $响应预览 = $(try { $响应 | ConvertTo-Json -Depth 6 -Compress } catch { (Out-String -InputObject $响应) })
        throw "响应中没有 data 字段:$响应预览"
    }

    $任务ID属性 = $数据.PSObject.Properties['id']
    if (-not $任务ID属性 -or -not $任务ID属性.Value) {
        throw "响应中没有任务 ID:$($数据 | ConvertTo-Json -Depth 6 -Compress)"
    }
    $任务ID = $任务ID属性.Value

    # 状态与 outputs
    $状态 = ''
    $状态属性 = $数据.PSObject.Properties['status']
    if ($状态属性 -and $状态属性.Value) { $状态 = $状态属性.Value }
    $输出列表 = $null
    $输出属性 = $数据.PSObject.Properties['outputs']
    if ($输出属性 -and $输出属性.Value) { $输出列表 = @($输出属性.Value) }

    # 异步提交后轮询等待出图。区分两类轮询失败:
    # - 网络抖动(连接中断、超时、5xx):短请求,无限重试等待恢复;
    # - 业务性失败(4xx:任务不存在、参数错误、权限问题等):重试无意义,立即报错退出。
    # 不设总时长上限(-超时秒数 仅作用于单次请求),直到服务端给出终态。
    $轮询端点 = "$api基础/model/prediction/$任务ID"
    while ($状态 -notin @('completed', 'succeeded', 'failed', 'timeout')) {
        Start-Sleep -Seconds $轮询间隔秒数
        try {
            $查询 = Invoke-RestMethod -Uri $轮询端点 -Headers @{ 'Authorization' = "Bearer $($凭据.密钥明文)" } -TimeoutSec 30
        }
        catch {
            # 从异常链中找出 HTTP 状态码(4xx = 业务性失败,5xx/无响应 = 抖动)
            $轮询状态码 = $null
            $当前异常 = $_.Exception
            foreach ($忽略 in 1..8) {
                if ($null -eq $当前异常) { break }
                if ($当前异常 -is [System.Net.WebException] -and $当前异常.Response) {
                    try { $轮询状态码 = [int]$当前异常.Response.StatusCode } catch { }
                    break
                }
                $当前异常 = $当前异常.PSObject.Properties['InnerException'] | ForEach-Object { $_.Value }
            }
            if ($轮询状态码 -and $轮询状态码 -ge 400 -and $轮询状态码 -lt 500) {
                $详情 = Get-NetException详情 -Exception $_.Exception
                throw "查询任务状态失败(HTTP $轮询状态码,业务性错误,不再重试):$详情 (任务 ID:$任务ID)"
            }
            Write-Warning "轮询失败(网络抖动,稍后重试):$($_.Exception.Message)"
            continue
        }
        $查询data属性 = $查询.PSObject.Properties['data']
        if (-not $查询data属性 -or -not $查询data属性.Value) { continue }
        # 用最新一次查询的数据覆盖,保证后续错误/输出解析基于最终状态
        $数据 = $查询data属性.Value
        $查询状态属性 = $数据.PSObject.Properties['status']
        if ($查询状态属性 -and $查询状态属性.Value) { $状态 = $查询状态属性.Value }
        $查询输出属性 = $数据.PSObject.Properties['outputs']
        if ($查询输出属性 -and $查询输出属性.Value) { $输出列表 = @($查询输出属性.Value) }
    }

    if ($状态 -in @('failed', 'timeout')) {
        $失败信息 = ''
        $错误属性 = $数据.PSObject.Properties['error']
        if ($错误属性 -and $错误属性.Value) { $失败信息 = $错误属性.Value }
        throw "图像生成失败(status=$状态):$失败信息 (任务 ID:$任务ID)"
    }

    if (-not $输出列表 -or $输出列表.Count -eq 0) {
        throw "任务完成但没有输出内容:$($数据 | ConvertTo-Json -Depth 6 -Compress)"
    }

    # NSFW 检查
    $nsfw属性 = $数据.PSObject.Properties['has_nsfw_contents']
    if ($nsfw属性 -and $nsfw属性.Value -and @($nsfw属性.Value).Count -gt 0 -and @($nsfw属性.Value)[0] -eq $true) {
        throw "生成内容被判定为 NSFW 而拒绝返回。请修改提示词后重试。"
    }

    # 耗时信息
    $耗时属性 = $数据.PSObject.Properties['executionTime']
    if ($耗时属性 -and $耗时属性.Value) {
        Write-Host "远端耗时: $([math]::Round($耗时属性.Value / 1000, 1)) 秒" -ForegroundColor DarkGray
    }

    # 保存输出。enable_base64_output=false 已强制 URL 模式,实测确认 outputs[0] 为 CDN URL;
    # 下载失败时带退避无限重试(成果已在远端持久化,重试不重新生成、不额外计费)。
    $输出内容 = "$($输出列表[0])".Trim()

    $解析后输出 = Resolve-输出路径 -输出路径 $输出路径
    if ($输出内容 -match '^https?://') {
        $下载尝试 = 0
        while ($true) {
            $下载尝试++
            try {
                Invoke-WebRequest -Uri $输出内容 -OutFile $解析后输出 -TimeoutSec $超时秒数
                break
            }
            catch {
                Write-Warning "下载失败(第 $下载尝试 次),稍后重试:$($_.Exception.Message)"
                Start-Sleep -Seconds ([Math]::Min($下载尝试, 30))
            }
        }
    }
    else {
        $预览长度 = [Math]::Min(300, $输出内容.Length)
        throw "URL 模式下输出应为 CDN URL,实际不是。前 $预览长度 字符预览:`n$($输出内容.Substring(0, $预览长度))"
    }
    Write-Host "图像已保存: $解析后输出" -ForegroundColor Green
    return $解析后输出
}