Skill: turkyden/tandian-image-skills (version 0.1.2)

--- name: tandian-image-skills description: | 本地生活探店图像处理 skill。接收门店、餐饮、商场、咖啡店等本地生活场景图，默认从预置模特 URL 配置随机选择一张作为人物参考，或由用户显式传入模特图 URL，调用 Replicate 的 gpt-image-2 做图像编辑，再调用 SeedVR2 做高清放大。用户提到探店图、门店场景合成、模特放入店铺场景、美女角色迁移、Replicate 图像编辑、SeedVR2 放大时使用。 --- 你是探店场景图像生成助手，负责把人物参考图自然融合到用户提供的本地生活门店场景中，并输出放大后的高清结果。 ## 适用场景 - 用户要基于一张门店或店内场景图生成探店照片 - 用户要把模特或美女角色放入咖啡店、餐厅、商场、展厅等场景 - 用户明确要求使用 Replicate 的 gpt-image-2 做编辑，或再接 SeedVR2 做清晰度增强 ## 前置条件 执行前先确认以下条件成立： 1. 环境变量 `REPLICATE_API_TOKEN` 已配置 2. 用户提供了可访问的场景参考图路径或 URL 3. 输出路径可写 如果缺少 token 或输入图，先向用户指出缺失项，不要编造参数。 ## 默认执行规则 1. 场景图作为图2，人物模板作为图1 2. 如果用户没有指定人物模板，默认从 `scripts/role_templates.js` 内的预置模特 URL 配置随机选择 3. gpt-image-2 默认提示词固定使用：生成一个探店打卡照片，要求：提取图1中的美女人物角色，她的姿势可以随意自由变化，站姿或者坐姿都可以，放入场景参考图2中，图2里的场景保持不变，不要漏出手指，远景镜头，去除无关水印，iphone 手机拍摄质感。 4. 其他 Replicate API 参数以 `scripts/replicate_cli.js` 中现有实现为准，不擅自改写 5. 图像编辑完成后，继续调用 SeedVR2 做放大输出 6. openclaw 或其他执行器默认以当前 skill 目录作为执行目录，命令统一使用当前目录相对路径，不再拼接工作区级固定前缀 ## 执行步骤 1. 确认输入的场景图路径或 URL 2. 如用户指定角色模板 URL，则优先使用 `--template-url`；如用户指定预置模板 key，则使用 `--template-preset`；否则走预置模板随机 3. 优先运行 `scripts/run_tandian.sh`，由包装脚本调用 `scripts/replicate_cli.js` 完成 gpt-image-2 编辑和 SeedVR2 放大 4. 将输出文件路径回传给用户，并说明使用了哪个角色模板来源（预置随机、预置 key、或用户 URL） ## 测试目录 - `test/input/`：用于存放本地测试场景图 - `test/output/`：用于存放本地测试生成结果 - 当前示例可直接使用 `test/input/1.png` 作为输入 ## 推荐入口 优先使用一键脚本： ```bash REPLICATE_API_TOKEN=你的token scripts/run_tandian.sh \ test/input/1.png ``` 如果不传输出路径，脚本会自动写到当前 skill 目录下的 `./output/` 目录，并生成带时间戳的文件名。 指定输出路径和模板： ```bash REPLICATE_API_TOKEN=你的token scripts/run_tandian.sh \ test/input/1.png \ test/output/1.webp \ --template-preset lucy ``` 指定远程模板 URL： ```bash REPLICATE_API_TOKEN=你的token scripts/run_tandian.sh \ test/input/1.png \ test/output/1.webp \ --template-url "https://example.com/model.png" ``` ## 底层命令 在当前 skill 目录执行： ```bash REPLICATE_API_TOKEN=你的token node scripts/replicate_cli.js \ --image test/input/1.png \ --out test/output/1.webp ``` 指定角色模板： ```bash REPLICATE_API_TOKEN=你的token node scripts/replicate_cli.js \ --image test/input/1.png \ --templatePreset lucy \ --out test/output/1.webp ``` 指定角色模板 URL： ```bash REPLICATE_API_TOKEN=你的token node scripts/replicate_cli.js \ --image test/input/1.png \ --templateUrl "https://example.com/model.png" \ --out test/output/1.webp ``` 覆盖默认提示词： ```bash REPLICATE_API_TOKEN=你的token node scripts/replicate_cli.js \ --image test/input/1.png \ --out test/output/1.webp \ --prompt "生成一个探店打卡照片，要求：提取图1中的美女人物角色，她的姿势可以随意自由变化，站姿或者坐姿都可以，放入场景参考图2中，图2里的场景保持不变，不要漏出手指，远景镜头，去除无关水印，iphone 手机拍摄质感。" ``` ## 参数说明 - `--image`：必填，门店或本地生活场景参考图 - `--out`：必填，最终高清输出文件路径 - `--template-preset`：可选，包装脚本参数，指定预置模特 key（如 `lucy`） - `--templatePreset`：可选，底层 CLI 参数，指定预置模特 key（如 `lucy`） - `--template-url`：可选，包装脚本参数，直接传远程角色图 URL - `--templateUrl`：可选，底层 CLI 参数，直接传远程角色图 URL - `--template`：兼容参数，等价于 `--templatePreset` - `--aspect`：可选，当前支持 `1:1`、`2:3`、`3:2`，默认 `2:3` - `--prompt`：可选，未传时使用默认探店打卡提示词 ## 约束 1. 不要颠倒图1和图2的语义，人物参考必须放在图1，场景参考必须放在图2 2. 不要跳过 SeedVR2 放大步骤，除非用户明确要求只返回编辑结果 3. 不要自造模板 key；如果用户指定模板，必须来自预置 key 或可访问的 `--templateUrl` 4. 如果 Replicate 返回失败或超时，原样告知错误信息，并建议用户重试或更换输入图

path: scripts/replicate_cli.js
content: #!/usr/bin/env node
/*
Simple CLI script to call Replicate for gpt-image-2 edit + SeedVR2 upscale,
save the final HD image to disk.

Usage:
  REPLICATE_API_TOKEN=... node scripts/replicate_cli.js --image test/input/1.png --out test/output/1.webp
  REPLICATE_API_TOKEN=... node scripts/replicate_cli.js --image test/input/1.png --templatePreset lucy --out test/output/1.webp --prompt "..."

*/
const fs = require('node:fs/promises')
const path = require('node:path')
const { PRESET_ROLE_TEMPLATES } = require('./role_templates')

const POLL_INTERVAL = 1500
const POLL_TIMEOUT = 90_000
const ALLOWED_ASPECT_RATIOS = ['1:1', '2:3', '3:2']
const DEFAULT_PROMPT = '生成一个探店打卡照片，要求：提取图1中的美女人物角色，她的姿势可以随意自由变化，站姿或者坐姿都可以，放入场景参考图2中，图2里的场景保持不变，不要漏出手指，远景镜头，去除无关水印，iphone 手机拍摄质感。'

function usage() {
  console.log('Usage: REPLICATE_API_TOKEN=... node scripts/replicate_cli.js --image test/input/1.png --out test/output/1.webp [--templatePreset lucy] [--templateUrl https://...] [--aspect 2:3] [--prompt "..."]')
}

function parseArgs() {
  const args = process.argv.slice(2)
  const res = {}
  for (let i = 0; i < args.length; i++) {
    const a = args[i]
    if (a.startsWith('--')) {
      const key = a.slice(2)
      const val = args[i + 1] && !args[i + 1].startsWith('--') ? args[++i] : 'true'
      res[key] = val
    }
  }
  return res
}

function isRemoteUrl(input) {
  return input.startsWith('http://') || input.startsWith('https://')
}

function getMimeType(filePathOrUrl) {
  const ext = path.extname(filePathOrUrl).toLowerCase()
  if (ext === '.webp') return 'image/webp'
  if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
  return 'image/png'
}

function getPresetTemplateEntries() {
  const entries = Object.entries(PRESET_ROLE_TEMPLATES).filter(([, url]) => typeof url === 'string' && isRemoteUrl(url))
  if (entries.length === 0) {
    throw new Error('No preset role templates configured')
  }
  return entries
}

function pickRandomItem(items) {
  return items[Math.floor(Math.random() * items.length)]
}

function normalizePresetKey(rawKey) {
  const lower = rawKey.toLowerCase().trim()
  return lower.endsWith('.png') || lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.webp')
    ? lower.slice(0, lower.lastIndexOf('.'))
    : lower
}

function resolveTemplateSource(args) {
  if (args.templateUrl) {
    if (!isRemoteUrl(args.templateUrl)) {
      throw new Error(`templateUrl must be an http/https URL: ${args.templateUrl}`)
    }
    return { url: args.templateUrl, label: args.templateUrl, source: 'user-url' }
  }

  const presetEntries = getPresetTemplateEntries()
  const requestedPreset = args.templatePreset || args.template
  if (requestedPreset) {
    const key = normalizePresetKey(requestedPreset)
    const selected = PRESET_ROLE_TEMPLATES[key]
    if (!selected) {
      throw new Error(`Template preset not found: ${requestedPreset}. Available presets: ${presetEntries.map(([name]) => name).join(', ')}`)
    }
    return { url: selected, label: `preset:${key}`, source: 'preset' }
  }

  const [randomKey, randomUrl] = pickRandomItem(presetEntries)
  return { url: randomUrl, label: `preset:${randomKey}`, source: 'preset-random' }
}

async function fileOrUrlToDataUrl(input) {
  if (!input) throw new Error('no input')
  try {
    if (isRemoteUrl(input)) {
      const resp = await fetch(input)
      if (!resp.ok) throw new Error(`Failed to fetch template: ${resp.status}`)
      const ab = await resp.arrayBuffer()
      const b64 = Buffer.from(ab).toString('base64')
      const mime = resp.headers.get('content-type') || 'image/png'
      return `data:${mime};base64,${b64}`
    } else {
      const abs = path.resolve(process.cwd(), input)
      const buf = await fs.readFile(abs)
      const mime = getMimeType(abs)
      return `data:${mime};base64,${buf.toString('base64')}`
    }
  } catch (err) {
    throw err
  }
}

async function createPrediction(token, userImage, templateImage, aspectRatio, prompt) {
  const input = {
    aspect_ratio: aspectRatio,
    background: 'auto',
    input_images: [templateImage, userImage],
    moderation: 'auto',
    number_of_images: 1,
    output_compression: 90,
    output_format: 'webp',
    prompt: prompt || DEFAULT_PROMPT,
    quality: 'low',
  }

  const res = await fetch('https://api.replicate.com/v1/models/openai/gpt-image-2/predictions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify({ input }),
  })
  if (!res.ok) {
    const text = await res.text()
    throw new Error(`Replicate create failed: ${res.status} ${text}`)
  }
  return res.json()
}

async function getPrediction(id, token) {
  const res = await fetch(`https://api.replicate.com/v1/predictions/${id}`, {
    headers: { Authorization: `Bearer ${token}` },
  })
  if (!res.ok) {
    const text = await res.text()
    throw new Error(`Replicate get failed: ${res.status} ${text}`)
  }
  return res.json()
}

async function createUpscalePrediction(token, mediaUrl) {
  const res = await fetch('https://api.replicate.com/v1/predictions', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
    body: JSON.stringify({
      version: 'ca98249be9cb623f02a80a7851a2b1a33d5104c251a8f5a1588f251f79bf7c78',
      input: {
        media: mediaUrl,
        fps: 24,
        sp_size: 1,
        cfg_scale: 1,
        sample_steps: 1,
        model_variant: '3b',
        output_format: 'webp',
        output_quality: 90,
        apply_color_fix: true,
      },
    }),
  })
  if (!res.ok) {
    const text = await res.text()
    throw new Error(`SeedVR2 create failed: ${res.status} ${text}`)
  }
  return res.json()
}

async function downloadToFile(url, outPath) {
  const resp = await fetch(url)
  if (!resp.ok) throw new Error(`Failed downloading output: ${resp.status}`)
  const ab = await resp.arrayBuffer()
  await fs.mkdir(path.dirname(outPath), { recursive: true })
  await fs.writeFile(outPath, Buffer.from(ab))
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms))
}

async function main() {
  const args = parseArgs()
  if (!args.image || !args.out) {
    usage()
    process.exit(1)
  }
  const [REDACTED_SECRET]
  if (!token) {
    console.error('Missing REPLICATE_API_TOKEN in environment')
    process.exit(2)
  }
  const aspect = args.aspect || '2:3'
  if (!ALLOWED_ASPECT_RATIOS.includes(aspect)) {
    console.error('Invalid aspect ratio')
    process.exit(1)
  }

  try {
    const userImage = await fileOrUrlToDataUrl(args.image)
    const selectedTemplate = resolveTemplateSource(args)
    const templateImage = await fileOrUrlToDataUrl(selectedTemplate.url)

    console.log('Using role template:', selectedTemplate.label)

    const pred = await createPrediction(token, userImage, templateImage, aspect, args.prompt || '')
    const start = Date.now()
    let finalUrl = null
    while (true) {
      const status = await getPrediction(pred.id, token)
      if (status.status === 'succeeded') {
        const output = Array.isArray(status.output) ? status.output[0] : status.output
        if (!output) throw new Error('Invalid output from replicate')
        const up = await createUpscalePrediction(token, output)
        const upStart = Date.now()
        while (true) {
          const upStatus = await getPrediction(up.id, token)
          if (upStatus.status === 'succeeded') {
            const hd = Array.isArray(upStatus.output) ? upStatus.output[0] : upStatus.output
            finalUrl = hd
            break
          }
          if (upStatus.status === 'failed') throw new Error(upStatus.error || 'SeedVR2 failed')
          if (Date.now() - upStart > POLL_TIMEOUT) throw new Error('Timeout waiting for SeedVR2')
          await sleep(POLL_INTERVAL)
        }
        break
      }
      if (status.status === 'failed') throw new Error(status.error || 'Replicate failed')
      if (Date.now() - start > POLL_TIMEOUT) throw new Error('Timeout waiting for prediction')
      await sleep(POLL_INTERVAL)
    }

    if (!finalUrl) throw new Error('No final url')
    await downloadToFile(finalUrl, path.resolve(process.cwd(), args.out))
    console.log('Saved output to', args.out)
  } catch (err) {
    console.error('Error:', err?.message || String(err))
    process.exit(3)
  }
}

if (process.argv[1] && process.argv[1].endsWith('replicate_cli.js')) {
  main()
}


path: scripts/role_templates.js
content: // Centralized preset model URLs for tandian image generation.
const PRESET_ROLE_TEMPLATES = {
  lucy:
    'https://images.unsplash.com/photo-1529232356377-57971f020a94?q=80&w=1288&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D',
}

module.exports = {
  PRESET_ROLE_TEMPLATES,
}


path: scripts/run_tandian.sh
content: #!/usr/bin/env bash

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI_SCRIPT="$SCRIPT_DIR/replicate_cli.js"
DEFAULT_PROMPT='生成一个探店打卡照片，要求：提取图1中的美女人物角色，她的姿势可以随意自由变化，站姿或者坐姿都可以，放入场景参考图2中，图2里的场景保持不变，不要漏出手指，远景镜头，去除无关水印，iphone 手机拍摄质感。'

usage() {
  cat <<'EOF'
Usage:
  scripts/run_tandian.sh <scene-image> [output-file] [--template-preset lucy] [--template-url https://...] [--aspect 2:3] [--prompt "..."]

Examples:
  scripts/run_tandian.sh test/input/1.png
  scripts/run_tandian.sh test/input/1.png test/output/1.webp --template-preset lucy
EOF
}

if [[ $# -lt 1 ]]; then
  usage
  exit 1
fi

if [[ "$1" == "-h" || "$1" == "--help" ]]; then
  usage
  exit 0
fi

if [[ -z "${REPLICATE_API_TOKEN:-}" ]]; then
  echo "Missing REPLICATE_API_TOKEN in environment" >&2
  exit 2
fi

SCENE_IMAGE="$1"
shift

OUTPUT_FILE=""
if [[ $# -gt 0 && "$1" != --* ]]; then
  OUTPUT_FILE="$1"
  shift
fi

if [[ -z "$OUTPUT_FILE" ]]; then
  scene_basename="$(basename "$SCENE_IMAGE")"
  scene_name="${scene_basename%.*}"
  OUTPUT_FILE="$SKILL_DIR/output/${scene_name}_tandian_$(date +%Y%m%d_%H%M%S).webp"
fi

ARGS=(--image "$SCENE_IMAGE" --out "$OUTPUT_FILE" --prompt "$DEFAULT_PROMPT")

while [[ $# -gt 0 ]]; do
  case "$1" in
    --template-preset)
      [[ $# -ge 2 ]] || { echo "Missing value for --template-preset" >&2; exit 1; }
      ARGS+=(--templatePreset "$2")
      shift 2
      ;;
    --template)
      [[ $# -ge 2 ]] || { echo "Missing value for --template" >&2; exit 1; }
      ARGS+=(--template "$2")
      shift 2
      ;;
    --aspect)
      [[ $# -ge 2 ]] || { echo "Missing value for --aspect" >&2; exit 1; }
      ARGS+=(--aspect "$2")
      shift 2
      ;;
    --prompt)
      [[ $# -ge 2 ]] || { echo "Missing value for --prompt" >&2; exit 1; }
      ARGS+=(--prompt "$2")
      shift 2
      ;;
    --template-url)
      [[ $# -ge 2 ]] || { echo "Missing value for --template-url" >&2; exit 1; }
      ARGS+=(--templateUrl "$2")
      shift 2
      ;;
    *)
      echo "Unknown argument: $1" >&2
      usage
      exit 1
      ;;
  esac
done

echo "Scene image: $SCENE_IMAGE"
echo "Output file: $OUTPUT_FILE"

node "$CLI_SCRIPT" "${ARGS[@]}"


Security scan verdicts — ClawScan: clean | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)