Skill: zoucdr/nail-art-video (version 1.0.1)

--- name: nail-art-video version: 1.0.0 description: "生成美甲制作过程短视频。猫眼、流沙、星空、奶油胶、微缩立体美甲——小尺寸极高细节，近景治愈感强，支持节日主题，一句话出片。" tags: [美甲, 精致, asmr, 变美, 手作, 短视频] metadata: openclaw: emoji: "💅" requires: bins: [node] user-invocable: true --- # 美甲制作视频生成 涂胶的弧线感、猫眼粉在磁石下瞬间汇聚成线、封层灯照后的镜面光泽——美甲视频的爆点全在这几秒里。小兔/猫咪给爪爪做精致美甲，每个细节都是治愈素材，配合后期的 ASMR 刷甲声效，完播率不需要担心。 **依赖**：本目录 `scripts/video_gen.js` + 运行环境需设置 `WERYAI_API_KEY` + Node.js 18+。不依赖其他 Cursor 技能。 --- ## 默认参数 | 参数 | 值 | |------|----| | 模型 | KLING_V3_0_PRO | | 比例 | 9:16（固定，竖版手部近景） | | 时长 | 5 秒（duration: 5，细节爆点聚焦） | | 音频 | 开启（涂胶声、UV灯滴滴声是美甲视频核心 ASMR 体验） | | 视觉风格 | 极近微距，手部特写，白色背景，柔和漫射光，光泽质感优先，颜色饱和度高 | > **API 合法性（默认 `KLING_V3_0_PRO`）**：文生 `duration` 仅 **5 / 10 / 15**，`aspect_ratio` 仅 **9:16、1:1、16:9**；图生 `aspect_ratio` 仅 **9:16、16:9、1:1**；**无 `resolution` 字段，请求勿传**。快档若用 VEO：文生 **`VEO_3_1_FAST`**、图生 **`CHATBOT_VEO_3_1_FAST`**，且 `duration` **固定 8**，`aspect_ratio` 仅 **9:16** 或 **16:9**。完整全模型表见 [`weryai-model-capabilities.md`](../references/weryai-model-capabilities.md)。 --- ## 拟人角色美甲制作 小动物爪爪做精致美甲，是这个赛道的专属内容形式。爪爪尺寸小、动作轻柔、指甲形状圆润——天然适合美甲近景治愈内容。 描述角色和想做的甲款，剩下的由 prompt 填充： **生成流程：** 收集角色描述 + 美甲款式 → 构建强调涂胶光泽、猫眼线条、封层镜面等视觉爆点的 prompt → 展示参数确认 → 执行 `node {baseDir}/scripts/video_gen.js wait --json '…'`（字段与确认表一致） > 即将使用以下参数生成，确认后开始： > - model：KLING_V3_0_PRO > - aspect_ratio：9:16 > - duration：5 > - generate_audio：true > - 循环衔接：否（回复"循环"可开启） **示例 Prompt：** > Extreme macro close-up of a small bunny's delicate paws, a nail technician applies deep navy cat-eye gel in a single smooth arcing stroke, immediately a slender magnetic wand sweeps slowly across the nail surface — fine metalite particles drift and gather in a perfect luminous line running from cuticle to tip, the nail is cured under UV light for 30 seconds, final reveal: three perfectly finished cat-eye nails with a moving aurora-like shimmer line visible in different angles, soft diffused white backlight, ASMR gel application and magnet sweep sound, minimal cream background > A small cat's round paws receive cherry blossom gel nails, medium macro shot, each nail gets a thin base of sheer pink gel, then a fine nail art brush places delicate three-petal sakura designs in white and pale rose, tiny yellow dot stamens added with a dotting tool, translucent overlay encases each flower, UV cure flash, final close-up pans across five perfectly finished nails with dimensional flowers visible under the gel surface, warm diffused salon light, ASMR brush stroke sound, white cotton pad background > A hamster's tiny paws get ultra-detailed Christmas theme nails, extreme close-up overhead, nail art pen draws miniature candy canes on alternating nails, one nail receives a tiny Christmas tree built up in green gel layers with sparkle top coat, red glitter gel sweeps a single accent nail, matte topcoat applied last for contrast, the final reveal shows five nails telling a tiny holiday story under warm macro ring light, ASMR application sounds, white ceramic nail rest surface **参数配置：** | 参数 | 值 | |------|----| | model | KLING_V3_0_PRO | | aspect_ratio | 9:16 | | duration | 5 | | generate_audio | true | --- ## 材质特写 ASMR 版本 不需要角色，纯聚焦美甲材料的质感瞬间：流沙粉在指甲盖上流动、奶油胶被刮刀抹开的绵软感、凝胶被灯光从半透明变成完全固化——材质本身就是内容。 说明想展示的材质类型，直接生成： **必须以表格展示所有参数，等待用户明确确认后再提交生成：** > 📋 **即将生成，请确认以下参数：** > > | 参数 | 本次值 | 说明 | > |------|--------|------| > | `model` | `KLING_V3_0_PRO` | 优档默认；快档：文生 `VEO_3_1_FAST`、图生 `CHATBOT_VEO_3_1_FAST`（`duration` 固定 8）；均衡→`KLING_V3_0_STA`；可直接指定模型名 | > | `aspect_ratio` | `9:16` | 默认 KLING：仅 9:16、1:1、16:9；换模见 [`../references/weryai-model-capabilities.md`](../references/weryai-model-capabilities.md) | > | `duration` | `5s` | KLING 系：5 / 10 / 15；切 VEO 快档：duration 仅 8 | > | `generate_audio` | `true` | 是否自动生成音频 | > | `prompt` | （本次构建的描述摘要） | 如需调整请告知 | > | `循环衔接` | 否 | 回复"循环"可开启 seamless loop | > > 直接回复**"确认"**开始生成，或告知需要修改的项目。 **示例 Prompt：** > Extreme close-up macro shot of quicksand nail art being manipulated, a nail's surface shimmers with ultrafine copper and gold sand particles that shift and flow in a liquid-like wave as the hand tilts slowly, microscopic metallic particles catch a focused spot light and scatter rainbow micro-glints, the sand settles into a new pattern with each movement, slow motion 120fps, ASMR dry sand whisper sound, black velvet background to maximize sparkle contrast, static locked-off macro lens --- ## ASMR 和质感关键词 **猫眼线条感**：`magnetic cat-eye line gathers precisely`, `aurora shimmer streak`, `luminous moving line under light`, `metalite particles align in perfect gradient` **封层镜面感**：`mirror topcoat reflects surroundings`, `glossy gel surface catches every light`, `depth illusion under clear gel encapsulation`, `3D dimensional effect preserved under dome coat` **涂胶 ASMR 声**：`ASMR gel brush stroke sound`, `UV lamp cure beep`, `nail file grit sound`, `cap click of gel bottle` > **提示**：美甲视频的光线设置非常关键。`soft diffused white backlight` 让甲面反光柔和，`focused spot light from above` 让猫眼线和闪粉更凸显。在 prompt 中明确光源角度，视觉爆点会更稳定。

path: scripts/video_gen.js
content: #!/usr/bin/env node
/**
 * WeryAI 视频生成 — 通用 CLI（零 npm 依赖，需 Node.js 18+）
 *
 * 命令:
 *   wait | submit-text | submit-image | submit-multi-image | status | models
 *
 * 示例:
 *   node video_gen.js models
 *   node video_gen.js models --mode text_to_video
 *   node video_gen.js wait --json '{"model":"WERYAI_VIDEO_1_0","prompt":"...","duration":5}'
 *   node video_gen.js wait --json '...' --dry-run
 *
 * 环境变量:
 *   WERYAI_API_KEY（models / 生成 / status 必填；--dry-run 除外）
 *   WERYAI_BASE_URL（默认 https://api.weryai.com）
 *   WERYAI_MODELS_BASE_URL（默认 https://api-growth-agent.weryai.com）
 *   WERYAI_POLL_INTERVAL_MS / WERYAI_POLL_TIMEOUT_MS
 */

const DEFAULT_MODEL = 'WERYAI_VIDEO_1_0';
const BASE_URL = (process.env.WERYAI_BASE_URL || 'https://api.weryai.com').replace(/\/$/, '');
const MODELS_BASE_URL = (process.env.WERYAI_MODELS_BASE_URL || 'https://api-growth-agent.weryai.com').replace(
  /\/$/,
  '',
);
const MODELS_API_PATH = '/growthai/v1/video/models';
const POLL_INTERVAL_MS = Number(process.env.WERYAI_POLL_INTERVAL_MS || 6000);
const POLL_TIMEOUT_MS = Number(process.env.WERYAI_POLL_TIMEOUT_MS || 600000);

const STATUS_MAP = {
  waiting: 'waiting',
  WAITING: 'waiting',
  pending: 'waiting',
  PENDING: 'waiting',
  processing: 'processing',
  PROCESSING: 'processing',
  succeed: 'completed',
  SUCCEED: 'completed',
  success: 'completed',
  SUCCESS: 'completed',
  failed: 'failed',
  FAILED: 'failed',
};

const ERROR_MESSAGES = {
  400: 'Bad request — check your request parameters.',
  403: 'Invalid API key or IP access denied — verify WERYAI_API_KEY is correct.',
  500: 'WeryAI server error. Please try again later.',
  1001: 'Request rate limit exceeded — slow down and retry after a moment.',
  1002: 'Parameter error — check prompt, image URLs, aspect_ratio, model key, and duration.',
  1003: 'Resource not found — the task_id may not exist or has expired.',
  1006: 'Model not supported — check the model key or try a different model.',
  1007: 'Queue full — the service is busy, please try again later.',
  1011: 'Insufficient credits — recharge at weryai.com.',
  2003: 'Content flagged by safety system — revise your prompt or input image.',
  2004: 'Image format not supported — use jpg, png, or webp.',
  6004: 'Generation failed — please try again later.',
  6010: 'Concurrent task limit reached (max 20) — wait for existing tasks to complete.',
};

function log(msg) {
  process.stderr.write(`[weryai] ${msg}\n`);
}

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

async function httpJson(method, fullUrl, body, apiKey) {
  const headers = {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  };
  const controller = new AbortController();
  const timer = setTimeout(() => controller.abort(), 30_000);
  try {
    const res = await fetch(fullUrl, {
      method,
      headers,
      body: body != null ? JSON.stringify(body) : undefined,
      signal: controller.signal,
    });
    clearTimeout(timer);
    let data;
    try {
      data = await res.json();
    } catch {
      data = { status: res.status, msg: `Non-JSON response (HTTP ${res.status})` };
    }
    return { httpStatus: res.status, ...data };
  } catch (err) {
    clearTimeout(timer);
    if (err.name === 'AbortError') {
      throw new Error(`Request timeout: ${method} ${fullUrl}`);
    }
    throw err;
  }
}

async function apiRequest(method, path, body, apiKey) {
  return httpJson(method, BASE_URL + path, body, apiKey);
}

async function fetchModelsRegistry(apiKey) {
  return httpJson('GET', MODELS_BASE_URL + MODELS_API_PATH, null, apiKey);
}

function isApiSuccess(res) {
  const httpOk = res.httpStatus >= 200 && res.httpStatus < 300;
  const bodyOk = res.status === 0 || res.status === 200;
  return httpOk && bodyOk;
}

function formatApiError(res) {
  const httpStatus = res.httpStatus || 0;
  const code = res.status;
  const msg = res.msg || '';

  if (httpStatus === 403) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '403',
      errorMessage: ERROR_MESSAGES[403] + (msg ? ` (${msg})` : ''),
    };
  }
  if (httpStatus === 429) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'RATE_LIMIT',
      errorMessage: 'Rate limited by WeryAI API. Please wait and try again.',
    };
  }
  if (httpStatus >= 500) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '500',
      errorMessage: `${ERROR_MESSAGES[500]} (HTTP ${httpStatus})`,
    };
  }
  if (httpStatus === 400) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: '400',
      errorMessage: ERROR_MESSAGES[400] + (msg ? ` (${msg})` : ''),
    };
  }

  const friendly = ERROR_MESSAGES[code] || '';
  const message =
    friendly && msg
      ? `${friendly} (${msg})`
      : friendly || msg || `API error (status: ${code}, HTTP ${httpStatus})`;

  return {
    ok: false,
    phase: 'failed',
    errorCode: code != null ? String(code) : null,
    errorMessage: message,
  };
}

function detectMode(params) {
  if (Array.isArray(params.images) && params.images.length > 0) return 'multi_image';
  if (typeof params.image === 'string' && params.image) return 'image';
  return 'text';
}

function coerceBool(v, defaultVal = false) {
  if (v == null) return defaultVal;
  if (typeof v === 'boolean') return v;
  return String(v).toLowerCase() === 'true';
}

function buildRequestBody(params) {
  const model = params.model || DEFAULT_MODEL;
  const body = {
    prompt: params.prompt,
    model,
    duration: Number(params.duration) || 5,
  };
  if (params.aspect_ratio) body.aspect_ratio = params.aspect_ratio;
  if (params.resolution) body.resolution = params.resolution;
  if (params.generate_audio != null) body.generate_audio = coerceBool(params.generate_audio, false);
  if (params.negative_prompt) body.negative_prompt = params.negative_prompt;
  if (params.image) body.image = params.image;
  if (params.images) body.images = params.images.slice(0, 3);
  return body;
}

async function submitTask(params, apiKey) {
  const mode = detectMode(params);
  const pathMap = {
    text: '/v1/generation/text-to-video',
    image: '/v1/generation/image-to-video',
    multi_image: '/v1/generation/multi-image-to-video',
  };
  const body = buildRequestBody(params);
  let res;
  try {
    res = await apiRequest('POST', pathMap[mode], body, apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const data = res.data || {};
  const taskIds = data.task_ids ?? (data.task_id ? [data.task_id] : []);
  return {
    ok: true,
    phase: 'submitted',
    batchId: data.batch_id ?? null,
    taskIds,
    taskId: taskIds[0] ?? null,
    taskStatus: null,
    videos: null,
    errorCode: null,
    errorMessage: null,
  };
}

function extractVideos(taskData) {
  const tr = taskData.task_result || {};
  const raw = tr.videos || taskData.videos || [];
  return raw.map((v) => ({
    url: v.url || '',
    cover_url: v.cover_image_url || '',
  }));
}

async function pollUntilDone(taskId, batchId, taskIds, apiKey) {
  const start = Date.now();
  while (true) {
    const elapsed = (Date.now() - start) / 1000;
    if (elapsed * 1000 >= POLL_TIMEOUT_MS) {
      return {
        ok: false,
        phase: 'failed',
        errorCode: 'TIMEOUT',
        errorMessage: `Poll timeout after ${Math.floor(elapsed)}s.`,
        batchId,
        taskIds,
        taskId,
        taskStatus: 'unknown',
        videos: null,
      };
    }

    await sleep(POLL_INTERVAL_MS);

    let res;
    try {
      res = await apiRequest('GET', `/v1/generation/${taskId}/status`, null, apiKey);
    } catch (err) {
      log(`Warning: poll failed (${err.message}), retrying...`);
      continue;
    }

    if (!isApiSuccess(res)) {
      log('Warning: poll returned non-success status, retrying...');
      continue;
    }

    const taskData = res.data || {};
    const rawStatus = taskData.task_status || '';
    const normalized = STATUS_MAP[rawStatus] || 'unknown';
    const elapsedSec = Math.floor((Date.now() - start) / 1000);
    log(`Polling ${taskId}... status: ${rawStatus} (${elapsedSec}s elapsed)`);

    if (normalized === 'completed') {
      const videos = extractVideos(taskData);
      return {
        ok: true,
        phase: 'completed',
        batchId,
        taskIds,
        taskId,
        taskStatus: rawStatus,
        videos: videos.length ? videos : null,
        errorCode: null,
        errorMessage: null,
      };
    }

    if (normalized === 'failed') {
      const tr = taskData.task_result || {};
      const errMsg = tr.message || taskData.msg || 'Task failed';
      return {
        ok: false,
        phase: 'failed',
        batchId,
        taskIds,
        taskId,
        taskStatus: rawStatus,
        videos: null,
        errorCode: 'TASK_FAILED',
        errorMessage: errMsg,
      };
    }
  }
}

async function cmdWait(params, apiKey) {
  const submitResult = await submitTask(params, apiKey);
  if (!submitResult.ok) return submitResult;
  const taskId = submitResult.taskId;
  const batchId = submitResult.batchId;
  const taskIds = submitResult.taskIds || [taskId];
  log(`Submitted task ${taskId}. Polling for completion...`);
  return pollUntilDone(taskId, batchId, taskIds, apiKey);
}

async function cmdStatus(taskId, apiKey) {
  let res;
  try {
    res = await apiRequest('GET', `/v1/generation/${taskId}/status`, null, apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const taskData = res.data || {};
  const rawStatus = taskData.task_status || '';
  const normalized = STATUS_MAP[rawStatus] || 'unknown';
  const videos = extractVideos(taskData);
  const phase =
    normalized === 'completed' ? 'completed' : normalized === 'failed' ? 'failed' : 'running';
  const tr = taskData.task_result || {};
  return {
    ok: phase !== 'failed',
    phase,
    batchId: null,
    taskIds: [taskId],
    taskId,
    taskStatus: rawStatus,
    videos: videos.length ? videos : null,
    errorCode: phase === 'failed' ? 'TASK_FAILED' : null,
    errorMessage: phase === 'failed' ? tr.message || taskData.msg || null : null,
  };
}

const VALID_MODEL_MODES = ['text_to_video', 'image_to_video', 'multi_image_to_video'];

async function cmdModels(modeFilter, apiKey) {
  let res;
  try {
    res = await fetchModelsRegistry(apiKey);
  } catch (err) {
    return {
      ok: false,
      phase: 'failed',
      errorCode: 'NETWORK_ERROR',
      errorMessage: err.message || String(err),
    };
  }

  if (!isApiSuccess(res)) return formatApiError(res);

  const data = res.data || {};
  const out = { ok: true, phase: 'completed' };

  if (modeFilter) {
    if (!VALID_MODEL_MODES.includes(modeFilter)) {
      return {
        ok: false,
        phase: 'failed',
        errorCode: 'VALIDATION',
        errorMessage: `Invalid --mode. Use: ${VALID_MODEL_MODES.join(', ')}`,
      };
    }
    out[modeFilter] = data[modeFilter] || [];
  } else {
    out.text_to_video = data.text_to_video || [];
    out.image_to_video = data.image_to_video || [];
    out.multi_image_to_video = data.multi_image_to_video || [];
  }

  return out;
}

function parseArgs(argv) {
  const cmd = argv[0];
  let jsonStr = null;
  let taskId = null;
  let dryRun = false;
  let modelsMode = null;
  for (let i = 1; i < argv.length; i++) {
    if (argv[i] === '--json') jsonStr = argv[++i];
    else if (argv[i] === '--task-id') taskId = argv[++i];
    else if (argv[i] === '--dry-run') dryRun = true;
    else if (argv[i] === '--mode') modelsMode = argv[++i];
  }
  return { command: cmd, jsonStr, taskId, dryRun, modelsMode };
}

function printJson(obj) {
  process.stdout.write(`${JSON.stringify(obj, null, 2)}\n`);
}

async function main() {
  const argv = process.argv.slice(2);
  const valid = new Set([
    'wait',
    'submit-text',
    'submit-image',
    'submit-multi-image',
    'status',
    'models',
  ]);
  const { command, jsonStr, taskId: cliTaskId, dryRun, modelsMode } = parseArgs(argv);

  if (!command || !valid.has(command)) {
    printJson({
      ok: false,
      phase: 'failed',
      errorCode: 'USAGE',
      errorMessage:
        'Usage: node video_gen.js <wait|submit-text|submit-image|submit-multi-image|status|models> [--json \'...\'] [--task-id id] [--dry-run] [--mode text_to_video|image_to_video|multi_image_to_video]',
    });
    process.exit(1);
  }

  let params = {};
  if (jsonStr) {
    try {
      params = JSON.parse(jsonStr);
    } catch (e) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'INVALID_JSON',
        errorMessage: `Invalid JSON: ${e.message}`,
      });
      process.exit(1);
    }
  }

  if (command === 'models') {
    const [REDACTED_SECRET] || '').trim();
    if (!apiKey) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'NO_API_KEY',
        errorMessage: 'Missing WERYAI_API_KEY environment variable.',
      });
      process.exit(1);
    }
    const result = await cmdModels(modelsMode, apiKey);
    printJson(result);
    process.exit(result.ok ? 0 : 1);
  }

  if (dryRun) {
    const mode = detectMode(params);
    const pathMap = {
      text: '/v1/generation/text-to-video',
      image: '/v1/generation/image-to-video',
      multi_image: '/v1/generation/multi-image-to-video',
    };
    const body = 'prompt' in params ? buildRequestBody(params) : {};
    printJson({
      ok: true,
      phase: 'dry-run',
      dryRun: true,
      requestBody: body,
      requestUrl: BASE_URL + (pathMap[mode] || pathMap.text),
    });
    process.exit(0);
  }

  const [REDACTED_SECRET] || '').trim();
  if (!apiKey) {
    printJson({
      ok: false,
      phase: 'failed',
      errorCode: 'NO_API_KEY',
      errorMessage: 'Missing WERYAI_API_KEY environment variable.',
    });
    process.exit(1);
  }

  let result;
  if (command === 'status') {
    const tid = cliTaskId || params.task_id || params.taskId;
    if (!tid) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: 'status command requires --task-id <id>',
      });
      process.exit(1);
    }
    result = await cmdStatus(tid, apiKey);
  } else {
    if (!params.prompt) {
      printJson({
        ok: false,
        phase: 'failed',
        errorCode: 'MISSING_PARAM',
        errorMessage: "'prompt' is required in JSON parameters.",
      });
      process.exit(1);
    }
    result = command === 'wait' ? await cmdWait(params, apiKey) : await submitTask(params, apiKey);
  }

  printJson(result);
  process.exit(result.ok ? 0 : 1);
}

main().catch((err) => {
  printJson({
    ok: false,
    phase: 'failed',
    errorCode: 'FATAL',
    errorMessage: err.message || String(err),
  });
  process.exit(1);
});


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