Skill: newt0n/creatok-generate-video (version 0.1.6)

--- name: creatok-generate-video version: "1.0.0" description: Use when generating, resuming, or checking TikTok videos, ads, or selling videos. license: Open Source compatibility: "Claude Code ≥1.0, OpenClaw skills, ClawHub-compatible installers. Requires network access to CreatOK Open Skills API. No local video rendering packages required." metadata: openclaw: requires: env: [] bins: - node primaryEnv: CREATOK_API_KEY author: creatok version: "1.0.0" geo-relevance: "low" tags: - tiktok - generate-video - ai-video - tiktok-ad - selling-video - sora - sora-2 - creator-workflow - seller-workflow triggers: - "generate a TikTok video" - "create a TikTok ad" - "turn this script into a video" - "create a selling video" - "use sora 2" - "start video generation" - "start this generation" - "check this video generation task" - "check this task id" - "TikTok動画を作って" - "TikTok広告を作って" - "この台本を動画にして" - "動画生成を開始して" - "틱톡 영상 만들어줘" - "틱톡 광고 만들어줘" - "이 대본을 영상으로 만들어줘" - "영상 생성을 시작해줘" - "genera un video de TikTok" - "crea un anuncio de TikTok" - "convierte este guion en un video" - "inicia la generación de video" - "gere um vídeo para TikTok" - "crie um anúncio para TikTok" - "transforme este roteiro em vídeo" - "inicie a geração de vídeo" --- # generate-video ## Constraints - Platform: **TikTok only**. - The model's final user-facing response should match the user's input language, default **English**. - Must request **user confirmation** before triggering any paid/high-cost video generation call. - After confirmed, must call **CreatOK Open Skills proxy** and wait until completion. - Avoid technical wording in the user-facing reply unless the user explicitly needs details for debugging or to share with a developer. - Follow shared guidance in `./references/common-rules.md`. - Unless the user explicitly asks for a live-action shoot version, the model should assume the goal is to generate an AI video, not to prepare a human filming plan. ## Model Selection Rules The skill must read model list, defaults, and hard limits from the CreatOK capabilities endpoint at runtime instead of hardcoding them locally. Keep the local client thin: - use the returned default model unless the user explicitly chooses another supported model - validate only hard execution constraints such as definition, duration, orientation, and reference-image cap - keep user-facing model explanation minimal and avoid depending on provider, family, or hint-style metadata If a chosen plan conflicts with model limits, the model should explain the limitation, suggest a workable plan, and wait for user confirmation before generating. Model list, defaults, and hard limits such as supported definitions, durations, orientations, and reference image caps must be read from the CreatOK capabilities endpoint at runtime instead of being hardcoded in this skill. Reference images are supported by uploading local image files first, then passing the uploaded reference to the video task. ## Multi-Segment Rules - If the requested video is longer than the chosen model's maximum duration, the model should recommend splitting it into multiple segments. - If the final video must be stitched from multiple generated clips, the model should explain that the user will need to assemble the clips afterward. ## Inputs to clarify (ask if missing) - ask only for what is still necessary to generate a good video - prefer the direction, script, and selling points already established earlier in the conversation - if details are missing, ask one or two short follow-up questions instead of requesting a full brief again - prefer details that help AI generation directly, such as scene intent, visual style, pacing, product emphasis, and whether the user wants `Sora 2` or a Veo model - when naming a model in tool calls or handoff instructions, use the exact official model id instead of aliases ## Workflow 1) **Confirmation gate** (mandatory) - Summarize: - model - orientation - definition and seconds, if relevant to the chosen model - whether reference images are used - whether the plan is single-shot or multi-segment - any important limitation such as duration cap, portrait requirement, or manual stitching afterward - estimated cost/credits if available - Ask for a simple confirmation in plain language, such as whether the user wants to start generation now. - Do **not** submit the generation task until user says yes. 2) Submit video generation - Call CreatOK: `POST /api/open/skills/tasks` 3) Poll status until completion - Call CreatOK: `GET /api/open/skills/tasks/status?task_id=...` 4) Persist artifacts + respond - Write: - `outputs/result.json` with `task_id/status/video_url/raw` - `outputs/result.md` - Persist the `task_id` immediately after submission, before waiting for the final status, so the user can recover the task later if the local process is interrupted. - Return the final `video_url` verbatim. - After the AI version is complete, the model may optionally ask whether the user also wants a live-action shoot version of the same idea. ## Existing Task Recovery - If the user already has a `task_id`, this skill should continue from that task instead of starting a new one. - In recovery mode, do not ask the user to restate the prompt or creative brief if the task id is already available. - The model can either: - check the current task status once - or continue waiting and polling if the user wants to keep checking - If the task succeeded, return the final `video_url` verbatim. - If the task is still queued or running, explain that clearly and offer to keep checking. - If the task failed, explain the failure message if available and suggest the next best step. ## Artifacts All artifacts under `generate-video/.artifacts/<run_id>/...`. ## Thin Client Boundary - Prefer using a prompt or brief that already came from `creatok-analyze-video` or `creatok-recreate-video`. - If the creative direction is still fuzzy, the model can tighten it in the conversation before generating. - This skill submits generation jobs, polls status, and persists fixed-format outputs. - The model should not make the user restate their idea from scratch if the previous conversation already made the direction clear. - The model should optimize the brief for AI video generation by default, not for on-set filming. - Natural user confirmations such as "generate it", "make the video", "go ahead", or "use this version" should be treated as intent to use this skill, as long as the cost confirmation step is still satisfied. ## Handoff - When reached from `creatok-analyze-video`, the model should carry forward the chosen direction without making the user repeat it. - When reached from `creatok-recreate-video`, the model should use the script or brief already developed in the conversation as the starting point for generation. - If generation was interrupted after submission, the model should help the user continue inside `creatok-generate-video` with the existing `task_id` instead of restarting the job from scratch.

path: lib/artifacts.js
content: const fs = require('node:fs');
const path = require('node:path');

class Artifacts {
  constructor(root) {
    this.root = root;
  }

  ensure() {
    for (const name of ['input', 'transcript', 'vision', 'outputs', 'logs']) {
      fs.mkdirSync(path.join(this.root, name), { recursive: true });
    }
  }

  writeJson(relPath, obj) {
    const filePath = path.join(this.root, relPath);
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, `${JSON.stringify(obj, null, 2)}\n`, 'utf8');
    return filePath;
  }

  writeText(relPath, text) {
    const filePath = path.join(this.root, relPath);
    fs.mkdirSync(path.dirname(filePath), { recursive: true });
    fs.writeFileSync(filePath, text, 'utf8');
    return filePath;
  }
}

function artifactsRootForSkill(skillDir) {
  return path.join(skillDir, '.artifacts');
}

function artifactsForRun(skillDir, runId) {
  return new Artifacts(path.join(artifactsRootForSkill(skillDir), runId));
}

module.exports = {
  Artifacts,
  artifactsRootForSkill,
  artifactsForRun,
};


path: lib/capabilities.js
content: function invalidContract(message) {
  return new Error(`Invalid capabilities contract: ${message}`);
}

function isPlainObject(value) {
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}

function isNonEmptyString(value) {
  return typeof value === 'string' && value.trim().length > 0;
}

function assertStringArray(value, field) {
  if (!Array.isArray(value) || value.length === 0 || value.some((item) => !isNonEmptyString(item))) {
    throw invalidContract(`${field} must be a non-empty array of strings`);
  }
}

function normalizePricing(pricing, field) {
  if (!isPlainObject(pricing)) {
    return null;
  }
  if (pricing.currency !== 'credits') {
    throw invalidContract(`${field}.currency must be 'credits'`);
  }
  if (pricing.unit !== 'video') {
    throw invalidContract(`${field}.unit must be 'video'`);
  }
  if (!Array.isArray(pricing.estimates)) {
    throw invalidContract(`${field}.estimates must be an array`);
  }
  return {
    currency: 'credits',
    unit: 'video',
    estimates: pricing.estimates.map((item, index) => {
      if (!isPlainObject(item)) {
        throw invalidContract(`${field}.estimates[${index}] must be an object`);
      }
      if (!isNonEmptyString(item.definition)) {
        throw invalidContract(`${field}.estimates[${index}].definition must be a non-empty string`);
      }
      if (typeof item.seconds !== 'number' || Number.isNaN(item.seconds)) {
        throw invalidContract(`${field}.estimates[${index}].seconds must be a number`);
      }
      if (!isNonEmptyString(item.orientation)) {
        throw invalidContract(`${field}.estimates[${index}].orientation must be a non-empty string`);
      }
      if (typeof item.credits !== 'number' || Number.isNaN(item.credits)) {
        throw invalidContract(`${field}.estimates[${index}].credits must be a number`);
      }
      return {
        definition: item.definition,
        seconds: Number(item.seconds),
        orientation: item.orientation,
        credits: Number(item.credits),
      };
    }),
  };
}

function assertNullableNumber(value, field) {
  if (value !== null && (typeof value !== 'number' || Number.isNaN(value))) {
    throw invalidContract(`${field} must be a number or null`);
  }
}

function normalizeDurations(durations, field) {
  if (!isPlainObject(durations)) {
    throw invalidContract(`${field} must be an object`);
  }

  assertNullableNumber(durations.min, `${field}.min`);
  assertNullableNumber(durations.max, `${field}.max`);

  if (durations.enum !== null) {
    if (
      !Array.isArray(durations.enum) ||
      durations.enum.length === 0 ||
      durations.enum.some((item) => typeof item !== 'number' || Number.isNaN(item))
    ) {
      throw invalidContract(`${field}.enum must be null or a non-empty array of numbers`);
    }
  }

  if (durations.enum === null && durations.min === null && durations.max === null) {
    throw invalidContract(`${field} must define enum or min/max bounds`);
  }

  return {
    min: durations.min === null ? null : Number(durations.min),
    max: durations.max === null ? null : Number(durations.max),
    enum: durations.enum === null ? null : [...durations.enum].map(Number),
  };
}

function normalizeVideoModel(model, field) {
  if (!isPlainObject(model)) {
    throw invalidContract(`${field} must be an object`);
  }
  if (!isNonEmptyString(model.id)) {
    throw invalidContract(`${field}.id must be a non-empty string`);
  }
  if (!isNonEmptyString(model.label)) {
    throw invalidContract(`${field}.label must be a non-empty string`);
  }
  if (!isPlainObject(model.defaults)) {
    throw invalidContract(`${field}.defaults must be an object`);
  }
  if (!isPlainObject(model.limits)) {
    throw invalidContract(`${field}.limits must be an object`);
  }
  if (!isNonEmptyString(model.defaults.orientation)) {
    throw invalidContract(`${field}.defaults.orientation must be a non-empty string`);
  }
  if (typeof model.defaults.seconds !== 'number' || Number.isNaN(model.defaults.seconds)) {
    throw invalidContract(`${field}.defaults.seconds must be a number`);
  }
  if (!isNonEmptyString(model.defaults.definition)) {
    throw invalidContract(`${field}.defaults.definition must be a non-empty string`);
  }
  assertStringArray(model.limits.definitions, `${field}.limits.definitions`);
  assertStringArray(model.limits.orientations, `${field}.limits.orientations`);
  if (typeof model.limits.max_reference_images !== 'number' || Number.isNaN(model.limits.max_reference_images)) {
    throw invalidContract(`${field}.limits.max_reference_images must be a number`);
  }

  return {
    id: model.id,
    label: model.label,
    defaults: {
      orientation: model.defaults.orientation,
      seconds: Number(model.defaults.seconds),
      definition: model.defaults.definition,
    },
    limits: {
      definitions: [...model.limits.definitions],
      orientations: [...model.limits.orientations],
      durations: normalizeDurations(model.limits.durations, `${field}.limits.durations`),
      maxReferenceImages: Number(model.limits.max_reference_images),
    },
    pricing: normalizePricing(model.pricing, `${field}.pricing`),
  };
}

function normalizeVideoCapabilities(payload) {
  const field = 'capabilities.video';
  if (!isPlainObject(payload)) {
    throw invalidContract(`${field} must be an object`);
  }
  if (!isNonEmptyString(payload.default_model)) {
    throw invalidContract(`${field}.default_model must be a non-empty string`);
  }
  if (!Array.isArray(payload.models)) {
    throw invalidContract(`${field}.models must be an array`);
  }

  const models = payload.models.map((model, index) => normalizeVideoModel(model, `${field}.models[${index}]`));
  if (models.length === 0) {
    throw invalidContract(`${field}.models must include at least one model`);
  }

  if (!models.some((model) => model.id === payload.default_model)) {
    throw invalidContract(`${field}.default_model must reference a model`);
  }

  return {
    defaultModel: payload.default_model,
    models,
  };
}

function buildUnsupportedVideoModelError(videoCapabilities, modelId) {
  return new Error(
    `Unsupported model: ${modelId}. Supported: ${videoCapabilities.models.map((model) => model.id).join(', ')}`,
  );
}

function summarizeModelSelection(model, { estimatedCredits = null } = {}) {
  const pricing = estimatedCredits == null ? '' : ` Estimated cost: ${estimatedCredits} credits.`;
  return `${model.label}.${pricing}`;
}

async function fetchVideoCapabilities({ client = null, timeoutSec = 60, defaultClient = null } = {}) {
  const effectiveClient = client || (typeof defaultClient === 'function' ? defaultClient() : null);
  if (!effectiveClient || typeof effectiveClient.getCapabilities !== 'function') {
    throw new Error('Missing capabilities client. Expected getCapabilities().');
  }

  const payload = await effectiveClient.getCapabilities(timeoutSec);
  if (!isPlainObject(payload)) {
    throw invalidContract('payload must be an object');
  }
  if (!isPlainObject(payload.capabilities)) {
    throw invalidContract('capabilities must be an object');
  }

  return normalizeVideoCapabilities(payload.capabilities.video);
}

module.exports = {
  buildUnsupportedVideoModelError,
  fetchVideoCapabilities,
  summarizeModelSelection,
};


path: lib/config.js
content: function getCreatokConfig() {
  const [REDACTED_SECRET];

  if (!apiKey) {
    throw new Error('Missing CREATOK_API_KEY. Set env CREATOK_API_KEY before running this skill.');
  }

  return {
    baseUrl: 'https://www.creatok.ai',
    openSkillsKey: String(apiKey),
  };
}

module.exports = {
  getCreatokConfig,
};


path: lib/creatok-capabilities-client.js
content: const { getCreatokConfig } = require('./config');

function summarizeHttpError(status, url) {
  const pathname = (() => {
    try {
      return new URL(url).pathname;
    } catch {
      return url;
    }
  })();
  if (status === 401) {
    return `Unauthorized calling ${pathname}. Check CREATOK_API_KEY.`;
  }
  if (status === 404) {
    return `Endpoint not found at ${pathname}. Check the CreatOK base URL and deployment.`;
  }
  if (status >= 500) {
    return `CreatOK server error calling ${pathname}. Try again later.`;
  }
  return `HTTP ${status} calling ${pathname}.`;
}

class CreatokCapabilitiesClient {
  constructor(cfg) {
    this.cfg = cfg;
  }

  async requestJson(method, url, { timeoutSec = 60 } = {}) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutSec) * 1000);

    try {
      const response = await fetch(url, {
        method,
        headers: {
          Authorization: `Bearer ${this.cfg.openSkillsKey}`,
          Accept: 'application/json',
        },
        signal: controller.signal,
      });

      const text = await response.text();
      let payload;
      try {
        payload = JSON.parse(text);
      } catch {
        throw new Error(`Invalid response from CreatOK endpoint ${url}.`);
      }

      if (!response.ok) {
        throw new Error(summarizeHttpError(response.status, url));
      }

      return payload;
    } finally {
      clearTimeout(timeout);
    }
  }

  async getCapabilities(timeoutSec = 60) {
    const payload = await this.requestJson('GET', `${this.cfg.baseUrl}/api/open/skills/capabilities`, {
      timeoutSec,
    });
    if (payload.code !== 0) {
      throw new Error(`CreatOK capabilities failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }
}

function defaultCapabilitiesClient() {
  return new CreatokCapabilitiesClient(getCreatokConfig());
}

module.exports = {
  CreatokCapabilitiesClient,
  defaultCapabilitiesClient,
};


path: lib/creatok-client.js
content: const fs = require('node:fs/promises');
const path = require('node:path');
const { getCreatokConfig } = require('./config');

function summarizeHttpError(status, url) {
  const pathname = (() => {
    try {
      return new URL(url).pathname;
    } catch {
      return url;
    }
  })();
  if (status === 401) {
    return `Unauthorized calling ${pathname}. Check CREATOK_API_KEY.`;
  }
  if (status === 404) {
    return `Endpoint not found at ${pathname}. Check the CreatOK base URL and deployment.`;
  }
  if (status >= 500) {
    return `CreatOK server error calling ${pathname}. Try again later.`;
  }
  return `HTTP ${status} calling ${pathname}.`;
}

function contentTypeForFile(filePath) {
  const ext = path.extname(filePath).toLowerCase();
  const map = {
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.png': 'image/png',
    '.webp': 'image/webp',
    '.gif': 'image/gif',
  };
  const type = map[ext];
  if (!type) {
    throw new Error(`Unsupported image file type for upload: ${filePath}`);
  }
  return type;
}

class CreatokOpenSkillsClient {
  constructor(cfg) {
    this.cfg = cfg;
  }

  async requestJson(method, url, { body, timeoutSec = 60 } = {}) {
    const controller = new AbortController();
    const timeout = setTimeout(() => controller.abort(), Math.max(1, timeoutSec) * 1000);

    try {
      const response = await fetch(url, {
        method,
        headers: {
          Authorization: `Bearer ${this.cfg.openSkillsKey}`,
          Accept: 'application/json',
          ...(body ? { 'Content-Type': 'application/json' } : {}),
        },
        body: body ? JSON.stringify(body) : undefined,
        signal: controller.signal,
      });

      const text = await response.text();
      let payload;
      try {
        payload = JSON.parse(text);
      } catch (error) {
        throw new Error(`Invalid response from CreatOK endpoint ${url}.`);
      }

      if (!response.ok) {
        throw new Error(summarizeHttpError(response.status, url));
      }

      return payload;
    } finally {
      clearTimeout(timeout);
    }
  }

  async analyze(tiktokUrl, timeoutSec = 180) {
    const payload = await this.requestJson('POST', `${this.cfg.baseUrl}/api/open/skills/analyze`, {
      body: { tiktok_url: tiktokUrl },
      timeoutSec,
    });
    if (payload.code !== 0) {
      throw new Error(`CreatOK analyze failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }

  async submitTask({ prompt, orientation, seconds, definition, model, referenceImageKeys }) {
    const body = { prompt, orientation, seconds, definition, model };
    if (referenceImageKeys && referenceImageKeys.length > 0) body.referenceImageKeys = referenceImageKeys;

    const payload = await this.requestJson('POST', `${this.cfg.baseUrl}/api/open/skills/tasks`, {
      body,
      timeoutSec: 60,
    });
    if (payload.code !== 0) {
      throw new Error(`CreatOK task submission failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }

  async createImageUpload({ fileName, fileType, fileSize, prefix, timeoutSec = 60 }) {
    const payload = await this.requestJson('POST', `${this.cfg.baseUrl}/api/open/skills/upload/image/presigned`, {
      body: { fileName, fileType, fileSize, prefix },
      timeoutSec,
    });
    if (payload.code !== 0) {
      throw new Error(`CreatOK image upload init failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }

  async uploadImageFile(filePath, { prefix = 'open-skills/reference-images', timeoutSec = 60 } = {}) {
    const fileName = path.basename(filePath);
    const fileType = contentTypeForFile(filePath);
    const file = await fs.readFile(filePath);
    const upload = await this.createImageUpload({
      fileName,
      fileType,
      fileSize: file.byteLength,
      prefix,
      timeoutSec,
    });
    if (!upload.presignedUploadUrl || !upload.objectKey) {
      throw new Error(`Invalid upload init response: ${JSON.stringify(upload)}`);
    }

    const response = await fetch(upload.presignedUploadUrl, {
      method: 'PUT',
      headers: { 'Content-Type': fileType },
      body: file,
    });
    if (!response.ok) {
      throw new Error(`Image upload failed with HTTP ${response.status}.`);
    }
    return upload.objectKey;
  }

  async getTaskStatus(taskId) {
    const payload = await this.requestJson(
      'GET',
      `${this.cfg.baseUrl}/api/open/skills/tasks/status?task_id=${encodeURIComponent(taskId)}&task_type=video_generation`,
      { timeoutSec: 60 },
    );
    if (payload.code !== 0) {
      throw new Error(`CreatOK task status failed: ${JSON.stringify(payload)}`);
    }
    return payload.data || {};
  }
}

function defaultClient() {
  return new CreatokOpenSkillsClient(getCreatokConfig());
}

module.exports = {
  CreatokOpenSkillsClient,
  defaultClient,
};


path: lib/generate-video.js
content: const { artifactsForRun } = require('./artifacts');
const { defaultClient } = require('./creatok-client');
const { buildUnsupportedVideoModelError, fetchVideoCapabilities, summarizeModelSelection } = require('./capabilities');
const { defaultCapabilitiesClient } = require('./creatok-capabilities-client');

function capabilitiesOptions(client, timeoutSec) {
  return {
    client: typeof client.getCapabilities === 'function' ? client : null,
    defaultClient: defaultCapabilitiesClient,
    timeoutSec,
  };
}

function parseDurationSeconds(seconds) {
  const parsed = Number(seconds);
  if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
    throw new Error('seconds must be a finite integer');
  }
  return parsed;
}

function validateDurations(model, durations, seconds) {
  if (durations.enum && !durations.enum.includes(seconds)) {
    throw new Error(`Model ${model} requires duration ${durations.enum.join(' or ')}s.`);
  }
  if (durations.min != null && durations.max != null && (seconds < durations.min || seconds > durations.max)) {
    throw new Error(`Model ${model} requires duration between ${durations.min}s and ${durations.max}s.`);
  }
  if (durations.min != null && durations.max == null && seconds < durations.min) {
    throw new Error(`Model ${model} requires duration at least ${durations.min}s.`);
  }
  if (durations.max != null && durations.min == null && seconds > durations.max) {
    throw new Error(`Model ${model} supports max duration ${durations.max}s.`);
  }
}

async function resolveGenerateVideoRequest({
  orientation = null,
  model = null,
  seconds = null,
  definition = null,
  referenceImages = [],
  timeoutSec = 600,
  client = defaultClient(),
}) {
  const videoCapabilities = await fetchVideoCapabilities(capabilitiesOptions(client, timeoutSec));
  const finalModel = model || videoCapabilities.defaultModel;
  const modelCapability = videoCapabilities.models.find((item) => item.id === finalModel);

  if (!modelCapability) {
    throw buildUnsupportedVideoModelError(videoCapabilities, finalModel);
  }

  const finalOrientation = orientation || modelCapability.defaults.orientation;
  const finalSeconds = seconds == null ? modelCapability.defaults.seconds : parseDurationSeconds(seconds);
  const finalDefinition = definition || modelCapability.defaults.definition;

  if (!modelCapability.limits.definitions.includes(finalDefinition)) {
    throw new Error(
      `Model ${finalModel} does not support definition ${finalDefinition}. Allowed: ${modelCapability.limits.definitions.join(', ')}`,
    );
  }
  validateDurations(finalModel, modelCapability.limits.durations, finalSeconds);
  if (!modelCapability.limits.orientations.includes(finalOrientation)) {
    throw new Error(
      `Model ${finalModel} does not support orientation ${finalOrientation}. Allowed: ${modelCapability.limits.orientations.join(', ')}`,
    );
  }
  if (referenceImages.length > modelCapability.limits.maxReferenceImages) {
    throw new Error(
      `Model ${finalModel} supports at most ${modelCapability.limits.maxReferenceImages} reference image${modelCapability.limits.maxReferenceImages > 1 ? 's' : ''}.`,
    );
  }

  const estimate =
    modelCapability.pricing &&
    modelCapability.pricing.estimates.find(
      (item) =>
        item.definition === finalDefinition &&
        item.seconds === finalSeconds &&
        item.orientation === finalOrientation,
    );
  const estimatedCredits = estimate ? estimate.credits : null;

  return {
    estimatedCredits,
    selectedModelSummary: summarizeModelSelection(modelCapability, { estimatedCredits }),
    selectionReason: model ? 'Model explicitly provided by caller.' : 'Using backend default model.',
    orientation: finalOrientation,
    model: finalModel,
    seconds: finalSeconds,
    definition: finalDefinition,
  };
}

async function uploadReferenceImages(client, referenceImages, timeoutSec) {
  if (!referenceImages || referenceImages.length === 0) return [];
  const uploadedKeys = [];
  for (const filePath of referenceImages) {
    const key = await client.uploadImageFile(filePath, {
      prefix: 'open-skills/reference-images',
      timeoutSec,
    });
    uploadedKeys.push(key);
  }
  return uploadedKeys;
}

function buildGenerateResult({
  runId,
  taskId,
  status,
  model = null,
  orientation = null,
  seconds = null,
  definition = null,
  videoUrl = null,
  raw = null,
  error = null,
}) {
  return {
    run_id: runId,
    task_id: taskId ? String(taskId) : null,
    status,
    model,
    orientation,
    seconds,
    definition,
    video_url: videoUrl,
    raw,
    error,
  };
}

function persistGenerateArtifacts(artifacts, result, title = 'Video Generate Result') {
  artifacts.writeJson('outputs/result.json', result);
  artifacts.writeText(
    'outputs/result.md',
    [
      `# ${title}`,
      '',
      `- run_id: \`${result.run_id}\``,
      `- model: \`${result.model || '(unknown)'}\``,
      `- orientation: \`${result.orientation || '(unknown)'}\``,
      `- seconds: \`${result.seconds || '(unknown)'}\``,
      `- definition: \`${result.definition || '(unknown)'}\``,
      `- status: \`${result.status}\``,
      `- task_id: \`${result.task_id || '(missing)'}\``,
      `- video_url: ${result.video_url || '(missing)'}`,
      `- error: ${result.error && result.error.message ? result.error.message : '(none)'}`,
      '',
    ].join('\n'),
  );
}

async function pollGenerate(client, taskId, pollInterval = 3, timeoutSec = 600) {
  const startedAt = Date.now();
  let lastStatus = null;

  while (true) {
    if ((Date.now() - startedAt) / 1000 > timeoutSec) {
      throw new Error(`Timeout waiting for task ${taskId}`);
    }

    const statusPayload = await client.getTaskStatus(taskId);
    const status = String(statusPayload.status || '');
    if (status !== lastStatus) {
      console.log(JSON.stringify({ task_id: taskId, status }));
      lastStatus = status;
    }

    if (status === 'succeeded' || status === 'failed') {
      return statusPayload;
    }

    await new Promise((resolve) => setTimeout(resolve, pollInterval * 1000));
  }
}

async function runGenerateVideoStatus({
  taskId,
  runId,
  skillDir,
  model = null,
  wait = false,
  pollInterval = 3,
  timeoutSec = 600,
  client = defaultClient(),
}) {
  const artifacts = artifactsForRun(skillDir, runId);
  artifacts.ensure();

  const raw = wait
    ? await pollGenerate(client, String(taskId), pollInterval, timeoutSec)
    : await client.getTaskStatus(String(taskId));

  const status = String(raw.status || '');
  const videoUrl = raw.result && typeof raw.result === 'object' ? raw.result.video_url || null : null;
  const error =
    raw.error && typeof raw.error === 'object'
      ? { message: raw.error.message || String(raw.error) }
      : null;

  const result = buildGenerateResult({
    runId,
    taskId: String(taskId),
    status,
    model,
    videoUrl,
    raw,
    error,
  });

  persistGenerateArtifacts(artifacts, result, 'Task Status');

  return {
    runId,
    artifactsDir: artifacts.root,
    taskId: String(taskId),
    status,
    videoUrl,
    raw,
    error,
  };
}

async function runGenerateVideo({
  prompt,
  runId,
  skillDir,
  orientation = null,
  model = null,
  seconds = null,
  definition = null,
  referenceImages = [],
  pollInterval = 3,
  timeoutSec = 600,
  client = defaultClient(),
}) {
  const resolved = await resolveGenerateVideoRequest({
    orientation,
    seconds,
    definition,
    model,
    referenceImages,
    timeoutSec,
    client,
  });
  const referenceImageKeys = await uploadReferenceImages(client, referenceImages, timeoutSec);

  const artifacts = artifactsForRun(skillDir, runId);
  artifacts.ensure();

  const submit = await client.submitTask({
    prompt,
    orientation: resolved.orientation,
    seconds: resolved.seconds,
    definition: resolved.definition,
    model: resolved.model,
    ...(referenceImageKeys.length > 0 ? { referenceImageKeys } : {}),
  });
  const taskId = submit.task_id;
  if (!taskId) {
    throw new Error(`Missing task_id: ${JSON.stringify(submit)}`);
  }

  const initial = buildGenerateResult({
    runId,
    taskId: String(taskId),
    status: String(submit.status || 'submitted'),
    model: resolved.model,
    orientation: resolved.orientation,
    seconds: resolved.seconds,
    definition: resolved.definition,
    raw: { submit },
  });
  persistGenerateArtifacts(artifacts, initial);

  try {
    const raw = await pollGenerate(client, String(taskId), pollInterval, timeoutSec);
    const status = String(raw.status || '');
    const videoUrl =
      raw.result && typeof raw.result === 'object' ? raw.result.video_url || null : null;

    const result = buildGenerateResult({
      runId,
      taskId: String(taskId),
      status,
      model: resolved.model,
      orientation: resolved.orientation,
      seconds: resolved.seconds,
      definition: resolved.definition,
      videoUrl,
      raw: { submit, status: raw },
      error:
        raw.error && typeof raw.error === 'object'
          ? { message: raw.error.message || String(raw.error) }
          : null,
    });

    persistGenerateArtifacts(artifacts, result);

    return {
      runId,
      artifactsDir: artifacts.root,
      taskId: String(taskId),
      status,
      videoUrl,
      raw,
    };
  } catch (error) {
    const failed = buildGenerateResult({
      runId,
      taskId: String(taskId),
      status: String(submit.status || 'submitted'),
      model: resolved.model,
      orientation: resolved.orientation,
      seconds: resolved.seconds,
      definition: resolved.definition,
      raw: { submit },
      error: { message: error instanceof Error ? error.message : String(error) },
    });
    persistGenerateArtifacts(artifacts, failed);
    throw error;
  }
}

module.exports = {
  buildGenerateResult,
  persistGenerateArtifacts,
  pollGenerate,
  resolveGenerateVideoRequest,
  runGenerateVideo,
  runGenerateVideoStatus,
};


path: references/common-rules.md
content: # Common Rules

Use these shared rules across CreatOK skills unless a specific skill overrides them.

## Skill Handoff

- When the current skill has already completed its main job and the user's next request more naturally matches a later CreatOK skill, the model should hand off instead of trying to keep everything inside the current skill.
- Do not require the user to say the skill name. Infer intent from natural requests such as "rewrite this for my product", "make me a version like this", "go ahead and generate", or "turn this into a video".
- During handoff, carry forward the already confirmed context so the user does not need to repeat the same brief, script, product details, or direction.

## 401 Unauthorized

- If a CreatOK API call returns `401 Unauthorized`, explain that the user's CreatOK API key is missing or invalid.
- Guide the user to generate a new API key at [https://www.creatok.ai/app/workspace/api-keys](https://www.creatok.ai/app/workspace/api-keys).
- Tell the user to configure `CREATOK_API_KEY` before retrying.
- If the user uses OpenClaw, suggest adding `CREATOK_API_KEY` under the `env` field in `$OPENCLAW_STATE_DIR/openclaw.json`.
- Keep the message practical and non-technical unless the user explicitly needs details for debugging or to share with a developer.

## Repeated Submission Failures

- If task submission fails multiple times in the same conversation or workflow, tell the user their local skills may be outdated.
- Ask them to upgrade skills at [https://www.creatok.ai/agent-skills](https://www.creatok.ai/agent-skills) before retrying.
- Use this guidance for repeated submission failures, not for a single transient network or auth error.


path: scripts/run.js
content: #!/usr/bin/env node
const path = require('node:path');
const readline = require('node:readline/promises');
const { stdin, stdout } = require('node:process');
const { resolveGenerateVideoRequest, runGenerateVideo, runGenerateVideoStatus } = require('../lib/generate-video');

const SKILL_ROOT = path.resolve(__dirname, '..');

function parseArgs(argv) {
  const args = {
    timeoutSec: 600,
    pollInterval: 3,
    yes: false,
    referenceImages: [],
  };

  for (let i = 2; i < argv.length; i += 1) {
    const key = argv[i];
    if (key === '--yes') {
      args.yes = true;
      continue;
    }
    if (key === '--wait') {
      args.wait = true;
      continue;
    }

    const value = argv[i + 1];
    if (key === '--prompt') args.prompt = value;
    if (key === '--task_id') args.taskId = value;
    if (key === '--orientation') args.orientation = value;
    if (key === '--seconds') args.seconds = Number(value);
    if (key === '--definition') args.definition = value;
    if (key === '--reference_images') args.referenceImages = value.split(',').map((s) => s.trim());
    if (key === '--model') args.model = value;
    if (key === '--run_id') args.runId = value;
    if (key === '--timeout_sec') args.timeoutSec = Number(value);
    if (key === '--poll_interval') args.pollInterval = Number(value);
    if (key.startsWith('--')) {
      i += 1;
    }
  }
  return args;
}

async function confirmGeneration(args) {
  console.log('About to generate video via CreatOK Open Skills proxy.');
  console.log(`- model: ${args.model}`);
  console.log(`- model_summary: ${args.selectedModelSummary}`);
  console.log(`- model_selection_reason: ${args.selectionReason}`);
  if (args.estimatedCredits != null) console.log(`- estimated_credits: ${args.estimatedCredits}`);
  console.log(`- orientation: ${args.orientation}`);
  console.log(`- seconds: ${args.seconds}`);
  console.log(`- definition: ${args.definition}`);
  if (args.referenceImages.length > 0) console.log(`- reference_images: ${args.referenceImages.join(', ')}`);
  console.log(`- prompt (first 120 chars): ${String(args.prompt).slice(0, 120)}`);
  const rl = readline.createInterface({ input: stdin, output: stdout });
  try {
    const answer = (await rl.question('Confirm to start generation? (yes/no): ')).trim().toLowerCase();
    return answer === 'y' || answer === 'yes';
  } finally {
    rl.close();
  }
}

async function resolveCommandArgs(args) {
  const resolved = await resolveGenerateVideoRequest({
    orientation: args.orientation || null,
    model: args.model || null,
    seconds: args.seconds == null ? null : args.seconds,
    definition: args.definition || null,
    referenceImages: args.referenceImages,
    timeoutSec: args.timeoutSec,
  });

  return {
    ...args,
    model: resolved.model,
    orientation: resolved.orientation,
    seconds: resolved.seconds,
    definition: resolved.definition,
    estimatedCredits: resolved.estimatedCredits,
    selectedModelSummary: resolved.selectedModelSummary,
    selectionReason: resolved.selectionReason,
  };
}

async function main() {
  const args = parseArgs(process.argv);
  if (!args.runId || (!args.prompt && !args.taskId)) {
    console.error('Usage: run.js --run_id <run_id> (--prompt <prompt> [--orientation <ratio>] [--seconds <seconds>] [--definition <definition>] [--reference_images /abs/a.png,/abs/b.jpg] [--model <model_id>] [--yes] | --task_id <task_id> [--wait] [--model <model_id>])');
    process.exit(2);
  }

  if (args.taskId) {
    const result = await runGenerateVideoStatus({
      taskId: args.taskId,
      runId: args.runId,
      skillDir: SKILL_ROOT,
      model: args.model || null,
      wait: Boolean(args.wait),
      timeoutSec: args.timeoutSec,
      pollInterval: args.pollInterval,
    });

    console.log(JSON.stringify({ ok: true, run_id: result.runId, task_id: result.taskId, status: result.status, video_url: result.videoUrl }));
    process.exit(result.status === 'succeeded' ? 0 : 1);
  }

  const resolvedArgs = await resolveCommandArgs(args);

  if (!resolvedArgs.yes) {
    const confirmed = await confirmGeneration(resolvedArgs);
    if (!confirmed) {
      console.log('Canceled.');
      process.exit(2);
    }
  }

  const result = await runGenerateVideo({
    prompt: resolvedArgs.prompt,
    runId: resolvedArgs.runId,
    skillDir: SKILL_ROOT,
    orientation: resolvedArgs.orientation,
    seconds: resolvedArgs.seconds,
    definition: resolvedArgs.definition || null,
    referenceImages: resolvedArgs.referenceImages,
    model: resolvedArgs.model,
    timeoutSec: resolvedArgs.timeoutSec,
    pollInterval: resolvedArgs.pollInterval,
  });

  console.log(JSON.stringify({ ok: true, run_id: result.runId, video_url: result.videoUrl }));
  process.exit(result.status === 'succeeded' && result.videoUrl ? 0 : 1);
}

main().catch((error) => {
  console.error(error instanceof Error ? error.message : String(error));
  process.exit(1);
});


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