feat:去掉无用的函数方法
This commit is contained in:
parent
fbf3e6c63c
commit
31eee930ca
151
dify-laser-workflow.drawio
Normal file
151
dify-laser-workflow.drawio
Normal file
@ -0,0 +1,151 @@
|
||||
<mxfile host="app.diagrams.net" modified="2026-07-08T10:00:00.000Z" agent="Claude" version="22.0.0" type="device">
|
||||
<diagram name="Dify Laser Workflow v3" id="laser-workflow-v3-with-compositor">
|
||||
<mxGraphModel dx="2400" dy="1500" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="2000" pageHeight="1400" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0" />
|
||||
<mxCell id="1" parent="0" />
|
||||
|
||||
<mxCell id="title" value="镭射卡生成完整链路 v3(含后端 compositor 合成) AI 出金属底 + 后端合成人像 + 镭射光效" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=18;fontStyle=1" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="20" width="1000" height="60" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="phase_dify" value="=== Dify 工作流(laser_card_v2_5series)===" style="text;html=1;strokeColor=#9673a6;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="100" width="1920" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="start" value="开始节点 (Start) 输入变量: - cutout_url (string, 必填) 用户风格描述词由 Dify 原生 #context# 注入 (不添加自定义变量)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=11" vertex="1" parent="1">
|
||||
<mxGeometry x="120" y="140" width="320" height="100" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="llm" value="LLM 节点 (gpt-4o-mini) 系统 prompt:spec §5.2 中文版 5 系列模板 + 5 字段 JSON Schema 用户提示:#context# 输出格式:JSON(结构化输出)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="280" width="440" height="120" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="branch_label" value="5 路并发(每路对应一个卡牌系列) AI 出『金属底 + 风格氛围』背景图(纯文生图,不带人像)" style="text;html=1;strokeColor=none;fillColor=none;align=center;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=12;fontStyle=2" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="420" width="440" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="p1_gen" value="生图节点 1 gen_prizm_blue 模型:image-01-live Prompt:{{ llm_rewrite.prizm_blue }} 尺寸:1024x1024 数量:1 → 金属底背景图 1(Prizm Blue 工艺)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="470" width="180" height="140" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="p2_gen" value="生图节点 2 gen_pokemon_rainbow Prompt:{{ llm_rewrite.pokemon_rainbow }} → 金属底背景图 2(彩虹稀有)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="240" y="470" width="180" height="140" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="p3_gen" value="生图节点 3 gen_topps_refractor Prompt:{{ llm_rewrite.topps_refractor }} → 金属底背景图 3(Topps 折射)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="440" y="470" width="180" height="140" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="p4_gen" value="生图节点 4 gen_yugioh_secret Prompt:{{ llm_rewrite.yugioh_secret }} → 金属底背景图 4(横纹 Secret)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="640" y="470" width="180" height="140" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="p5_gen" value="生图节点 5 gen_ws_ssp Prompt:{{ llm_rewrite.ws_ssp }} → 金属底背景图 5(WS SSP)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#e1d5e7;strokeColor=#9673a6;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="840" y="470" width="180" height="140" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="end_dify" value="Dify 结束节点 输出 variants[5] 每项 = {preset_id, signed_url(AI背景图), oss_key} ⚠️ 注意:signed_url 是 AI 出的金属底背景图, 不含人像(人像在后端 compositor 叠加)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f8cecc;strokeColor=#b85450;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="640" width="960" height="80" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="phase_backend" value="=== 后端 gateway 服务(topfans-gateway)===" style="text;html=1;strokeColor=#d6b656;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="760" width="1920" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="handle_dify" value="handleDifyBlocking() 控制器 1. POST /v1/workflows/run 调 Dify 2. 解析 5 个 variant(AI 背景图) 3. **逐张调 compositor.Compose() 合成** 4. 上传合成图到 OSS 5. 返回 5 张最终镭射卡" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="800" width="380" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="compositor" value="compositor.Compose()【核心:6 层合成】 输入: - BackgroundURL(AI 出的金属底背景图) - CutoutURL(用户上传的人像) - GratingConfig(5 套光栅参数) 合成 6 层: 1. AI 艺术背景 2. 金属反光梯度 3. 彩虹衍射光栅色带(核心镭射) 4. AI 装饰层(可选) 5. 胶片颗粒 + 磨砂哑光 6. 人物抠图(最顶层) 输出:合成后的 PNG bytes" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#fad7ac;strokeColor=#b46504;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="500" y="800" width="520" height="280" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="oss" value="OSS 上传 把合成 PNG bytes 上传到 top-fans-test bucket key: laser-card/compositor/ {star_id}/{user_id}/{preset_id}_ {ts}.png 返回 final_url(signed URL,2h 过期)" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#ffe6cc;strokeColor=#d79b00;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="1080" y="800" width="380" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="frontend" value="前端 LenticularCard.vue 5 张最终镭射卡 (每张都是『用户人像 + 镭射金属底 + 风格场景』完整合成) 用户选 1 张 → 走铸造" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="1500" y="800" width="460" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="phase_db" value="=== 数据库(topfans-postgres)===" style="text;html=1;strokeColor=#82b366;fillColor=none;align=left;verticalAlign=middle;whiteSpace=wrap;rounded=0;fontSize=14;fontStyle=1" vertex="1" parent="1">
|
||||
<mxGeometry x="40" y="1130" width="1920" height="30" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="db" value="laser_card_instances 表 INSERT 一行: - instance_no (LC + 时间戳 + 随机) - template_id (preset_id → template.id) - status = 'rendered' - materials_snapshot = [{role:'composite', oss_key, preset_id} x5] - composite_oss_key = final image key" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontSize=11;align=left;spacingLeft=10" vertex="1" parent="1">
|
||||
<mxGeometry x="60" y="1170" width="800" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="legend" value="图例: ■ 绿色 = 开始 / 输入 / 前端 / DB ■ 蓝色 = LLM 处理 / DB 操作 ■ 紫色 = AI 生图节点(5 路并发) ■ 黄色 = 后端 gateway 处理 ■ 橙色 = compositor 合成(6 层) ■ 红色 = 结束 / 输出" style="rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;fontSize=11;align=left;spacingLeft=10;verticalAlign=top" vertex="1" parent="1">
|
||||
<mxGeometry x="1500" y="1170" width="460" height="160" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_start_llm" value="cutout_url + #context#" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#6c8ebf;strokeWidth=2;fontSize=11" edge="1" parent="1" source="start" target="llm">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_llm_p1" value="json.prizm_blue" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.1;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#9673a6;strokeWidth=1;fontSize=11" edge="1" parent="1" source="llm" target="p1_gen">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_llm_p2" value="json.pokemon_rainbow" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.3;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#9673a6;strokeWidth=1;fontSize=11" edge="1" parent="1" source="llm" target="p2_gen">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_llm_p3" value="json.topps_refractor" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#9673a6;strokeWidth=1;fontSize=11" edge="1" parent="1" source="llm" target="p3_gen">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_llm_p4" value="json.yugioh_secret" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.7;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#9673a6;strokeWidth=1;fontSize=11" edge="1" parent="1" source="llm" target="p4_gen">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_llm_p5" value="json.ws_ssp" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.9;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#9673a6;strokeWidth=1;fontSize=11" edge="1" parent="1" source="llm" target="p5_gen">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_p1_end" value="bg1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.1;entryY=0;endArrow=classic;strokeColor=#b85450;strokeWidth=1;fontSize=10" edge="1" parent="1" source="p1_gen" target="end_dify">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_p2_end" value="bg2" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.25;entryY=0;endArrow=classic;strokeColor=#b85450;strokeWidth=1;fontSize=10" edge="1" parent="1" source="p2_gen" target="end_dify">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_p3_end" value="bg3" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.4;entryY=0;endArrow=classic;strokeColor=#b85450;strokeWidth=1;fontSize=10" edge="1" parent="1" source="p3_gen" target="end_dify">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_p4_end" value="bg4" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.55;entryY=0;endArrow=classic;strokeColor=#b85450;strokeWidth=1;fontSize=10" edge="1" parent="1" source="p4_gen" target="end_dify">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_p5_end" value="bg5" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.7;entryY=0;endArrow=classic;strokeColor=#b85450;strokeWidth=1;fontSize=10" edge="1" parent="1" source="p5_gen" target="end_dify">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_end_backend" value="variants[5]" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#d6b656;strokeWidth=2;fontSize=12" edge="1" parent="1" source="end_dify" target="handle_dify">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_handle_compositor" value="5x compose call" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;strokeColor=#b46504;strokeWidth=2;fontSize=12" edge="1" parent="1" source="handle_dify" target="compositor">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_compositor_oss" value="PNG bytes" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;strokeColor=#d6b656;strokeWidth=2;fontSize=12" edge="1" parent="1" source="compositor" target="oss">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_oss_frontend" value="5 final URLs" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=1;exitY=0.5;entryX=0;entryY=0.5;endArrow=classic;strokeColor=#82b366;strokeWidth=2;fontSize=12" edge="1" parent="1" source="oss" target="frontend">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
<mxCell id="e_handle_dify" value="INSERT" style="edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;exitX=0.5;exitY=1;entryX=0.5;entryY=0;endArrow=classic;strokeColor=#6c8ebf;strokeWidth=1;fontSize=11" edge="1" parent="1" source="handle_dify" target="db">
|
||||
<mxGeometry relative="1" as="geometry" />
|
||||
</mxCell>
|
||||
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@ -159,22 +159,24 @@ do_install() {
|
||||
print_msg "$YELLOW" " 如需重新配置,请先备份 ${SERVER_PATH}/.env 后删除"
|
||||
else
|
||||
# 下载 Dify docker-compose
|
||||
print_msg "$YELLOW" "下载 Dify docker-compose..."
|
||||
# ★ 版本说明:v0.15.0 的 sandbox 镜像有 seccomp bug(Python 启动崩溃)
|
||||
# 必须用 Dify v1.x 或更新版本
|
||||
print_msg "$YELLOW" "下载 Dify docker-compose(v1.x)..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/docker-compose.yaml \
|
||||
-o docker-compose.yml 2>/dev/null || \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/v0.15.0/docker/docker-compose.yaml \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/1.4.0/docker/docker-compose.yaml \
|
||||
-o docker-compose.yml"
|
||||
print_msg "$GREEN" "✅ docker-compose.yml 下载完成"
|
||||
print_msg "$GREEN" "✅ docker-compose.yml 下载完成(v1.x)"
|
||||
|
||||
# 下载 .env 示例文件
|
||||
print_msg "$YELLOW" "下载 .env 配置..."
|
||||
print_msg "$YELLOW" "下载 .env 配置(v1.x)..."
|
||||
ssh_cmd "cd ${SERVER_PATH} && \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/main/docker/.env.example \
|
||||
-o .env 2>/dev/null || \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/v0.15.0/docker/.env.example \
|
||||
curl -L https://raw.githubusercontent.com/langgenius/dify/1.4.0/docker/.env.example \
|
||||
-o .env"
|
||||
print_msg "$GREEN" "✅ .env 下载完成"
|
||||
print_msg "$GREEN" "✅ .env 下载完成(v1.x)"
|
||||
|
||||
# 修改 .env 配置(端口、密码等)
|
||||
# ★ heredoc delimiter 不可加单引号,否则 \$VAR 不展开
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,57 +0,0 @@
|
||||
<template>
|
||||
<view class="lc-wrap">
|
||||
<view class="lc-frame">
|
||||
<LenticularCard
|
||||
:layers="lenticularLayers"
|
||||
:transforms="layerTransforms"
|
||||
gyro-source="simulation"
|
||||
:approximate-preview="true"
|
||||
tilt-hint-text="滑动卡片预览光栅效果"
|
||||
:show-slider="showSlider"
|
||||
:simulate-tilt-from-normalized="simulate"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import LenticularCard from './LenticularCard.vue'
|
||||
import { useLenticularPreview } from '@/composables/useLenticularPreview.js'
|
||||
import { buildLenticularLayers } from '@/utils/castloveMintForm.js'
|
||||
|
||||
const props = defineProps({
|
||||
/** 用户上传图:作为光栅主体层 */
|
||||
imageSrc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
showSlider: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const lenticularLayers = ref(buildLenticularLayers(props.imageSrc || ''))
|
||||
|
||||
watch(
|
||||
() => props.imageSrc,
|
||||
(s) => {
|
||||
lenticularLayers.value = buildLenticularLayers(s || '')
|
||||
}
|
||||
)
|
||||
|
||||
const { layerTransforms, simulate } = useLenticularPreview(lenticularLayers)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lc-wrap {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 16rpx 0 32rpx;
|
||||
}
|
||||
|
||||
.lc-frame {
|
||||
width: 100%;
|
||||
height: 520rpx;
|
||||
max-height: 70vh;
|
||||
}
|
||||
</style>
|
||||
@ -1,277 +0,0 @@
|
||||
<template>
|
||||
<view class="holo-container">
|
||||
<view class="holo-frame" :style="frameStyle">
|
||||
<!-- WebGL 渲染层 —— Canvas 缓冲 = CSS × DPR,Mipmap + MSAA -->
|
||||
<canvas
|
||||
v-show="webglReady && !hasError"
|
||||
ref="webglCanvas"
|
||||
class="holo-canvas"
|
||||
:style="canvasStyle"
|
||||
@touchstart.stop="onTouchStart"
|
||||
@touchmove.stop.prevent="onTouchMove"
|
||||
@touchend.stop="onTouchEnd"
|
||||
@touchcancel.stop="onTouchEnd"
|
||||
/>
|
||||
|
||||
<!-- CSS 降级层(WebGL 不可用时自动启用) -->
|
||||
<view v-if="!webglReady || hasError" class="holo-fallback" :style="fallbackStyle">
|
||||
<view class="holo-fallback-card" :style="fallbackCardStyle">
|
||||
<image
|
||||
v-if="baseImageSrc"
|
||||
class="holo-fallback-img"
|
||||
:src="baseImageSrc"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view class="holo-fallback-shimmer" />
|
||||
<view class="holo-fallback-rim" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="showHint && hintText" class="holo-hint" :class="{ 'holo-hint--hidden': !showHint }">
|
||||
<text class="holo-hint-icon">↻</text>
|
||||
<text class="holo-hint-text">{{ hintText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<ManualTiltSlider
|
||||
v-if="showSlider"
|
||||
:value="sliderValue"
|
||||
@change="v => emit('update:sliderValue', v)"
|
||||
@reset="emit('sliderReset')"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import { HolographicEngine } from '@/utils/webgl/holographic-engine.js'
|
||||
import { loadTextureImage } from '@/utils/laser-card/laserPreviewWebgl.js'
|
||||
import ManualTiltSlider from './ManualTiltSlider.vue'
|
||||
|
||||
const props = defineProps({
|
||||
baseImageSrc: { type: String, default: '' },
|
||||
aspectRatio: { type: Number, default: 3 / 4 },
|
||||
maxWidth: { type: Number, default: 420 },
|
||||
cornerRadius: { type: Number, default: 24 },
|
||||
effectIntensity: { type: Number, default: 0.85 },
|
||||
dispersionStrength: { type: Number, default: 1.0 },
|
||||
diffractionScale: { type: Number, default: 0.7 },
|
||||
highlightSpeed: { type: Number, default: 0.8 },
|
||||
highlightWidth: { type: Number, default: 1.0 },
|
||||
fresnelPower: { type: Number, default: 3.5 },
|
||||
noiseScale: { type: Number, default: 1.0 },
|
||||
noiseOctaves: { type: Number, default: 6 },
|
||||
safeZoneRadius: { type: Number, default: 0.35 },
|
||||
safeZoneSoftness: { type: Number, default: 0.15 },
|
||||
viewX: { type: Number, default: 0 },
|
||||
viewY: { type: Number, default: 0 },
|
||||
viewZ: { type: Number, default: 0 },
|
||||
hintText: { type: String, default: '移动光标或倾斜设备' },
|
||||
showHint: { type: Boolean, default: true },
|
||||
maxDPR: { type: Number, default: 2 },
|
||||
skipBuiltInTouch: { type: Boolean, default: false },
|
||||
// 新增:手动滑块接入(与 LenticularCard 对齐)
|
||||
showSlider: { type: Boolean, default: false },
|
||||
sliderValue: { type: Number, default: 0.5 },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['simulate', 'update:sliderValue', 'sliderReset', 'ready', 'error', 'fpsUpdate'])
|
||||
|
||||
const webglCanvas = ref(null)
|
||||
const webglReady = ref(false)
|
||||
const hasError = ref(false)
|
||||
let engine = null
|
||||
let resizeObserver = null
|
||||
let fpsInterval = null
|
||||
|
||||
function resolveSize() {
|
||||
const vw = typeof window !== 'undefined' ? window.innerWidth : 375
|
||||
const w = Math.min(props.maxWidth, vw * 0.92)
|
||||
const h = w / props.aspectRatio
|
||||
return { w, h }
|
||||
}
|
||||
|
||||
const frameStyle = computed(() => {
|
||||
const { w, h } = resolveSize()
|
||||
return { width: `${w}px`, height: `${h}px` }
|
||||
})
|
||||
const canvasStyle = computed(() => {
|
||||
const { w, h } = resolveSize()
|
||||
return { width: `${w}px`, height: `${h}px` }
|
||||
})
|
||||
const fallbackStyle = computed(() => {
|
||||
const { w, h } = resolveSize()
|
||||
return { width: `${w}px`, height: `${h}px` }
|
||||
})
|
||||
const fallbackCardStyle = computed(() => {
|
||||
const vx = props.viewX || 0
|
||||
const vy = props.viewY || 0
|
||||
return {
|
||||
borderRadius: `${props.cornerRadius}px`,
|
||||
transform: `perspective(1000px) rotateY(${vx * 15}deg) rotateX(${-vy * 8}deg)`,
|
||||
}
|
||||
})
|
||||
|
||||
function dispatchTouch(clientX, clientY) {
|
||||
if (props.skipBuiltInTouch) return
|
||||
const canvas = webglCanvas.value
|
||||
if (!canvas) return
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
if (!rect.width || !rect.height) return
|
||||
const cx = rect.left + rect.width / 2
|
||||
const cy = rect.top + rect.height / 2
|
||||
emit('simulate',
|
||||
Math.max(-1, Math.min(1, (clientX - cx) / (rect.width / 2) * 1.1)),
|
||||
Math.max(-1, Math.min(1, (clientY - cy) / (rect.height / 2) * 1.1))
|
||||
)
|
||||
}
|
||||
|
||||
function getT(e) {
|
||||
if (e.touches && e.touches[0]) return e.touches[0]
|
||||
if (e.changedTouches && e.changedTouches[0]) return e.changedTouches[0]
|
||||
return null
|
||||
}
|
||||
function onTouchStart(e) { onTouchMove(e) }
|
||||
function onTouchMove(e) {
|
||||
if (props.skipBuiltInTouch) return
|
||||
const t = getT(e)
|
||||
if (t) dispatchTouch(t.clientX, t.clientY)
|
||||
}
|
||||
function onTouchEnd() {
|
||||
if (props.skipBuiltInTouch) return
|
||||
// 修改前:emit('simulate', 0, 0)
|
||||
// 修改后:触摸结束不再发 (0,0)
|
||||
}
|
||||
|
||||
function syncEngineParams() {
|
||||
if (!engine) return
|
||||
engine.setEffectParams({
|
||||
effectIntensity: props.effectIntensity,
|
||||
dispersionStrength: props.dispersionStrength,
|
||||
diffractionScale: props.diffractionScale,
|
||||
highlightSpeed: props.highlightSpeed,
|
||||
highlightWidth: props.highlightWidth,
|
||||
fresnelPower: props.fresnelPower,
|
||||
noiseScale: props.noiseScale,
|
||||
noiseOctaves: Math.round(props.noiseOctaves),
|
||||
cardCornerRadius: props.cornerRadius,
|
||||
safeZoneRadius: props.safeZoneRadius,
|
||||
safeZoneSoftness: props.safeZoneSoftness,
|
||||
})
|
||||
engine.setViewAngle(props.viewX, props.viewY, props.viewZ)
|
||||
}
|
||||
|
||||
watch(() => [
|
||||
props.effectIntensity, props.dispersionStrength, props.diffractionScale,
|
||||
props.highlightSpeed, props.highlightWidth, props.fresnelPower,
|
||||
props.noiseScale, props.noiseOctaves, props.cornerRadius,
|
||||
props.safeZoneRadius, props.safeZoneSoftness,
|
||||
props.viewX, props.viewY, props.viewZ,
|
||||
], syncEngineParams)
|
||||
|
||||
watch(() => props.baseImageSrc, async (src) => {
|
||||
if (!engine || !src) return
|
||||
try {
|
||||
const img = await loadTextureImage(src)
|
||||
engine.uploadBaseImage(img)
|
||||
} catch (e) {
|
||||
console.warn('[HolographicCard] load baseImage failed:', e)
|
||||
}
|
||||
})
|
||||
|
||||
function initWebGL() {
|
||||
if (!HolographicEngine.isSupported()) {
|
||||
hasError.value = true
|
||||
emit('error', new Error('WebGL not supported'))
|
||||
return
|
||||
}
|
||||
const canvas = webglCanvas.value
|
||||
if (!canvas) return
|
||||
try {
|
||||
const { w, h } = resolveSize()
|
||||
engine = new HolographicEngine(canvas, {
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
useFBO: false,
|
||||
})
|
||||
engine.setDPR(Math.min(window.devicePixelRatio || 1, props.maxDPR))
|
||||
if (!engine.init()) {
|
||||
hasError.value = true
|
||||
emit('error', new Error('WebGL init failed'))
|
||||
return
|
||||
}
|
||||
syncEngineParams()
|
||||
engine.start()
|
||||
webglReady.value = true
|
||||
emit('ready')
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
const { width, height } = e.contentRect
|
||||
if (engine && width > 0 && height > 0) engine.resize(width, height)
|
||||
}
|
||||
})
|
||||
resizeObserver.observe(canvas.parentElement || canvas)
|
||||
}
|
||||
|
||||
fpsInterval = setInterval(() => {
|
||||
if (engine) emit('fpsUpdate', engine.getFPS())
|
||||
}, 1000)
|
||||
|
||||
if (props.baseImageSrc) {
|
||||
loadTextureImage(props.baseImageSrc)
|
||||
.then(img => engine.uploadBaseImage(img))
|
||||
.catch(e => console.warn('[HolographicCard] init load:', e))
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[HolographicCard] init error:', e)
|
||||
hasError.value = true
|
||||
emit('error', e)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => { nextTick(initWebGL) })
|
||||
|
||||
onUnmounted(() => {
|
||||
if (fpsInterval) { clearInterval(fpsInterval); fpsInterval = null }
|
||||
if (resizeObserver) { resizeObserver.disconnect(); resizeObserver = null }
|
||||
if (engine) { engine.destroy(); engine = null }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.holo-container { width: 100%; display: flex; align-items: center; justify-content: center; }
|
||||
.holo-frame { position: relative; max-width: 100%; }
|
||||
.holo-canvas { display: block; border-radius: inherit; }
|
||||
|
||||
/* ---- CSS 降级 ---- */
|
||||
.holo-fallback { position: relative; display: flex; align-items: center; justify-content: center; perspective: 1000px; }
|
||||
.holo-fallback-card {
|
||||
width: 100%; height: 100%; overflow: hidden; position: relative;
|
||||
background-color: #060e20; box-shadow: 0 20px 50px rgba(0,0,0,0.55);
|
||||
border: 1px solid rgba(255,255,255,0.18);
|
||||
will-change: transform; transform-style: preserve-3d;
|
||||
}
|
||||
.holo-fallback-img { width: 100%; height: 100%; position: absolute; top: 0; left: 0; }
|
||||
.holo-fallback-shimmer {
|
||||
position: absolute; inset: 0; pointer-events: none;
|
||||
background: linear-gradient(135deg, rgba(221,183,255,0.08) 0%, rgba(100,200,255,0.15) 25%, transparent 50%, rgba(255,180,220,0.12) 75%, rgba(76,215,246,0.08) 100%);
|
||||
}
|
||||
.holo-fallback-rim {
|
||||
position: absolute; inset: 0;
|
||||
border-top: 1px solid rgba(221,183,255,0.42);
|
||||
box-shadow: inset 0 0 40px rgba(255,255,255,0.05);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ---- 提示 ---- */
|
||||
.holo-hint {
|
||||
position: absolute; bottom: 12px; left: 50%; transform: translateX(-50%);
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 8px 16px; background: rgba(0,0,0,0.55); backdrop-filter: blur(12px);
|
||||
border-radius: 20px; transition: opacity 0.4s ease; pointer-events: none;
|
||||
}
|
||||
.holo-hint--hidden { opacity: 0; }
|
||||
.holo-hint-icon { font-size: 16px; color: rgba(255,255,255,0.8); }
|
||||
.holo-hint-text { font-size: 12px; color: rgba(255,255,255,0.75); }
|
||||
</style>
|
||||
@ -1,76 +0,0 @@
|
||||
/**
|
||||
* 全息镭射卡预览组合式函数
|
||||
*
|
||||
* 集成 WebGL HolographicEngine + 触摸交互
|
||||
* simulate(x, y) 是唯一驱动入口
|
||||
*/
|
||||
import { ref, reactive } from 'vue'
|
||||
|
||||
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)) }
|
||||
function lerp(a, b, t) { return a + (b - a) * t }
|
||||
|
||||
export function useHolographicPreview() {
|
||||
const physics = reactive({
|
||||
tiltSensitivity: 72,
|
||||
transitionSmoothness: 66,
|
||||
angleStability: 88,
|
||||
sensorDeadzoneStrength: 1,
|
||||
gyroSimEnabled: true,
|
||||
})
|
||||
const viewAngle = reactive({ x: 0, y: 0, z: 0 })
|
||||
const isWebGLReady = ref(false)
|
||||
const hasWebGLError = ref(false)
|
||||
const fps = ref(0)
|
||||
|
||||
function simulate(x, y) {
|
||||
viewAngle.x = clamp(x, -1, 1)
|
||||
viewAngle.y = clamp(y != null ? y : 0, -1, 1)
|
||||
viewAngle.z = Math.sqrt(viewAngle.x * viewAngle.x + viewAngle.y * viewAngle.y) * 0.5
|
||||
}
|
||||
|
||||
function relax(factor = 0.85) {
|
||||
viewAngle.x *= factor
|
||||
viewAngle.y *= factor
|
||||
viewAngle.z *= factor
|
||||
}
|
||||
|
||||
function onWebGLReady() { isWebGLReady.value = true; hasWebGLError.value = false }
|
||||
function onWebGLError() { hasWebGLError.value = true; isWebGLReady.value = false }
|
||||
function onFPSUpdate(v) { fps.value = v }
|
||||
|
||||
return {
|
||||
physics, viewAngle,
|
||||
isWebGLReady, hasWebGLError, fps,
|
||||
simulate, relax,
|
||||
onWebGLReady, onWebGLError, onFPSUpdate,
|
||||
}
|
||||
}
|
||||
|
||||
export function detectPerformanceTier() {
|
||||
const mem = navigator.deviceMemory || 4
|
||||
const cores = navigator.hardwareConcurrency || 4
|
||||
if (mem <= 2 || cores <= 2) return 'low'
|
||||
if (mem <= 4 || cores <= 4) return 'mid'
|
||||
return 'high'
|
||||
}
|
||||
|
||||
export const HOLO_PERFORMANCE_PRESETS = {
|
||||
high: {
|
||||
effectIntensity: 0.85, dispersionStrength: 1.0, diffractionScale: 0.7,
|
||||
highlightSpeed: 0.8, highlightWidth: 1.0, fresnelPower: 3.5,
|
||||
noiseScale: 1.0, noiseOctaves: 6,
|
||||
safeZoneRadius: 0.35, safeZoneSoftness: 0.15,
|
||||
},
|
||||
mid: {
|
||||
effectIntensity: 0.75, dispersionStrength: 0.8, diffractionScale: 0.55,
|
||||
highlightSpeed: 0.7, highlightWidth: 1.1, fresnelPower: 3.0,
|
||||
noiseScale: 0.8, noiseOctaves: 4,
|
||||
safeZoneRadius: 0.35, safeZoneSoftness: 0.15,
|
||||
},
|
||||
low: {
|
||||
effectIntensity: 0.6, dispersionStrength: 0.5, diffractionScale: 0.35,
|
||||
highlightSpeed: 0.5, highlightWidth: 1.3, fresnelPower: 2.5,
|
||||
noiseScale: 0.55, noiseOctaves: 3,
|
||||
safeZoneRadius: 0.38, safeZoneSoftness: 0.18,
|
||||
},
|
||||
}
|
||||
@ -189,7 +189,7 @@ export function useLenticularCraftTiltPreview(layersRef) {
|
||||
|
||||
/**
|
||||
* 触摸入口:-1..+1 归一化转度数(系数 45)
|
||||
* 由 LenticularCard.vue / HolographicCard.vue 的 @simulate 事件触发
|
||||
* 由 LenticularCard.vue 的 @simulate 事件触发
|
||||
*/
|
||||
function simulateTiltFromNormalized(x, y) {
|
||||
const dx = x * NORMALIZED_TO_DEG
|
||||
|
||||
@ -1,557 +0,0 @@
|
||||
/**
|
||||
* uni-app App 端可用:OSS Post/Put + 签名 GET URL;直连抠图默认 IVPD SegmentImage,另有 imageseg SegmentHDBody(POP,不依赖 ali-oss / Node http)
|
||||
* 签名使用 Web Crypto(HMAC-SHA1),避免额外依赖 crypto-js;需安全上下文(localhost / HTTPS)且存在 globalThis.crypto.subtle。
|
||||
*/
|
||||
const _textEnc = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null
|
||||
|
||||
function utf8StringToBase64(str) {
|
||||
if (_textEnc) {
|
||||
const bytes = _textEnc.encode(str)
|
||||
let bin = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
bin += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
return btoa(unescape(encodeURIComponent(str)))
|
||||
}
|
||||
|
||||
async function hmacSha1Base64(message, secretKey) {
|
||||
const subtle = globalThis.crypto && globalThis.crypto.subtle
|
||||
if (!subtle || !_textEnc) {
|
||||
throw new Error(
|
||||
'当前环境无法计算 OSS/阿里云签名:需要支持 Web Crypto(请在 localhost 或 HTTPS 下打开,并确保非禁用 crypto.subtle 的 WebView)'
|
||||
)
|
||||
}
|
||||
const key = await subtle.importKey(
|
||||
'raw',
|
||||
_textEnc.encode(secretKey),
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
const sig = await subtle.sign('HMAC', key, _textEnc.encode(message))
|
||||
const bytes = new Uint8Array(sig)
|
||||
let bin = ''
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
bin += String.fromCharCode(bytes[i])
|
||||
}
|
||||
return btoa(bin)
|
||||
}
|
||||
|
||||
/** RAM 密钥常见复制问题:首尾空格、BOM、零宽字符、Windows 多余 \\r */
|
||||
function normalizeAccessKeyPart(s) {
|
||||
return String(s || '')
|
||||
.trim()
|
||||
.replace(/^\uFEFF/, '')
|
||||
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\r/g, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function percentEncode(str) {
|
||||
if (str === undefined || str === null) {
|
||||
return ''
|
||||
}
|
||||
return encodeURIComponent(String(str))
|
||||
.replace(/!/g, '%21')
|
||||
.replace(/'/g, '%27')
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/\*/g, '%2A')
|
||||
}
|
||||
|
||||
function pickHttpImageUrl(obj, depth = 0) {
|
||||
if (!obj || depth > 10) return null
|
||||
if (typeof obj === 'string' && /^https?:\/\//i.test(obj) && /\.(png|jpg|jpeg|webp)/i.test(obj)) {
|
||||
return obj
|
||||
}
|
||||
if (typeof obj !== 'object') return null
|
||||
for (const v of Object.values(obj)) {
|
||||
const u = pickHttpImageUrl(v, depth + 1)
|
||||
if (u) return u
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 递归查找 oss://bucket/object 形式 URI */
|
||||
function pickOssUriString(obj, depth = 0) {
|
||||
if (!obj || depth > 10) return null
|
||||
if (typeof obj === 'string' && /^oss:\/\//i.test(obj)) {
|
||||
if (parseOssUri(obj)) {
|
||||
return obj
|
||||
}
|
||||
}
|
||||
if (typeof obj !== 'object') return null
|
||||
for (const v of Object.values(obj)) {
|
||||
const u = pickOssUriString(v, depth + 1)
|
||||
if (u) return u
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function parseOssUri(uri) {
|
||||
const s = String(uri || '').trim()
|
||||
const m = /^oss:\/\/([^/]+)\/(.+)$/i.exec(s)
|
||||
if (!m) return null
|
||||
return { bucket: m[1], objectKey: m[2] }
|
||||
}
|
||||
|
||||
/** PostObject 的目标地址(仅桶根、无 object 路径);失败 errMsg 常只有这一串 URL */
|
||||
export function looksLikeBareOssEndpointUrl(s) {
|
||||
const t = String(s || '').trim()
|
||||
if (!/^https?:\/\/[^/\s]+\.oss-[a-z0-9-]+\.aliyuncs\.com\/?$/i.test(t)) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const u = new URL(t)
|
||||
if (!/^https?:$/i.test(u.protocol)) return false
|
||||
const segs = u.pathname.split('/').filter(Boolean)
|
||||
return segs.length === 0
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅一行 OSS 根域名错误时,转成可操作的说明(依赖 manifest domainWhiteList) */
|
||||
export function humanizeIfBareOssUploadErr(msg) {
|
||||
const t = String(msg || '').trim()
|
||||
if (!looksLikeBareOssEndpointUrl(t)) {
|
||||
return String(msg || '')
|
||||
}
|
||||
const hostOnly = t.replace(/^https?:\/\//i, '').replace(/\/$/, '')
|
||||
return (
|
||||
`OSS 上传失败(uni-app 常因未配置「域名白名单」拦截 uploadFile,控制台只显示请求 URL)。\n\n请在 manifest.json → app-plus → networkSecurity → domainWhiteList 中加入(注意 https):\n• https://${hostOnly}\n• IVPD:https://ivpd.<地域>.aliyuncs.com(与 IMM_REGION 一致,如 cn-shanghai)\n以及抠图结果下载域名(若与上面不同)。\n保存后必须重新制作自定义调试基座再运行。\n\n原文:${t}`
|
||||
)
|
||||
}
|
||||
|
||||
/** 排除仅桶根域名、无对象路径的「伪 URL」(IMM 偶发只回 Host) */
|
||||
function isLikelyObjectHttpUrl(url) {
|
||||
try {
|
||||
const u = new URL(String(url).trim())
|
||||
if (!/^https?:$/i.test(u.protocol)) {
|
||||
return false
|
||||
}
|
||||
const parts = u.pathname.split('/').filter(Boolean)
|
||||
return parts.length >= 1
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function ossHost(bucket, ossRegionId) {
|
||||
const r = ossRegionId.replace(/^oss-/, '')
|
||||
return `${bucket}.oss-${r}.aliyuncs.com`
|
||||
}
|
||||
|
||||
/** objectKey 含路径时用分段编码路径 */
|
||||
function ossObjectPath(objectKey) {
|
||||
return objectKey
|
||||
.split('/')
|
||||
.map((seg) => encodeURIComponent(seg))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
/** 私有读:签名 GET URL(与 Put 后给分割接口用的 URL 一致) */
|
||||
export async function ossPresignedObjectUrl({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
objectKey,
|
||||
expiresSec = 3600
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
const host = ossHost(bucket, ossRegionId)
|
||||
const path = ossObjectPath(objectKey)
|
||||
const resource = `/${bucket}/${objectKey}`
|
||||
const expires = Math.floor(Date.now() / 1000) + expiresSec
|
||||
const stringToSignGet = `GET\n\n\n${expires}\n${resource}`
|
||||
const sigGet = await hmacSha1Base64(stringToSignGet, sk)
|
||||
let qs = `OSSAccessKeyId=${percentEncode(ak)}&Expires=${expires}&Signature=${percentEncode(sigGet)}`
|
||||
if (securityToken) {
|
||||
qs += `&security-token=${percentEncode(securityToken)}`
|
||||
}
|
||||
return `https://${host}/${path}?${qs}`
|
||||
}
|
||||
|
||||
/**
|
||||
* PostObject + policy:用 uni.uploadFile 直传本地路径,不经 JS 读 ArrayBuffer(调试基座读图失败时的主路径)。
|
||||
*/
|
||||
export async function ossPostObjectFromLocalFilePath({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
objectKey,
|
||||
filePath
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
if (!ak || !sk) {
|
||||
return Promise.reject(new Error('OSS 缺少 AccessKeyId 或 AccessKeySecret'))
|
||||
}
|
||||
|
||||
const expiration = new Date(Date.now() + 55 * 60 * 1000).toISOString()
|
||||
const conditions = [
|
||||
['content-length-range', 0, 52428800],
|
||||
{ bucket },
|
||||
['eq', '$key', objectKey]
|
||||
]
|
||||
if (securityToken) {
|
||||
conditions.push({ 'x-oss-security-token': securityToken })
|
||||
}
|
||||
|
||||
const policyJson = JSON.stringify({ expiration, conditions })
|
||||
const policyB64 = utf8StringToBase64(policyJson)
|
||||
const signature = await hmacSha1Base64(policyB64, sk)
|
||||
|
||||
const host = ossHost(bucket, ossRegionId)
|
||||
const url = `https://${host}/`
|
||||
|
||||
const formData = {
|
||||
key: objectKey,
|
||||
policy: policyB64,
|
||||
OSSAccessKeyId: ak,
|
||||
Signature: signature,
|
||||
success_action_status: '200'
|
||||
}
|
||||
if (securityToken) {
|
||||
formData['x-oss-security-token'] = securityToken
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url,
|
||||
filePath,
|
||||
name: 'file',
|
||||
formData,
|
||||
timeout: 180000,
|
||||
success: (res) => {
|
||||
const sc = res.statusCode
|
||||
if (sc >= 200 && sc < 300) {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
const body = typeof res.data === 'string' ? res.data : JSON.stringify(res.data || '')
|
||||
if (typeof body === 'string' && body.includes('SignatureDoesNotMatch')) {
|
||||
reject(
|
||||
new Error(
|
||||
'OSS SignatureDoesNotMatch:AccessKeySecret 与当前 OSSAccessKeyId 不匹配,或密钥含隐形字符/换行。请到 RAM 用户页「创建 AccessKey」重新复制 Secret 写入 config/segmentApi.js;若曾泄露请删除旧密钥并轮换。'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
reject(new Error(body || `OSS PostObject 失败 HTTP ${sc}`))
|
||||
},
|
||||
fail: (e) => {
|
||||
const raw = e.errMsg || 'uploadFile 失败'
|
||||
if (
|
||||
looksLikeBareOssEndpointUrl(raw) ||
|
||||
(/request:fail/i.test(raw) && /\.oss-[a-z0-9-]+\.aliyuncs\.com/i.test(raw))
|
||||
) {
|
||||
reject(
|
||||
new Error(
|
||||
humanizeIfBareOssUploadErr(raw)
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
reject(new Error(raw))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function uniRequestRaw(options) {
|
||||
const timeout = options.timeout != null ? options.timeout : 120000
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
timeout,
|
||||
success: (r) => {
|
||||
if (r.statusCode >= 200 && r.statusCode < 300) {
|
||||
resolve(r)
|
||||
} else {
|
||||
const msg =
|
||||
(typeof r.data === 'string' ? r.data : '') ||
|
||||
(r.data && r.data.Message) ||
|
||||
`HTTP ${r.statusCode}`
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || '请求失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT 上传后生成 OSS 签名 GET URL(私有 Bucket 时分割接口需可访问该 URL)
|
||||
*/
|
||||
export async function ossPutAndSignedGetUrl({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
objectKey,
|
||||
bodyBuffer,
|
||||
contentType = 'image/jpeg'
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
if (!ak || !sk) {
|
||||
throw new Error('OSS 签名缺少 AccessKeyId 或 AccessKeySecret')
|
||||
}
|
||||
|
||||
const host = ossHost(bucket, ossRegionId)
|
||||
const path = ossObjectPath(objectKey)
|
||||
const urlPut = `https://${host}/${path}`
|
||||
|
||||
const dateGmt = new Date().toUTCString()
|
||||
/**
|
||||
* uni.request 会带 Date;若再发 x-oss-date,OSS 的 StringToSign 为:Date 行 + 规范头里的 x-oss-date(与错误 XML 一致),不能签成 Date 为空。
|
||||
*/
|
||||
const ossHeaderPairs = []
|
||||
ossHeaderPairs.push(['x-oss-date', dateGmt])
|
||||
if (securityToken) {
|
||||
ossHeaderPairs.push(['x-oss-security-token', securityToken])
|
||||
}
|
||||
ossHeaderPairs.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
const canonHeaders = ossHeaderPairs.map(([k, v]) => `${k}:${v}\n`).join('')
|
||||
|
||||
const resource = `/${bucket}/${objectKey}`
|
||||
const stringToSignPut = `PUT\n\n${contentType}\n${dateGmt}\n${canonHeaders}${resource}`
|
||||
const sigPut = await hmacSha1Base64(stringToSignPut, sk)
|
||||
|
||||
const headersPut = {
|
||||
Date: dateGmt,
|
||||
'Content-Type': contentType,
|
||||
'x-oss-date': dateGmt,
|
||||
Authorization: `OSS ${ak}:${sigPut}`
|
||||
}
|
||||
if (securityToken) {
|
||||
headersPut['x-oss-security-token'] = securityToken
|
||||
}
|
||||
|
||||
await uniRequestRaw({
|
||||
url: urlPut,
|
||||
method: 'PUT',
|
||||
header: headersPut,
|
||||
data: bodyBuffer,
|
||||
/** 大图上传(Bitmap 兜底常为 PNG)易慢;超时后 fail 才会结束 loading */
|
||||
timeout: 180000
|
||||
})
|
||||
|
||||
return await ossPresignedObjectUrl({
|
||||
bucket,
|
||||
ossRegionId,
|
||||
accessKeyId: ak,
|
||||
accessKeySecret: sk,
|
||||
securityToken,
|
||||
objectKey,
|
||||
expiresSec: 3600
|
||||
})
|
||||
}
|
||||
|
||||
/** IVPD 常见英文报错 → 中文说明(控制台需开通「智能视觉生产」等产品) */
|
||||
function mapIvpdUserMessage(raw) {
|
||||
const s = String(raw || '')
|
||||
if (/please\s+open\s+service/i.test(s)) {
|
||||
return (
|
||||
'IVPD(智能视觉生产)服务尚未开通:请用阿里云主账号或有权限的子账号登录控制台,搜索「智能视觉生产」或「视觉智能开放平台」,进入产品页点击「立即开通」后再试抠图。' +
|
||||
(s.length <= 60 ? `(接口原文:${s})` : '')
|
||||
)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能视觉生产 IVPD「通用分割 / 抠图」SegmentImage。
|
||||
* Endpoint:ivpd.{region}.aliyuncs.com,Version:2019-06-25,参数 Url 为可访问的图片 HTTP(S) URL(如 OSS 签名 GET)。
|
||||
*/
|
||||
export async function rpcIvpdSegmentImage({
|
||||
imageUrl,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
regionId
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
const u = String(imageUrl || '').trim()
|
||||
if (!/^https?:\/\//i.test(u)) {
|
||||
throw new Error('IVPD SegmentImage 需要可访问的 HTTP(S) 图片 URL(一般为 OSS 私有桶签名 GET)')
|
||||
}
|
||||
|
||||
const params = {
|
||||
Format: 'JSON',
|
||||
Version: '2019-06-25',
|
||||
AccessKeyId: ak,
|
||||
SignatureMethod: 'HMAC-SHA1',
|
||||
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
SignatureVersion: '1.0',
|
||||
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
Action: 'SegmentImage',
|
||||
RegionId: regionId,
|
||||
Url: u
|
||||
}
|
||||
if (securityToken) {
|
||||
params.SecurityToken = securityToken
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(params).sort()
|
||||
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
|
||||
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
|
||||
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
|
||||
|
||||
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
|
||||
const host = `ivpd.${regionId}.aliyuncs.com`
|
||||
|
||||
let res
|
||||
try {
|
||||
res = await uniRequestRaw({
|
||||
url: `https://${host}/`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: body,
|
||||
timeout: 120000
|
||||
})
|
||||
} catch (e) {
|
||||
const em = e && e.message ? String(e.message) : ''
|
||||
if (/please\s+open\s+service/i.test(em)) {
|
||||
throw new Error(mapIvpdUserMessage(em))
|
||||
}
|
||||
try {
|
||||
const j = JSON.parse(em)
|
||||
const mm = j.Message || j.message || ''
|
||||
if (/please\s+open\s+service/i.test(String(mm))) {
|
||||
throw new Error(mapIvpdUserMessage(mm))
|
||||
}
|
||||
} catch (inner) {
|
||||
if (inner && inner.message && /IVPD(/.test(inner.message)) {
|
||||
throw inner
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
let data = res.data
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new Error((data && data.slice(0, 240)) || 'IVPD 返回非 JSON')
|
||||
}
|
||||
}
|
||||
|
||||
const flat = data
|
||||
const bizCode = flat.code ?? flat.Code
|
||||
const bizMsg = flat.message ?? flat.Message
|
||||
if (bizCode != null && String(bizCode) !== '0') {
|
||||
throw new Error(mapIvpdUserMessage(bizMsg ? `${bizCode}: ${bizMsg}` : String(bizCode)))
|
||||
}
|
||||
|
||||
const aliPopMsg = flat.Message || flat.Code
|
||||
if (typeof aliPopMsg === 'string' && /please\s+open\s+service/i.test(aliPopMsg)) {
|
||||
throw new Error(mapIvpdUserMessage(aliPopMsg))
|
||||
}
|
||||
|
||||
const result = flat.result ?? flat.Result
|
||||
const outUrl =
|
||||
(result && (result.url || result.URL)) ||
|
||||
flat.url ||
|
||||
flat.URL ||
|
||||
pickHttpImageUrl(flat)
|
||||
|
||||
if (outUrl && isLikelyObjectHttpUrl(outUrl)) {
|
||||
return outUrl
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
bizMsg
|
||||
? mapIvpdUserMessage(bizMsg)
|
||||
: `IVPD 未返回有效结果图 URL。摘录:${JSON.stringify(flat).slice(0, 600)}`
|
||||
)
|
||||
}
|
||||
|
||||
/** 人像高清抠图(imageseg SegmentHDBody);自建代理 server/index.js 等可使用。 */
|
||||
export async function rpcSegmentHDBody({
|
||||
imageURL,
|
||||
accessKeyId,
|
||||
accessKeySecret,
|
||||
securityToken,
|
||||
regionId
|
||||
}) {
|
||||
const ak = normalizeAccessKeyPart(accessKeyId)
|
||||
const sk = normalizeAccessKeyPart(accessKeySecret)
|
||||
const params = {
|
||||
Format: 'JSON',
|
||||
Version: '2019-12-30',
|
||||
AccessKeyId: ak,
|
||||
SignatureMethod: 'HMAC-SHA1',
|
||||
Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
|
||||
SignatureVersion: '1.0',
|
||||
SignatureNonce: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
Action: 'SegmentHDBody',
|
||||
ImageURL: imageURL
|
||||
}
|
||||
if (securityToken) {
|
||||
params.SecurityToken = securityToken
|
||||
}
|
||||
|
||||
const sortedKeys = Object.keys(params).sort()
|
||||
const canonicalized = sortedKeys.map((k) => `${percentEncode(k)}=${percentEncode(params[k])}`).join('&')
|
||||
const stringToSign = `POST&${percentEncode('/')}&${percentEncode(canonicalized)}`
|
||||
const signature = await hmacSha1Base64(stringToSign, `${sk}&`)
|
||||
|
||||
const body = `${canonicalized}&Signature=${percentEncode(signature)}`
|
||||
const host = `imageseg.${regionId}.aliyuncs.com`
|
||||
|
||||
const res = await uniRequestRaw({
|
||||
url: `https://${host}/`,
|
||||
method: 'POST',
|
||||
header: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: body,
|
||||
timeout: 90000
|
||||
})
|
||||
|
||||
let data = res.data
|
||||
if (typeof data === 'string') {
|
||||
try {
|
||||
data = JSON.parse(data)
|
||||
} catch (e) {
|
||||
throw new Error((data && data.slice(0, 240)) || '分割接口返回非 JSON')
|
||||
}
|
||||
}
|
||||
|
||||
let flat = data
|
||||
if (flat && typeof flat.Data === 'string') {
|
||||
try {
|
||||
const inner = JSON.parse(flat.Data)
|
||||
flat = { ...flat, ...inner }
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const outUrl =
|
||||
flat?.ImageURL ||
|
||||
flat?.imageURL ||
|
||||
flat?.Data?.ImageURL ||
|
||||
pickHttpImageUrl(flat)
|
||||
|
||||
if (!outUrl) {
|
||||
const msg = (flat && (flat.Message || flat.Code)) || JSON.stringify(flat).slice(0, 200)
|
||||
throw new Error(msg || '分割接口未返回结果图 URL')
|
||||
}
|
||||
return outUrl
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
/**
|
||||
* 镭射卡加权抽卡 — 5 张不重复,稀有度档位加权 + 第 5 张保底
|
||||
*
|
||||
* 算法说明:
|
||||
* 1. 单张稀有度按 RARITY_WEIGHTS (50/30/15/4/1) 独立采样
|
||||
* 2. 同档位内按 style.weight 二次加权选 style
|
||||
* 3. 5 张不重复(总池 45,远大于 5,无需去重惩罚)
|
||||
* 4. 第 5 张保底:若前 4 张全是 N,强制抽 R+(R/SR/SSR/UR 等概率)
|
||||
* 5. 纯 JS,无第三方依赖
|
||||
*
|
||||
* 使用:
|
||||
* - drawGachaStyles(5) → 5 个 style 完整对象(含 grating_config + bg_prompt 等)
|
||||
* - drawGachaStyleIds(5) → 仅 5 个 style ID
|
||||
*
|
||||
* 边界:
|
||||
* - count <= 0 → 返回空数组
|
||||
* - count > 池子大小 → 全部返回(顺序随机)
|
||||
* - Math.random 无 NaN/Inf 风险
|
||||
*/
|
||||
|
||||
import { LASER_STYLE_POOL } from './stylePool.js'
|
||||
|
||||
/** @typedef {('N'|'R'|'SR'|'SSR'|'UR')} Rarity */
|
||||
|
||||
/** 档位权重(总和 100,代表单次抽卡的稀有度分布百分比) */
|
||||
export const RARITY_WEIGHTS = /** @type {Record<Rarity, number>} */ ({
|
||||
N: 50,
|
||||
R: 30,
|
||||
SR: 15,
|
||||
SSR: 4,
|
||||
UR: 1,
|
||||
})
|
||||
|
||||
/** 保底用:抽 R+ 时各档位等概率 */
|
||||
const R_PLUS_BUCKETS = /** @type {Rarity[]} */ (['R', 'SR', 'SSR', 'UR'])
|
||||
|
||||
/**
|
||||
* 加权随机选一个稀有度档位
|
||||
* @returns {Rarity}
|
||||
*/
|
||||
export function pickRarity() {
|
||||
const total = Object.values(RARITY_WEIGHTS).reduce((a, b) => a + b, 0)
|
||||
let r = Math.random() * total
|
||||
for (const [rarity, w] of Object.entries(RARITY_WEIGHTS)) {
|
||||
r -= /** @type {number} */ (w)
|
||||
if (r <= 0) return /** @type {Rarity} */ (rarity)
|
||||
}
|
||||
return 'N'
|
||||
}
|
||||
|
||||
/**
|
||||
* 在指定档位内按 style.weight 加权抽一个,且不与 exclude 重复
|
||||
* @param {Rarity} rarity
|
||||
* @param {Set<string>} exclude 已选 style id 集合
|
||||
* @returns {import('./stylePool.js').LaserStyle | null}
|
||||
*/
|
||||
export function pickStyleInRarity(rarity, exclude) {
|
||||
const candidates = LASER_STYLE_POOL.filter(
|
||||
(s) => s.rarity === rarity && !exclude.has(s.id),
|
||||
)
|
||||
if (candidates.length === 0) return null
|
||||
const sum = candidates.reduce((a, s) => a + (s.weight || 0), 0) || 1
|
||||
let r = Math.random() * sum
|
||||
for (const s of candidates) {
|
||||
r -= s.weight || 0
|
||||
if (r <= 0) return s
|
||||
}
|
||||
return candidates[candidates.length - 1]
|
||||
}
|
||||
|
||||
/**
|
||||
* 加权抽卡:5 张不重复,保底 ≥1 张 R+
|
||||
* @param {number} [count=5] 抽卡数量
|
||||
* @returns {Array<import('./stylePool.js').LaserStyle>}
|
||||
*/
|
||||
export function drawGachaStyles(count = 5) {
|
||||
const n = Math.max(0, Math.floor(count))
|
||||
if (n === 0) return []
|
||||
if (n >= LASER_STYLE_POOL.length) {
|
||||
// 数量超过池子大小 → 全部返回(洗牌)
|
||||
const all = LASER_STYLE_POOL.slice()
|
||||
for (let i = all.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1))
|
||||
;[all[i], all[j]] = [all[j], all[i]]
|
||||
}
|
||||
return all
|
||||
}
|
||||
|
||||
const picked = /** @type {Array<import('./stylePool.js').LaserStyle>} */ ([])
|
||||
const used = new Set()
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
let rarity = pickRarity()
|
||||
|
||||
// 第 n 张(最后一张)保底:前 n-1 张全是 N → 强制 R+
|
||||
if (i === n - 1 && picked.length > 0 && picked.every((s) => s.rarity === 'N')) {
|
||||
rarity = R_PLUS_BUCKETS[Math.floor(Math.random() * R_PLUS_BUCKETS.length)]
|
||||
}
|
||||
|
||||
let style = pickStyleInRarity(rarity, used)
|
||||
|
||||
// 同档位被抽空 → 降级到任意可用档位
|
||||
if (!style) {
|
||||
style = pickStyleInRarity('N', used)
|
||||
if (!style) {
|
||||
// 极端兜底:任意未选 style
|
||||
style = LASER_STYLE_POOL.find((s) => !used.has(s.id)) || null
|
||||
}
|
||||
}
|
||||
|
||||
if (!style) break
|
||||
picked.push(style)
|
||||
used.add(style.id)
|
||||
}
|
||||
|
||||
return picked
|
||||
}
|
||||
|
||||
/**
|
||||
* 便捷函数:只返回 ID 列表
|
||||
* @param {number} [count=5]
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function drawGachaStyleIds(count = 5) {
|
||||
return drawGachaStyles(count).map((s) => s.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 工具:按 preset_id 反查 style(供调试 / 日志使用)
|
||||
* @param {string} id
|
||||
* @returns {import('./stylePool.js').LaserStyle | undefined}
|
||||
*/
|
||||
export function findStyleById(id) {
|
||||
return LASER_STYLE_POOL.find((s) => s.id === id)
|
||||
}
|
||||
@ -141,55 +141,3 @@ export function buildRenderConfigs(presetIds) {
|
||||
}
|
||||
|
||||
export { resolveGratingConfig, DEFAULT_BACKDROP_TONE } from './laserGrating.js'
|
||||
|
||||
// =========================================================================
|
||||
// 新增:风格池 + 抽卡(45 个风格,5 档分层)
|
||||
// - 旧调用方(激光导出会、success.vue)继续用上方 export,完全兼容
|
||||
// - 新调用方(抽卡、稀有度徽章)使用下方 export
|
||||
// =========================================================================
|
||||
|
||||
// 风格池与索引(转自 stylePool.js)
|
||||
export {
|
||||
LASER_STYLE_POOL,
|
||||
LASER_STYLE_IDS,
|
||||
LASER_STYLE_INDEX,
|
||||
RARITY_DISTRIBUTION,
|
||||
RARITY_COLORS,
|
||||
RARITY_STARS,
|
||||
} from './stylePool.js'
|
||||
|
||||
// 抽卡算法(转自 gacha.js)
|
||||
export {
|
||||
drawGachaStyles,
|
||||
drawGachaStyleIds,
|
||||
pickRarity,
|
||||
pickStyleInRarity,
|
||||
findStyleById,
|
||||
RARITY_WEIGHTS,
|
||||
} from './gacha.js'
|
||||
|
||||
/**
|
||||
* 兼容旧调用方:按数字索引取 style 对象
|
||||
* 0-4 仍映射到原 dream/classic/holoFull/ice/sunset(与旧 PRESET_VARIANTS 顺序一致)
|
||||
* 5~44 映射到新池子
|
||||
* @param {number} index
|
||||
*/
|
||||
export function getStyleByIndex(index) {
|
||||
const i = Number(index)
|
||||
if (!Number.isFinite(i) || i < 0) return LASER_STYLE_POOL[0]
|
||||
if (i < 5) {
|
||||
const oldIds = ['dream', 'classic', 'holoFull', 'ice', 'sunset']
|
||||
return LASER_STYLE_POOL.find((s) => s.id === oldIds[i]) || LASER_STYLE_POOL[0]
|
||||
}
|
||||
return LASER_STYLE_POOL[i] || LASER_STYLE_POOL[0]
|
||||
}
|
||||
|
||||
/**
|
||||
* 兼容旧调用方:按 style id 反查数字索引(用于 success.vue)
|
||||
* @param {string} id
|
||||
* @returns {number}
|
||||
*/
|
||||
export function getStyleIndexById(id) {
|
||||
const idx = LASER_STYLE_INDEX[String(id || '')]
|
||||
return Number.isFinite(idx) ? idx : 0
|
||||
}
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
/**
|
||||
* 智能抠图:生产环境走网关 POST /api/v1/segment(见 useLaserSegment.js)。
|
||||
* 下列 direct 配置仅作本地调试遗留,正式包勿填 AK。
|
||||
*/
|
||||
|
||||
/** 仅开发:RAM 子账号 AccessKeyId(勿提交仓库、勿用于正式包) */
|
||||
export const ALIYUN_ACCESS_KEY_ID = ''
|
||||
|
||||
/** 仅开发:RAM 子账号 AccessKeySecret */
|
||||
export const ALIYUN_ACCESS_KEY_SECRET = ''
|
||||
|
||||
/** 可选:使用 STS 临时凭证时填写 SecurityToken;长期 AK 一般留空 */
|
||||
export const ALIYUN_SECURITY_TOKEN = ''
|
||||
|
||||
/** 生产:STS 获取地址(GET,返回 JSON)。若上面已填 AK/SK,则不再请求此地址 */
|
||||
export const ALIYUN_STS_URL = ''
|
||||
|
||||
/** OSS Bucket 名(不含域名) */
|
||||
export const OSS_BUCKET = ''
|
||||
|
||||
/** OSS region,如 oss-cn-shanghai */
|
||||
export const OSS_REGION = 'oss-cn-shanghai'
|
||||
|
||||
/** 上传目录前缀 */
|
||||
export const OSS_KEY_PREFIX = 'laser-card-segment/tmp/'
|
||||
|
||||
/** imageseg 地域,如 cn-shanghai */
|
||||
export const IMAGESEG_REGION = 'cn-shanghai'
|
||||
|
||||
/** 预留;当前直连抠图走 IVPD,不使用 IMM 项目名称 */
|
||||
export const IMM_PROJECT_NAME = 'laser-card'
|
||||
|
||||
/** IVPD 与 OSS 同地域 id,如 cn-shanghai */
|
||||
export const IMM_REGION = 'cn-shanghai'
|
||||
|
||||
/**
|
||||
* auto | backend | direct — 详见 segmentApi.example.js 注释;接后端时改为 backend 并填 SEGMENT_API_BASE。
|
||||
*/
|
||||
export const SEGMENT_TRANSPORT = 'backend'
|
||||
|
||||
/** 方案二:自建代理根地址(无末尾斜杠);与方案一互斥:有 OSS 且(AK 或 STS)时不会走代理 */
|
||||
export const SEGMENT_API_BASE = ''
|
||||
|
||||
export const SEGMENT_API_TOKEN = ''
|
||||
@ -1,320 +0,0 @@
|
||||
import {
|
||||
ALIYUN_ACCESS_KEY_ID,
|
||||
ALIYUN_ACCESS_KEY_SECRET,
|
||||
ALIYUN_SECURITY_TOKEN,
|
||||
ALIYUN_STS_URL,
|
||||
IMM_REGION,
|
||||
OSS_BUCKET,
|
||||
OSS_KEY_PREFIX,
|
||||
OSS_REGION,
|
||||
SEGMENT_API_BASE,
|
||||
SEGMENT_API_TOKEN,
|
||||
SEGMENT_TRANSPORT
|
||||
} from './segmentApi.js'
|
||||
import {
|
||||
ossPostObjectFromLocalFilePath,
|
||||
ossPresignedObjectUrl,
|
||||
rpcIvpdSegmentImage,
|
||||
looksLikeBareOssEndpointUrl
|
||||
} from './aliyunPortraitUni.js'
|
||||
|
||||
function uniRequest(options) {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.request({
|
||||
...options,
|
||||
success: (r) => {
|
||||
if (r.statusCode >= 200 && r.statusCode < 300) {
|
||||
resolve(r)
|
||||
} else {
|
||||
const msg =
|
||||
(typeof r.data === 'object' && r.data && (r.data.Message || r.data.message)) ||
|
||||
(typeof r.data === 'string' ? r.data : '') ||
|
||||
`HTTP ${r.statusCode}`
|
||||
reject(new Error(msg))
|
||||
}
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || '网络请求失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 内层 Promise 长期无回调时强制失败(避免一直「正在识别」) */
|
||||
function raceWithTimeout(promise, ms, errMsg) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error(errMsg)), ms)
|
||||
Promise.resolve(promise).then(
|
||||
(v) => {
|
||||
clearTimeout(t)
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
clearTimeout(t)
|
||||
reject(e)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function normalizeStsPayload(raw) {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return null
|
||||
}
|
||||
if (raw.Credentials) {
|
||||
const c = raw.Credentials
|
||||
return {
|
||||
accessKeyId: c.AccessKeyId,
|
||||
accessKeySecret: c.AccessKeySecret,
|
||||
securityToken: c.SecurityToken || undefined,
|
||||
expiration: c.Expiration
|
||||
}
|
||||
}
|
||||
if (raw.accessKeyId && raw.accessKeySecret) {
|
||||
return {
|
||||
accessKeyId: raw.accessKeyId,
|
||||
accessKeySecret: raw.accessKeySecret,
|
||||
securityToken: raw.securityToken || undefined,
|
||||
expiration: raw.expiration
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function fetchStsCredentials() {
|
||||
const url = (ALIYUN_STS_URL || '').trim()
|
||||
if (!url) {
|
||||
return null
|
||||
}
|
||||
const res = await uniRequest({ url, method: 'GET', timeout: 30000 })
|
||||
const data = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
const cred = normalizeStsPayload(data)
|
||||
if (!cred || !cred.accessKeyId) {
|
||||
throw new Error('STS 地址返回格式不正确(需 Credentials 或 accessKeyId/accessKeySecret)')
|
||||
}
|
||||
return cred
|
||||
}
|
||||
|
||||
/** 开发直配 AK,或 STS 拉取 */
|
||||
function getCredentialsFromConfig() {
|
||||
const id = (ALIYUN_ACCESS_KEY_ID || '').trim()
|
||||
const sec = (ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||||
const token = (ALIYUN_SECURITY_TOKEN || '').trim()
|
||||
if (id && sec) {
|
||||
return {
|
||||
accessKeyId: id,
|
||||
accessKeySecret: sec,
|
||||
securityToken: token || undefined
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function resolveCredentials() {
|
||||
const fromFile = getCredentialsFromConfig()
|
||||
if (fromFile) {
|
||||
return fromFile
|
||||
}
|
||||
const url = (ALIYUN_STS_URL || '').trim()
|
||||
if (!url) {
|
||||
throw new Error('请配置 ALIYUN_ACCESS_KEY_ID/SECRET(开发)或 ALIYUN_STS_URL(生产)')
|
||||
}
|
||||
return fetchStsCredentials()
|
||||
}
|
||||
|
||||
function canUseDirectAliyunClient() {
|
||||
const bucket = (OSS_BUCKET || '').trim()
|
||||
const hasAk =
|
||||
!!(ALIYUN_ACCESS_KEY_ID || '').trim() && !!(ALIYUN_ACCESS_KEY_SECRET || '').trim()
|
||||
const hasStsUrl = !!(ALIYUN_STS_URL || '').trim()
|
||||
return !!(bucket && (hasAk || hasStsUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {'direct' | 'backend'}
|
||||
*/
|
||||
function resolveSegmentTransport() {
|
||||
const raw = String(SEGMENT_TRANSPORT == null ? 'auto' : SEGMENT_TRANSPORT)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (raw === 'backend' || raw === 'proxy' || raw === 'server') {
|
||||
return 'backend'
|
||||
}
|
||||
if (raw === 'direct' || raw === 'aliyun' || raw === 'client' || raw === 'oss') {
|
||||
if (!canUseDirectAliyunClient()) {
|
||||
throw new Error(
|
||||
'SEGMENT_TRANSPORT 为 direct/aliyun/client/oss 时需配置 OSS_BUCKET 以及(ALIYUN_ACCESS_KEY_ID+SECRET 或 ALIYUN_STS_URL)'
|
||||
)
|
||||
}
|
||||
return 'direct'
|
||||
}
|
||||
return canUseDirectAliyunClient() ? 'direct' : 'backend'
|
||||
}
|
||||
|
||||
/**
|
||||
* 凭证 → OSS PostObject → 签名 GET URL → IVPD SegmentImage(Url)→ downloadFile
|
||||
*/
|
||||
async function segmentViaOssIvpd(filePath) {
|
||||
const bucket = (OSS_BUCKET || '').trim()
|
||||
if (!bucket) {
|
||||
throw new Error('请在 config/segmentApi.js 配置 OSS_BUCKET')
|
||||
}
|
||||
|
||||
const cred = await resolveCredentials()
|
||||
|
||||
const ext = /\.png$/i.test(String(filePath || '')) ? 'png' : 'jpg'
|
||||
const base = `${Date.now()}_${Math.random().toString(36).slice(2, 10)}`
|
||||
const prefix = OSS_KEY_PREFIX.replace(/^\/+/, '')
|
||||
const objectKeyIn = `${prefix}${base}.${ext}`
|
||||
|
||||
await ossPostObjectFromLocalFilePath({
|
||||
bucket,
|
||||
ossRegionId: OSS_REGION,
|
||||
accessKeyId: cred.accessKeyId,
|
||||
accessKeySecret: cred.accessKeySecret,
|
||||
securityToken: cred.securityToken,
|
||||
objectKey: objectKeyIn,
|
||||
filePath
|
||||
})
|
||||
|
||||
const imageUrlForSeg = await ossPresignedObjectUrl({
|
||||
bucket,
|
||||
ossRegionId: OSS_REGION,
|
||||
accessKeyId: cred.accessKeyId,
|
||||
accessKeySecret: cred.accessKeySecret,
|
||||
securityToken: cred.securityToken,
|
||||
objectKey: objectKeyIn,
|
||||
expiresSec: 3600
|
||||
})
|
||||
|
||||
const ivpdRegion = (IMM_REGION || '').trim() || String(OSS_REGION || '').replace(/^oss-/i, '')
|
||||
|
||||
const outUrl = await rpcIvpdSegmentImage({
|
||||
imageUrl: imageUrlForSeg,
|
||||
accessKeyId: cred.accessKeyId,
|
||||
accessKeySecret: cred.accessKeySecret,
|
||||
securityToken: cred.securityToken,
|
||||
regionId: ivpdRegion
|
||||
})
|
||||
|
||||
return raceWithTimeout(
|
||||
new Promise((res, rej) => {
|
||||
uni.downloadFile({
|
||||
url: outUrl,
|
||||
timeout: 120000,
|
||||
success: (dr) => {
|
||||
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||||
rej(new Error('下载分割结果失败'))
|
||||
return
|
||||
}
|
||||
res({ kind: 'cutout', localPath: dr.tempFilePath })
|
||||
},
|
||||
fail: (e) => {
|
||||
const raw = e.errMsg || '未知错误'
|
||||
const needDomainHint =
|
||||
looksLikeBareOssEndpointUrl(raw) ||
|
||||
(/^https?:\/\//i.test(String(raw).trim()) && String(raw).includes('aliyuncs'))
|
||||
const extra = needDomainHint
|
||||
? '(若仅为 URL:请在 manifest → networkSecurity.domainWhiteList 中加入下载域名并重编译。)'
|
||||
: ''
|
||||
rej(new Error(`下载分割结果失败:${raw}${extra}`))
|
||||
}
|
||||
})
|
||||
}),
|
||||
125000,
|
||||
'下载分割结果超时'
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 方案二:自建 Node 代理(server/index.js)
|
||||
*/
|
||||
function segmentViaProxy(filePath) {
|
||||
const base = (SEGMENT_API_BASE || '').trim().replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
return Promise.reject(
|
||||
new Error(
|
||||
'缺少 SEGMENT_API_BASE:HTTP/后端抠图需要填写根地址(POST {BASE}/segment)。若使用客户端直连阿里云,请配置 OSS_BUCKET 与 AK 或 STS,并将 SEGMENT_TRANSPORT 设为 auto 或 direct。'
|
||||
)
|
||||
)
|
||||
}
|
||||
const url = `${base}/segment`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url,
|
||||
filePath,
|
||||
name: 'image',
|
||||
timeout: 120000,
|
||||
header: SEGMENT_API_TOKEN ? { 'x-laser-token': SEGMENT_API_TOKEN } : {},
|
||||
success: (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
reject(new Error(typeof res.data === 'string' ? res.data : `HTTP ${res.statusCode}`))
|
||||
return
|
||||
}
|
||||
let body
|
||||
try {
|
||||
body = typeof res.data === 'string' ? JSON.parse(res.data) : res.data
|
||||
} catch (e) {
|
||||
reject(new Error('代理返回非 JSON'))
|
||||
return
|
||||
}
|
||||
if (!body || body.ok !== true || !body.imageUrl) {
|
||||
reject(new Error((body && body.error) || '抠图失败'))
|
||||
return
|
||||
}
|
||||
uni.downloadFile({
|
||||
url: body.imageUrl,
|
||||
timeout: 120000,
|
||||
success: (dr) => {
|
||||
if (dr.statusCode !== 200 || !dr.tempFilePath) {
|
||||
reject(new Error('下载分割结果失败'))
|
||||
return
|
||||
}
|
||||
resolve({ kind: 'cutout', localPath: dr.tempFilePath })
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || 'downloadFile 失败'))
|
||||
})
|
||||
},
|
||||
fail: (e) => reject(new Error(e.errMsg || 'uploadFile 失败'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 防止某一步网络/原生无回调导致一直转圈 */
|
||||
function withTimeout(promise, ms, message) {
|
||||
let timer = null
|
||||
return new Promise((resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
reject(new Error(message))
|
||||
}, ms)
|
||||
promise.then(
|
||||
(v) => {
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
resolve(v)
|
||||
},
|
||||
(e) => {
|
||||
if (timer != null) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
reject(e)
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传本地图并获取抠图结果本地路径。
|
||||
* 链路由 config/segmentApi.js 的 SEGMENT_TRANSPORT 决定:direct=客户端 OSS + IVPD SegmentImage;backend=HTTP 后端(契约同 server/index.js);auto=能直连则直连否则走后端。
|
||||
*/
|
||||
export function segmentPortraitToLocal(filePath) {
|
||||
const transport = resolveSegmentTransport()
|
||||
const inner =
|
||||
transport === 'direct' ? segmentViaOssIvpd(filePath) : segmentViaProxy(filePath)
|
||||
return withTimeout(
|
||||
inner,
|
||||
240000,
|
||||
'智能抠图超时:请检查手机网络、OSS、IVPD 地域(IMM_REGION,如 cn-shanghai)、RAM 权限及 manifest 网络权限'
|
||||
)
|
||||
}
|
||||
@ -1,533 +0,0 @@
|
||||
/**
|
||||
* 镭射卡风格池 — 45 个风格,5 档分层(N/R/SR/SSR/UR)
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 每个 style 自包含 grating_config + bg_prompt + overlay_prompt,可独立喂给 compositor
|
||||
* 2. 真实参考(reference 字段)指向实体卡牌系列,设计师可回溯优化
|
||||
* 3. 权重(weight)在**同档位内**有效;跨档位权重由 gacha.js 的 RARITY_WEIGHTS 控制
|
||||
* 4. 全英文 prompt 利于 AI 生图;负向提示"no text, no people, no watermark"防跑偏
|
||||
* 5. bg_prompt 显式标注 sheen 角度/速度,与 grating_config 对齐,避免"参数是冷银但生图是紫色"的不一致
|
||||
*
|
||||
* 档位分布:
|
||||
* - N(50%):日常量产,银底 + 单色带
|
||||
* - R(30%):Refractor / Holo Rare 级常规工艺
|
||||
* - SR(15%):限量,Mosaic / Atomic / 横纹类
|
||||
* - SSR(4%):稀有,Full Art / Rainbow / Gold 类
|
||||
* - UR(1%):终极,Secret / Star / Prismatic Secret 类
|
||||
*
|
||||
* @typedef {('N'|'R'|'SR'|'SSR'|'UR')} Rarity
|
||||
* @typedef {Object} LaserStyle
|
||||
* @property {string} id
|
||||
* @property {Rarity} rarity
|
||||
* @property {string} display_name_zh
|
||||
* @property {string} display_name_en
|
||||
* @property {number} weight
|
||||
* @property {string} family
|
||||
* @property {{series: string, year: string, note: string}} reference
|
||||
* @property {{sheen_band_angle: number, sheen_speed: number, sheen_intensity: number, foil_coverage: number, backdrop_tone: string}} grating_config
|
||||
* @property {string} color_hint
|
||||
* @property {string} bg_prompt
|
||||
* @property {string} overlay_prompt
|
||||
* @property {string} tags
|
||||
*/
|
||||
|
||||
export const LASER_STYLE_POOL = [
|
||||
// ============ N 档(10 个,基础银底工艺)============
|
||||
{
|
||||
id: 'classic_silver', rarity: 'N', display_name_zh: '经典银底', display_name_en: 'Classic Silver', weight: 12,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Silver Prizm', year: '2018-', note: '入门款 Prizm, 单角度折射' },
|
||||
grating_config: { sheen_band_angle: 120, sheen_speed: 0.40, sheen_intensity: 0.32, foil_coverage: 0.55, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '冷银 浅灰',
|
||||
bg_prompt: 'pure silver holographic background, soft brushed metal texture, single subtle diagonal sheen band, neutral gray gradient, clean cardstock finish, minimalist industrial design, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin silver border, single diagonal light line, small geometric corner ornaments',
|
||||
tags: 'classic,base,entry',
|
||||
},
|
||||
{
|
||||
id: 'moonlight_silver', rarity: 'N', display_name_zh: '月光银', display_name_en: 'Moonlight Silver', weight: 10,
|
||||
family: 'pokemon_tcg',
|
||||
reference: { series: 'Pokémon TCG Base Set Holo', year: '1999', note: '初代宝可梦 holo, 微彩虹折射' },
|
||||
grating_config: { sheen_band_angle: 115, sheen_speed: 0.35, sheen_intensity: 0.30, foil_coverage: 0.50, backdrop_tone: '#A89E91' },
|
||||
color_hint: '月光银 香槟银',
|
||||
bg_prompt: 'gentle moonlight silver holographic gradient, soft brushed metal with micro sparkles, muted silver-to-pearl shift, calm celestial mood, clean pastel undertones, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin pearl border, soft star scatter, subtle corner brackets',
|
||||
tags: 'soft,pastel',
|
||||
},
|
||||
{
|
||||
id: 'pearl_basic', rarity: 'N', display_name_zh: '珍珠银', display_name_en: 'Pearl Basic', weight: 10,
|
||||
family: 'topps_chrome',
|
||||
reference: { series: 'Topps Chrome Refractor (Base)', year: '1996-', note: 'Topps Chrome 入门反色' },
|
||||
grating_config: { sheen_band_angle: 130, sheen_speed: 0.38, sheen_intensity: 0.34, foil_coverage: 0.58, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '珍珠白 淡奶白',
|
||||
bg_prompt: 'pearl white brushed metal holographic surface, soft luminous glow, neutral off-white gradient with faint iridescent shimmer, clean minimalist backdrop, no text, no people, no watermark',
|
||||
overlay_prompt: 'fine pearl-white border line, gentle corner ornaments, no harsh shapes',
|
||||
tags: 'pearl,clean',
|
||||
},
|
||||
{
|
||||
id: 'frost_silver', rarity: 'N', display_name_zh: '霜银', display_name_en: 'Frost Silver', weight: 10,
|
||||
family: 'pokemon_tcg',
|
||||
reference: { series: 'Pokémon TCG Reverse Holo (Basic)', year: '2003-', note: '宝可梦基础系列反 holo' },
|
||||
grating_config: { sheen_band_angle: 110, sheen_speed: 0.42, sheen_intensity: 0.30, foil_coverage: 0.48, backdrop_tone: '#9C9993' },
|
||||
color_hint: '冰蓝 浅灰银',
|
||||
bg_prompt: 'frosted silver metal surface with cool blue undertone, soft frosted glass texture, gentle vertical sheen band, clean icy minimalism, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin frosted edge line, sparse ice flake scatter, light frost corner motif',
|
||||
tags: 'cool,frost',
|
||||
},
|
||||
{
|
||||
id: 'warm_silver', rarity: 'N', display_name_zh: '暖银', display_name_en: 'Warm Silver', weight: 10,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Base (Retail)', year: '2020-', note: '零售版基础款' },
|
||||
grating_config: { sheen_band_angle: 125, sheen_speed: 0.40, sheen_intensity: 0.32, foil_coverage: 0.52, backdrop_tone: '#A89E91' },
|
||||
color_hint: '暖银 浅米色',
|
||||
bg_prompt: 'warm silver holographic base, soft champagne undertone, brushed metal texture with subtle warmth, gentle pearl shine, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin warm silver border, small geometric corners',
|
||||
tags: 'warm,classic',
|
||||
},
|
||||
{
|
||||
id: 'graphite_silver', rarity: 'N', display_name_zh: '石墨银', display_name_en: 'Graphite Silver', weight: 10,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm (Retail Hanger)', year: '2019-', note: '银底素色基础款' },
|
||||
grating_config: { sheen_band_angle: 135, sheen_speed: 0.36, sheen_intensity: 0.30, foil_coverage: 0.50, backdrop_tone: '#9C9993' },
|
||||
color_hint: '石墨灰 冷灰银',
|
||||
bg_prompt: 'graphite silver brushed metal surface, neutral cool gray gradient, single soft diagonal sheen, industrial clean mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin graphite border, sparse geometric lines',
|
||||
tags: 'industrial,cool',
|
||||
},
|
||||
{
|
||||
id: 'rosegold_base', rarity: 'N', display_name_zh: '玫瑰金底', display_name_en: 'Rose Gold Base', weight: 10,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Rose Gold (Retail)', year: '2020-', note: '零售版玫瑰金反色' },
|
||||
grating_config: { sheen_band_angle: 140, sheen_speed: 0.40, sheen_intensity: 0.34, foil_coverage: 0.55, backdrop_tone: '#9E958A' },
|
||||
color_hint: '玫瑰金 浅粉金',
|
||||
bg_prompt: 'soft rose gold holographic base, warm pinkish metallic gradient, subtle champagne sheen, gentle romantic mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin rose gold border, small heart corner accents',
|
||||
tags: 'romantic,warm',
|
||||
},
|
||||
{
|
||||
id: 'sky_silver', rarity: 'N', display_name_zh: '天空银', display_name_en: 'Sky Silver', weight: 10,
|
||||
family: 'topps_chrome',
|
||||
reference: { series: 'Topps Chrome Sky Refractor', year: '2021-', note: '天空蓝基础款(非 1/1)' },
|
||||
grating_config: { sheen_band_angle: 105, sheen_speed: 0.44, sheen_intensity: 0.32, foil_coverage: 0.50, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '天蓝 银白',
|
||||
bg_prompt: 'sky blue tinted silver holographic base, soft daylight gradient with cool silver undertones, airy clean minimalism, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin sky blue border, soft cloud scatter',
|
||||
tags: 'sky,cool',
|
||||
},
|
||||
{
|
||||
id: 'cream_silver', rarity: 'N', display_name_zh: '奶白银', display_name_en: 'Cream Silver', weight: 9,
|
||||
family: 'weiss',
|
||||
reference: { series: 'Weiss Schwarz R (Common)', year: '2008-', note: 'WS 普通卡基础反色' },
|
||||
grating_config: { sheen_band_angle: 145, sheen_speed: 0.38, sheen_intensity: 0.30, foil_coverage: 0.46, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '奶白 米黄',
|
||||
bg_prompt: 'cream ivory silver base, soft warm white metallic gradient, gentle sheen, clean Japanese-card aesthetic, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin cream border, sparse sakura scatter',
|
||||
tags: 'japan,soft',
|
||||
},
|
||||
{
|
||||
id: 'steel_silver', rarity: 'N', display_name_zh: '钢银', display_name_en: 'Steel Silver', weight: 9,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Hyper (Base Tier)', year: '2019-', note: 'Hyper 基础款' },
|
||||
grating_config: { sheen_band_angle: 150, sheen_speed: 0.34, sheen_intensity: 0.30, foil_coverage: 0.52, backdrop_tone: '#9C9993' },
|
||||
color_hint: '钢灰 冷银',
|
||||
bg_prompt: 'cold steel silver holographic base, cool industrial brushed metal, sharp single diagonal sheen, masculine minimal mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin steel border, small angular corner motif',
|
||||
tags: 'industrial,sharp',
|
||||
},
|
||||
|
||||
// ============ R 档(10 个,Refractor/反 holo 工艺)============
|
||||
{
|
||||
id: 'prizm_blue', rarity: 'R', display_name_zh: '蓝棱镜', display_name_en: 'Prizm Blue', weight: 7,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Blue Prizm', year: '2018-', note: '蓝棱镜折射,真卡标志款' },
|
||||
grating_config: { sheen_band_angle: 120, sheen_speed: 0.42, sheen_intensity: 0.38, foil_coverage: 0.70, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '电光蓝 宝石蓝',
|
||||
bg_prompt: 'electric blue prismatic holographic surface, sapphire and cyan layered sheen, vivid blue-to-silver diagonal band, sport card premium mood, sharp reflective energy, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean blue bevel border, sharp diagonal sheen sweep, minimal geometric accents',
|
||||
tags: 'blue,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'prizm_red', rarity: 'R', display_name_zh: '红棱镜', display_name_en: 'Prizm Red', weight: 7,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Red Prizm', year: '2018-', note: '红棱镜折射,真卡标志款' },
|
||||
grating_config: { sheen_band_angle: 125, sheen_speed: 0.40, sheen_intensity: 0.38, foil_coverage: 0.70, backdrop_tone: '#A89E91' },
|
||||
color_hint: '宝石红 深粉红',
|
||||
bg_prompt: 'ruby red prismatic holographic surface, crimson and rose layered sheen, vivid red-to-silver diagonal band, premium sport card energy, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean red bevel border, sharp diagonal sheen sweep, minimal geometric accents',
|
||||
tags: 'red,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'prizm_green', rarity: 'R', display_name_zh: '绿棱镜', display_name_en: 'Prizm Green', weight: 6,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Green Prizm', year: '2018-', note: '绿棱镜折射' },
|
||||
grating_config: { sheen_band_angle: 130, sheen_speed: 0.40, sheen_intensity: 0.38, foil_coverage: 0.70, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '祖母绿 翡翠',
|
||||
bg_prompt: 'emerald green prismatic holographic surface, forest and mint layered sheen, vivid green-to-silver diagonal band, sport card vitality, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean green bevel border, sharp diagonal sheen sweep',
|
||||
tags: 'green,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'prizm_orange', rarity: 'R', display_name_zh: '橙棱镜', display_name_en: 'Prizm Orange', weight: 6,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Orange Prizm', year: '2018-', note: '橙棱镜折射' },
|
||||
grating_config: { sheen_band_angle: 115, sheen_speed: 0.42, sheen_intensity: 0.38, foil_coverage: 0.68, backdrop_tone: '#9E958A' },
|
||||
color_hint: '橙色 琥珀色',
|
||||
bg_prompt: 'vivid orange prismatic holographic surface, tangerine and amber layered sheen, sharp orange-to-silver diagonal band, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean orange bevel border, sharp diagonal sheen sweep',
|
||||
tags: 'orange,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'prizm_purple', rarity: 'R', display_name_zh: '紫棱镜', display_name_en: 'Prizm Purple', weight: 6,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Purple Prizm', year: '2018-', note: '紫棱镜折射' },
|
||||
grating_config: { sheen_band_angle: 110, sheen_speed: 0.40, sheen_intensity: 0.38, foil_coverage: 0.70, backdrop_tone: '#9C9993' },
|
||||
color_hint: '紫水晶 葡萄紫',
|
||||
bg_prompt: 'amethyst purple prismatic holographic surface, violet and magenta layered sheen, vivid purple-to-silver diagonal band, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean purple bevel border, sharp diagonal sheen sweep',
|
||||
tags: 'purple,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'topps_refractor', rarity: 'R', display_name_zh: '经典折射', display_name_en: 'Topps Refractor', weight: 6,
|
||||
family: 'topps_chrome',
|
||||
reference: { series: 'Topps Chrome Refractor', year: '1996-', note: 'Topps Chrome 标志款折射' },
|
||||
grating_config: { sheen_band_angle: 125, sheen_speed: 0.40, sheen_intensity: 0.36, foil_coverage: 0.68, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '彩虹光 全光谱',
|
||||
bg_prompt: 'classic baseball card refractor holographic surface, full spectrum rainbow sheen sweeping diagonally, sharp multilayer reflective layers, vintage baseball card mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean silver bevel border, single bright diagonal sheen line',
|
||||
tags: 'refractor,rainbow,sport',
|
||||
},
|
||||
{
|
||||
id: 'reverse_holo', rarity: 'R', display_name_zh: '反面全息', display_name_en: 'Reverse Holo', weight: 5,
|
||||
family: 'pokemon_tcg',
|
||||
reference: { series: 'Pokémon TCG Reverse Holo', year: '2003-', note: '宝可梦反面全息(图案外全息)' },
|
||||
grating_config: { sheen_band_angle: 105, sheen_speed: 0.38, sheen_intensity: 0.34, foil_coverage: 0.62, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '浅银 柔彩虹',
|
||||
bg_prompt: 'reverse holo pattern background, soft rainbow shimmer surrounding a centered art window, gentle pastel shimmer band, clean Japanese TCG aesthetic, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin silver border around art window, gentle sparkle scatter, modest corner brackets',
|
||||
tags: 'pokemon,reverse',
|
||||
},
|
||||
{
|
||||
id: 'bowman_chrome', rarity: 'R', display_name_zh: 'Bowman 折射', display_name_en: 'Bowman Chrome', weight: 5,
|
||||
family: 'topps_chrome',
|
||||
reference: { series: 'Bowman Chrome Refractor', year: '2008-', note: 'Bowman Chrome 棒球卡折射' },
|
||||
grating_config: { sheen_band_angle: 135, sheen_speed: 0.40, sheen_intensity: 0.36, foil_coverage: 0.65, backdrop_tone: '#A89E91' },
|
||||
color_hint: '暖银 浅金',
|
||||
bg_prompt: 'warm silver Bowman Chrome refractor surface, soft warm metallic gradient with single diagonal sheen, clean baseball prospect card mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'thin warm silver border, single diagonal sheen line',
|
||||
tags: 'baseball,sport',
|
||||
},
|
||||
{
|
||||
id: 'prizm_pink', rarity: 'R', display_name_zh: '粉棱镜', display_name_en: 'Prizm Pink', weight: 5,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Pink Prizm (WBC)', year: '2023-', note: '粉棱镜折射' },
|
||||
grating_config: { sheen_band_angle: 120, sheen_speed: 0.40, sheen_intensity: 0.36, foil_coverage: 0.68, backdrop_tone: '#9E958A' },
|
||||
color_hint: '樱花粉 玫瑰粉',
|
||||
bg_prompt: 'cherry blossom pink prismatic holographic surface, soft rose and magenta layered sheen, vivid pink-to-silver diagonal band, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean pink bevel border, soft floral corner accents',
|
||||
tags: 'pink,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'wave_blue', rarity: 'R', display_name_zh: '波浪蓝', display_name_en: 'Wave Blue', weight: 5,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Wave', year: '2021-', note: 'Wave 波浪折射' },
|
||||
grating_config: { sheen_band_angle: 100, sheen_speed: 0.46, sheen_intensity: 0.34, foil_coverage: 0.65, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '海蓝 浪花白',
|
||||
bg_prompt: 'wave-patterned blue holographic surface, layered horizontal ocean wave sheen, soft sea blue gradient with silver crests, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean silver border, soft wave corner motif',
|
||||
tags: 'wave,blue,sport',
|
||||
},
|
||||
|
||||
// ============ SR 档(10 个,Mosaic/Atomic/Neon 类限量工艺)============
|
||||
{
|
||||
id: 'mosaic', rarity: 'SR', display_name_zh: '马赛克', display_name_en: 'Mosaic', weight: 3,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Mosaic', year: '2019-', note: 'Mosaic 马赛克拼贴反色' },
|
||||
grating_config: { sheen_band_angle: 115, sheen_speed: 0.44, sheen_intensity: 0.40, foil_coverage: 0.78, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '多彩 马赛克',
|
||||
bg_prompt: 'mosaic tiled holographic surface, geometric tile pattern with shimmer variation between cells, prismatic rainbow sheen across tile grid, structured luxury card mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'geometric mosaic border, tile corner motif',
|
||||
tags: 'mosaic,pattern',
|
||||
},
|
||||
{
|
||||
id: 'shimmer', rarity: 'SR', display_name_zh: '微光闪片', display_name_en: 'Shimmer', weight: 3,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Shimmer', year: '2020-', note: 'Shimmer 闪光颗粒反色' },
|
||||
grating_config: { sheen_band_angle: 125, sheen_speed: 0.42, sheen_intensity: 0.40, foil_coverage: 0.76, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '珍珠光 颗粒银',
|
||||
bg_prompt: 'shimmer micro-flake holographic surface, dense pearl flake particles with prismatic sparkle, soft radiant metallic gradient, glitter-card premium mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'fine pearl border, sparkle scatter',
|
||||
tags: 'shimmer,sparkle',
|
||||
},
|
||||
{
|
||||
id: 'neon_molectric', rarity: 'SR', display_name_zh: '霓虹电光', display_name_en: 'Neon Molectric', weight: 3,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Neon Molectric', year: '2021-', note: '霓虹电光反色' },
|
||||
grating_config: { sheen_band_angle: 110, sheen_speed: 0.46, sheen_intensity: 0.44, foil_coverage: 0.78, backdrop_tone: '#9C9993' },
|
||||
color_hint: '霓虹粉 电光绿',
|
||||
bg_prompt: 'neon molectric holographic surface, vivid neon pink and electric green sheen sweep, dark silver base with bright color sparks, cyber-arcade premium mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'neon border, sharp diagonal sheen sweep',
|
||||
tags: 'neon,cyber',
|
||||
},
|
||||
{
|
||||
id: 'disco', rarity: 'SR', display_name_zh: '迪斯科', display_name_en: 'Disco', weight: 3,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Disco', year: '2020-', note: 'Disco 迪斯科球反色' },
|
||||
grating_config: { sheen_band_angle: 130, sheen_speed: 0.44, sheen_intensity: 0.42, foil_coverage: 0.78, backdrop_tone: '#A89E91' },
|
||||
color_hint: '彩虹光 多彩',
|
||||
bg_prompt: 'disco ball holographic surface, scattered facet sparkles across silver base, vivid prismatic points of light, retro disco luxury mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'silver bevel border, scattered facet sparkle',
|
||||
tags: 'disco,party',
|
||||
},
|
||||
{
|
||||
id: 'topps_atomic', rarity: 'SR', display_name_zh: '原子折射', display_name_en: 'Topps Atomic', weight: 3,
|
||||
family: 'topps_chrome',
|
||||
reference: { series: 'Topps Chrome Atomic Refractor', year: '2018-', note: 'Atomic 原子反色' },
|
||||
grating_config: { sheen_band_angle: 120, sheen_speed: 0.42, sheen_intensity: 0.40, foil_coverage: 0.76, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '电蓝 辐射光',
|
||||
bg_prompt: 'atomic refractor holographic surface, vivid electric blue and white atomic burst sheen, sharp radiant diagonal sweep, retro sci-fi card mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean silver border, atomic burst corner motif',
|
||||
tags: 'atomic,blue,sport',
|
||||
},
|
||||
{
|
||||
id: 'hyper_prizm', rarity: 'SR', display_name_zh: '极速棱镜', display_name_en: 'Hyper Prizm', weight: 2,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Hyper Prizm', year: '2022-', note: 'Hyper 极速棱镜反色' },
|
||||
grating_config: { sheen_band_angle: 100, sheen_speed: 0.50, sheen_intensity: 0.42, foil_coverage: 0.80, backdrop_tone: '#9C9993' },
|
||||
color_hint: '极速银 锐利光',
|
||||
bg_prompt: 'hyper speed holographic surface, sharp high-speed diagonal sheen sweep across silver base, kinetic energy lines, futuristic racing card mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'sharp silver border, kinetic corner motif',
|
||||
tags: 'hyper,speed',
|
||||
},
|
||||
{
|
||||
id: 'cracked_ice', rarity: 'SR', display_name_zh: '碎冰', display_name_en: 'Cracked Ice', weight: 3,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Cracked Ice', year: '2021-', note: 'Cracked Ice 碎冰反色' },
|
||||
grating_config: { sheen_band_angle: 105, sheen_speed: 0.40, sheen_intensity: 0.38, foil_coverage: 0.74, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '冰蓝 裂纹光',
|
||||
bg_prompt: 'cracked ice holographic surface, crystalline fracture pattern with cool blue sheen in cracks, frozen lake premium mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'clean silver border, ice crack corner motif',
|
||||
tags: 'ice,cracked',
|
||||
},
|
||||
{
|
||||
id: 'yugioh_super', rarity: 'SR', display_name_zh: '游戏王 SR', display_name_en: 'Yu-Gi-Oh Super Rare', weight: 2,
|
||||
family: 'yugioh',
|
||||
reference: { series: 'Yu-Gi-Oh Super Rare', year: '2002-', note: '游戏王 SR 全卡面横纹反色' },
|
||||
grating_config: { sheen_band_angle: 90, sheen_speed: 0.38, sheen_intensity: 0.40, foil_coverage: 0.85, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '横纹光 彩虹',
|
||||
bg_prompt: 'Yu-Gi-Oh Super Rare full-card horizontal stripe holographic, parallel horizontal sheen bands sweeping across entire art, classic TCG anime premium mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card sheen stripe',
|
||||
tags: 'yugioh,stripe',
|
||||
},
|
||||
{
|
||||
id: 'yugioh_ultra', rarity: 'SR', display_name_zh: '游戏王 UR', display_name_en: 'Yu-Gi-Oh Ultra Rare', weight: 2,
|
||||
family: 'yugioh',
|
||||
reference: { series: 'Yu-Gi-Oh Ultra Rare', year: '2002-', note: '游戏王 UR 雪花+横纹混合' },
|
||||
grating_config: { sheen_band_angle: 90, sheen_speed: 0.40, sheen_intensity: 0.42, foil_coverage: 0.88, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '雪花 棱镜',
|
||||
bg_prompt: 'Yu-Gi-Oh Ultra Rare full-card horizontal stripe holographic with cross-hatch sparkles, dense sheen sweep with sparkle dots, premium anime TCG mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card sheen with sparkle dots',
|
||||
tags: 'yugioh,stripe,sparkle',
|
||||
},
|
||||
{
|
||||
id: 'ws_sr', rarity: 'SR', display_name_zh: 'WS SR', display_name_en: 'Weiss Schwarz SR', weight: 2,
|
||||
family: 'weiss',
|
||||
reference: { series: 'Weiss Schwarz SR', year: '2008-', note: 'WS SR 横纹反色' },
|
||||
grating_config: { sheen_band_angle: 95, sheen_speed: 0.40, sheen_intensity: 0.40, foil_coverage: 0.82, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '横纹银 轻彩虹',
|
||||
bg_prompt: 'Weiss Schwarz SR full-card horizontal stripe holographic, soft parallel silver sheen bands, clean Japanese-anime card premium mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card horizontal sheen',
|
||||
tags: 'weiss,stripe',
|
||||
},
|
||||
|
||||
// ============ SSR 档(9 个,Full Art/Rainbow/Gold 类稀有工艺)============
|
||||
{
|
||||
id: 'rainbow_rare', rarity: 'SSR', display_name_zh: '彩虹稀有', display_name_en: 'Rainbow Rare', weight: 1,
|
||||
family: 'pokemon_tcg',
|
||||
reference: { series: 'Pokémon TCG Rainbow Rare', year: '2017-', note: '宝可梦彩虹稀有,真卡顶级工艺' },
|
||||
grating_config: { sheen_band_angle: 100, sheen_speed: 0.46, sheen_intensity: 0.46, foil_coverage: 0.90, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '全光谱彩虹',
|
||||
bg_prompt: 'Pokémon Rainbow Rare full-card rainbow holographic, dense full-spectrum sheen sweeping across entire art, vivid prismatic layers, premium Japanese TCG luxury mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card rainbow sheen with sparkle dots',
|
||||
tags: 'pokemon,rainbow,fullart',
|
||||
},
|
||||
{
|
||||
id: 'prizm_gold', rarity: 'SSR', display_name_zh: '金棱镜', display_name_en: 'Prizm Gold', weight: 0.8,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Gold Prizm', year: '2018-', note: '金棱镜,真卡高阶平行版' },
|
||||
grating_config: { sheen_band_angle: 120, sheen_speed: 0.42, sheen_intensity: 0.42, foil_coverage: 0.85, backdrop_tone: '#A89E91' },
|
||||
color_hint: '金色 暖金',
|
||||
bg_prompt: 'gold Prizm holographic surface, vivid gold-to-amber sheen sweeping diagonally, warm metallic premium sport card luxury mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'gold bevel border, sharp diagonal sheen sweep',
|
||||
tags: 'gold,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'prizm_black', rarity: 'SSR', display_name_zh: '黑棱镜', display_name_en: 'Prizm Black', weight: 0.8,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Black Prizm', year: '2020-', note: '黑棱镜,稀缺平行版' },
|
||||
grating_config: { sheen_band_angle: 115, sheen_speed: 0.40, sheen_intensity: 0.44, foil_coverage: 0.88, backdrop_tone: '#9C9993' },
|
||||
color_hint: '黑银 烟雾光',
|
||||
bg_prompt: 'black Prizm holographic surface, dark silver-to-black gradient with vivid rainbow sparkle dots, mysterious dark luxury card mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'dark silver border, scattered rainbow sparkles',
|
||||
tags: 'black,sport,prizm',
|
||||
},
|
||||
{
|
||||
id: 'cosmic_prizm', rarity: 'SSR', display_name_zh: '宇宙棱镜', display_name_en: 'Cosmic Prizm', weight: 0.8,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Cosmic', year: '2023-', note: '宇宙棱镜,空间主题' },
|
||||
grating_config: { sheen_band_angle: 105, sheen_speed: 0.44, sheen_intensity: 0.44, foil_coverage: 0.86, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '深空紫 星光',
|
||||
bg_prompt: 'cosmic Prizm holographic surface, deep purple and navy gradient with starfield sparkle dots and nebula-like color swirl, cinematic space premium mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'silver border, star scatter corner',
|
||||
tags: 'cosmic,space',
|
||||
},
|
||||
{
|
||||
id: 'prizm_shattered', rarity: 'SSR', display_name_zh: '碎光棱镜', display_name_en: 'Shattered Prizm', weight: 0.8,
|
||||
family: 'panini_prizm',
|
||||
reference: { series: 'Panini Prizm Shattered Glass', year: '2019-', note: '碎光玻璃反色' },
|
||||
grating_config: { sheen_band_angle: 95, sheen_speed: 0.42, sheen_intensity: 0.44, foil_coverage: 0.86, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '玻璃碎光 棱镜',
|
||||
bg_prompt: 'shattered glass Prizm holographic surface, geometric glass-shard pattern with prismatic rainbow between shards, dramatic luxury sport card mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'silver bevel border, glass shard corner motif',
|
||||
tags: 'shattered,glass',
|
||||
},
|
||||
{
|
||||
id: 'topps_sepia', rarity: 'SSR', display_name_zh: '复古棕', display_name_en: 'Topps Sepia', weight: 0.7,
|
||||
family: 'topps_chrome',
|
||||
reference: { series: 'Topps Chrome Sepia Refractor', year: '2017-', note: '复古棕反色,棒球稀有平行' },
|
||||
grating_config: { sheen_band_angle: 130, sheen_speed: 0.38, sheen_intensity: 0.40, foil_coverage: 0.82, backdrop_tone: '#A89E91' },
|
||||
color_hint: '棕褐色 复古',
|
||||
bg_prompt: 'sepia refractor holographic surface, warm brown and amber gradient with vintage metallic sheen, nostalgic baseball-card premium mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'warm sepia border, vintage corner motif',
|
||||
tags: 'sepia,vintage,sport',
|
||||
},
|
||||
{
|
||||
id: 'pokemon_fullart', rarity: 'SSR', display_name_zh: '全图宝可梦', display_name_en: 'Pokémon Full Art', weight: 0.7,
|
||||
family: 'pokemon_tcg',
|
||||
reference: { series: 'Pokémon TCG Full Art Rare', year: '2010-', note: '宝可梦全图稀有' },
|
||||
grating_config: { sheen_band_angle: 110, sheen_speed: 0.44, sheen_intensity: 0.44, foil_coverage: 0.88, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: '柔光 全图',
|
||||
bg_prompt: 'Pokémon Full Art holographic background with edge-to-edge soft rainbow sheen, art fills entire card with shimmering border halo, premium Japanese TCG mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'soft rainbow halo around art, no hard border',
|
||||
tags: 'pokemon,fullart',
|
||||
},
|
||||
{
|
||||
id: 'yugioh_secret', rarity: 'SSR', display_name_zh: '游戏王 Secret', display_name_en: 'Yu-Gi-Oh Secret Rare', weight: 0.7,
|
||||
family: 'yugioh',
|
||||
reference: { series: 'Yu-Gi-Oh Secret Rare', year: '2002-', note: '游戏王 Secret 横纹+名字彩虹' },
|
||||
grating_config: { sheen_band_angle: 90, sheen_speed: 0.44, sheen_intensity: 0.44, foil_coverage: 0.90, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '横纹彩虹 Secret',
|
||||
bg_prompt: 'Yu-Gi-Oh Secret Rare full-card horizontal stripe holographic with sparkling diagonal cross-sheen, premium anime TCG secret-tier mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card sparkle cross-sheen',
|
||||
tags: 'yugioh,secret',
|
||||
},
|
||||
{
|
||||
id: 'gold_secret', rarity: 'SSR', display_name_zh: '游戏王金 Secret', display_name_en: 'Yu-Gi-Oh Gold Secret', weight: 0.7,
|
||||
family: 'yugioh',
|
||||
reference: { series: 'Yu-Gi-Oh Gold Secret Rare', year: '2012-', note: '游戏王金 Secret 稀有' },
|
||||
grating_config: { sheen_band_angle: 90, sheen_speed: 0.42, sheen_intensity: 0.42, foil_coverage: 0.92, backdrop_tone: '#A89E91' },
|
||||
color_hint: '金色 Secret',
|
||||
bg_prompt: 'Yu-Gi-Oh Gold Secret full-card warm gold horizontal stripe holographic, dense gold sheen sweeping across entire art, premium anime TCG luxury mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card gold sheen',
|
||||
tags: 'yugioh,gold',
|
||||
},
|
||||
|
||||
// ============ UR 档(6 个,终极稀有)============
|
||||
{
|
||||
id: 'pokemon_star', rarity: 'UR', display_name_zh: '宝可梦星卡', display_name_en: 'Pokémon Star', weight: 0.4,
|
||||
family: 'pokemon_tcg',
|
||||
reference: { series: 'Pokémon TCG Star Holo', year: '2000-2003', note: '宝可梦初代星卡,真卡传奇工艺' },
|
||||
grating_config: { sheen_band_angle: 95, sheen_speed: 0.50, sheen_intensity: 0.50, foil_coverage: 0.95, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '全光谱 星辉',
|
||||
bg_prompt: 'Pokémon Star holographic surface with five-point star burst pattern across entire art, intense prismatic star radiance, legendary premium TCG mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'star burst overlay across entire art',
|
||||
tags: 'pokemon,star,legend',
|
||||
},
|
||||
{
|
||||
id: 'superfractor', rarity: 'UR', display_name_zh: '至尊折射', display_name_en: 'Topps Superfractor', weight: 0.3,
|
||||
family: 'topps_chrome',
|
||||
reference: { series: 'Topps Chrome Superfractor (1/1)', year: '1996-', note: 'Topps Chrome 1/1 真卡顶点' },
|
||||
grating_config: { sheen_band_angle: 100, sheen_speed: 0.50, sheen_intensity: 0.55, foil_coverage: 1.00, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '全光谱彩虹',
|
||||
bg_prompt: 'Topps Chrome Superfractor 1/1 full-spectrum rainbow holographic, intense prismatic rainbow burst across entire art, ultimate luxury card apex mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'rainbow burst overlay, no border',
|
||||
tags: 'superfractor,1of1,apex',
|
||||
},
|
||||
{
|
||||
id: 'yugioh_starlight', rarity: 'UR', display_name_zh: '游戏王星光', display_name_en: 'Yu-Gi-Oh Starlight Rare', weight: 0.3,
|
||||
family: 'yugioh',
|
||||
reference: { series: 'Yu-Gi-Oh Starlight Rare', year: '2018-', note: '游戏王星光稀有,终极工艺' },
|
||||
grating_config: { sheen_band_angle: 95, sheen_speed: 0.48, sheen_intensity: 0.50, foil_coverage: 0.98, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '星光 全光谱',
|
||||
bg_prompt: 'Yu-Gi-Oh Starlight Rare full-card starlight burst holographic, intense star-pattern radiance across entire art, ultimate anime TCG apex mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'starlight burst overlay, no border',
|
||||
tags: 'yugioh,starlight,apex',
|
||||
},
|
||||
{
|
||||
id: 'yugioh_platinum', rarity: 'UR', display_name_zh: '游戏王白金', display_name_en: 'Yu-Gi-Oh Platinum Secret', weight: 0.3,
|
||||
family: 'yugioh',
|
||||
reference: { series: 'Yu-Gi-Oh Platinum Secret Rare', year: '2014-', note: '游戏王白金 Secret' },
|
||||
grating_config: { sheen_band_angle: 90, sheen_speed: 0.46, sheen_intensity: 0.50, foil_coverage: 0.98, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '白金 棱镜',
|
||||
bg_prompt: 'Yu-Gi-Oh Platinum Secret full-card platinum-white horizontal stripe holographic with cross sparkle, intense white-silver sheen, ultimate anime TCG mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card platinum sheen',
|
||||
tags: 'yugioh,platinum',
|
||||
},
|
||||
{
|
||||
id: 'yugioh_prismatic', rarity: 'UR', display_name_zh: '游戏王棱镜 Secret', display_name_en: 'Yu-Gi-Oh Prismatic Secret', weight: 0.3,
|
||||
family: 'yugioh',
|
||||
reference: { series: 'Yu-Gi-Oh Prismatic Secret Rare', year: '2020-', note: '游戏王棱镜 Secret' },
|
||||
grating_config: { sheen_band_angle: 90, sheen_speed: 0.46, sheen_intensity: 0.52, foil_coverage: 0.98, backdrop_tone: '#B0ACA6' },
|
||||
color_hint: '棱镜 彩虹',
|
||||
bg_prompt: 'Yu-Gi-Oh Prismatic Secret full-card prismatic rainbow with multi-axis sheen, intense multi-directional prismatic burst, ultimate anime TCG apex mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card prismatic burst',
|
||||
tags: 'yugioh,prismatic',
|
||||
},
|
||||
{
|
||||
id: 'ws_ssp', rarity: 'UR', display_name_zh: 'WS SSP', display_name_en: 'Weiss SSP', weight: 0.3,
|
||||
family: 'weiss',
|
||||
reference: { series: 'Weiss Schwarz SSP', year: '2015-', note: 'WS SSP 终极签名/限定' },
|
||||
grating_config: { sheen_band_angle: 95, sheen_speed: 0.48, sheen_intensity: 0.48, foil_coverage: 0.95, backdrop_tone: '#A8ACB2' },
|
||||
color_hint: 'SSP 彩虹',
|
||||
bg_prompt: 'Weiss Schwarz SSP full-card horizontal stripe holographic with rainbow sparkle dots, dense premium anime collectible apex mood, no text, no people, no watermark',
|
||||
overlay_prompt: 'no border, full-card sparkle stripe',
|
||||
tags: 'weiss,ssp,apex',
|
||||
},
|
||||
]
|
||||
|
||||
/** 全池 style ID 列表(顺序与 LASER_STYLE_POOL 一致) */
|
||||
export const LASER_STYLE_IDS = LASER_STYLE_POOL.map((s) => s.id)
|
||||
|
||||
/** O(1) ID 索引(用于通过 preset_id 反查 style) */
|
||||
export const LASER_STYLE_INDEX = LASER_STYLE_POOL.reduce(
|
||||
(acc, s, i) => {
|
||||
acc[s.id] = i
|
||||
return acc
|
||||
},
|
||||
/** @type {Record<string, number>} */ ({}),
|
||||
)
|
||||
|
||||
/** 档位统计({N:10, R:10, SR:10, SSR:9, UR:6}) */
|
||||
export const RARITY_DISTRIBUTION = LASER_STYLE_POOL.reduce(
|
||||
(acc, s) => {
|
||||
acc[s.rarity] = (acc[s.rarity] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
/** @type {Record<string, number>} */ ({}),
|
||||
)
|
||||
|
||||
/** 档位配色(供 UI 徽章复用) */
|
||||
export const RARITY_COLORS = {
|
||||
N: '#78828C',
|
||||
R: '#50A0DC',
|
||||
SR: '#B464DC',
|
||||
SSR: 'linear-gradient(90deg, #ff8a00, #e52e71)',
|
||||
UR: 'linear-gradient(90deg, #ff5e62, #ff9966, #ff5e62)',
|
||||
}
|
||||
|
||||
/** 档位星数(供 UI 显示) */
|
||||
export const RARITY_STARS = {
|
||||
N: '★',
|
||||
R: '★★',
|
||||
SR: '★★★',
|
||||
SSR: '★★★★',
|
||||
UR: '★★★★★',
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user