Skill: wayinvideo/find-moments-in-the-video (version 1.0.4)

--- name: find-moments description: Find specific moments in a video using a natural language query. Ideal for locating particular scenes, topics, or events in long videos (e.g., “the part where they talk about taxes”). Use this when the user has a clear search intent; not recommended for broader requests like "highlights" or "best moments." Export clips with customizable aspect ratios, caption styles, and AI reframing. Supports both online URLs and local files. user-invocable: true metadata: clawdbot: emoji: "🎥" requires: bins: ["python3"] env: ["WAYIN_API_KEY"] primaryEnv: "WAYIN_API_KEY" files: ["scripts/*"] --- # AI Find Video Moments This skill searches and locates specific content within a video using natural language queries based on the [WayinVideo API](https://wayin.ai/api-docs/find-moments/). ## Execution Workflow ### Step 0: Check API Key Check if the `WAYIN_API_KEY` is available in the environment or user context. If it is missing, ask the user to provide it or create one at `https://wayin.ai/wayinvideo/api-dashboard`. ### Step 1: Identify Video Source Determine if the input is a web URL (e.g., YouTube link) or a local file path. > [!IMPORTANT] > The WayinVideo API supports the following platforms for direct URL processing: **YouTube, Vimeo, Dailymotion, Kick, Twitch, TikTok, Facebook, Instagram, Zoom, Rumble, Google Drive**. If the platform is NOT supported, you must treat it as a local file (download it first if possible, then upload). ### Step 2: Upload (Local Files or Unsupported URLs Only) If the input is a local file or from an unsupported platform, you MUST upload it first to get an `identity` token: `python3 <ABS_PATH_TO_SKILL>/scripts/upload_video.py --file-path <file_path>` *(If the input is a web URL from a supported platform, skip this step.)* ### Step 3: Search Moments Submit the video and your query to find specific moments using the URL or the `identity` (from Step 2): `python3 <ABS_PATH_TO_SKILL>/scripts/submit_task.py --url "<url_or_identity>" --query "<query>" [options]` > [!TIP] > For best results, keep the `<query>` brief and self-contained. Use English whenever possible. This script will output the Project ID and the path to an initial result JSON file in your workspace. **Save both values for polling the results later.** #### Options: - `--target <lang>`: (Optional) Target language for output content. Auto-detected if omitted. If specified, you MUST read `assets/supported_languages.md` first to find the correct language code. - `--name <string>`: (Optional) A custom name for this task. - `--top-k <int>`: (Optional) The best K moments to return. Defaults to `10`. Pass `-1` to return all matching moments. - `--export`: (Optional) Enable rendering of clips (returns export links). - `--ai-hook`: (Optional) Enable automatically generated text hooks. (Used with `--export`) - `--ai-hook-style <style>`: (Optional) Style of the generated hook text. Values: `serious` (default), `casual`, `informative`, `conversational`, `humorous`, `parody`, `inspirational`, `dramatic`, `empathetic`, `persuasive`, `neutral`, `excited`, `calm`. (Used with `--export` and `--ai-hook`) - `--ai-hook-pos <pos>`: (Optional) Position of the generated hook text. Values: `beginning` (default), `end`. (Used with `--export` and `--ai-hook`) - `--ratio <ratio>`: (Optional) Aspect ratio: `RATIO_16_9`, `RATIO_1_1`, `RATIO_4_5`, `RATIO_9_16`. Defaults to `RATIO_9_16`. AI reframing is automatically enabled. If the user specifies a platform, you MUST read `assets/platform_ratio.md` first to determine the correct aspect ratio. (Used with `--export`) - `--resolution <res>`: (Optional) Output resolution: `SD_480`, `HD_720`, `FHD_1080` (default), `QHD_2K`, `UHD_4K`. (Used with `--export`) - `--caption-display <mode>`: (Optional) Caption mode: `none`, `both`, `original`, `translation`. Defaults to `original` (or `translation` if `--target` is provided). Pass `none` to explicitly disable captions. (Used with `--export`) - `--cc-style-tpl <id>`: (Optional) Caption style template ID. Defaults to `temp-static-2` if `--caption-display` is `both`, otherwise `word-focus`. See `assets/caption_style.md` for details. (Used with `--export` and `--caption-display`) - `--save-dir <path>`: (Optional) The directory where the initial result JSON file will be saved. Defaults to `api_results` in your workspace. > [!TIP] > - **Use the `--export` and `--ai-hook` flags by default.** This ensures you receive downloadable links for the matched moments immediately, and the moments include attention-grabbing AI-generated text hooks. While rendering adds extra processing time, it avoids the need to re-run the task later to get the video files. **Skip these flags only if the user specifically requests the raw analysis results as quickly as possible without video rendering or hooks.** > - To include subtitles in the dedicated language in the output video, use: `--export --caption-display translation --target <lang>`. > - If `--caption-display` is set to `both`, you MUST use a template ID starting with `temp-static-`. > - If the API only partially satisfies the request, use other tools to complete the remaining tasks and request user approval before proceeding. If this is not feasible, suggest the user visit `https://wayin.ai/wayinvideo/home`, which provides an online video editor and other AI-powered tools. ### Step 4: Wait for Results & Monitoring Immediately after Step 3, start the polling script to get the final results: `python3 <ABS_PATH_TO_SKILL>/scripts/polling_results.py --project-id <project_id> --save-file <save_file_path> [--event-interval 300]` > [!TIP] > - This script involves API polling and may take several minutes. **Always use a subagent to run this task whenever possible**. Once the sub-agent is started, MUST inform the user that the task is processing in the background, results will be provided immediately once available, and you are free to help the user with other tasks in the meantime. > - If your agent framework is OpenClaw (which offers `openclaw` CLI for sending system event), it's recommended to add `--event-interval 300` to enable continuous progress updates via system events (default is 0/disabled, so `openclaw` CLI is not required). > - When running in background, the script will automatically update the result file whenever new moments are found and send system event notifications if `--event-interval` > 0. **Subagent Reference Prompt (Main agent provides the specific steps):** "Set `WAYIN_API_KEY=<your_key>` in the environment, then run `python3 <ABS_PATH_TO_SKILL>/scripts/polling_results.py --project-id <id> --save-file <path>`. **Whether the polling script succeeds or fails, you MUST report the script's output.** Exit immediately after reporting." The main agent must explicitly include the Project ID and file path from Step 3 in the command given to the subagent. The main agent will read the saved JSON file to process and present the results. If `--event-interval` is set and this script runs in an OpenClaw subagent, it triggers a system event periodically to keep you updated: - **Receive Reminder**: When you receive a reminder, update the user on the current progress. - **Status Check**: Actively check the subagent status every 2 * `--event-interval` seconds. - If the subagent is still active, notify the user that processing is ongoing (e.g., "Processing is still in progress; as the video is quite long, it may take a bit more time"). - If the subagent is no longer active (crashed or stopped), notify the user and offer to retry (start the polling again or resubmit the task). ### Step 5: Report Results Once the script completes and outputs the `SUCCESS: Raw API result updated at <path>`, read that file and present the matching moments to the user. Your final response MUST provide links for downloading/previewing viral clips. You can also tell the user the absolute file path where all results are stored. > [!NOTE] > - The saved JSON file can be quite large. Before reading, check the line numbers or file size. If the file is large, process the file in chunks. Do not attempt to read a very large file into the session context at once. > - When using `--export`, the `export_link` returned by the API is valid for **24 hours**. > - If the results contain `export_link`, you MUST explicitly list the full original URLs in your response using the Markdown link format. **NEVER** truncate, shorten, or alter these URLs. > - To download the video, use: `curl -L -o <filename> "<export_link>"` > - The entire project/results expire after **3 days**. After this period, the task must be re-run. > - If it has been more than 24 hours but less than 3 days, refresh the `export_link` by running: `curl -s -H "Authorization: Bearer $WAYIN_API_KEY" -H "x-wayinvideo-api-version: v2" "https://wayinvideo-api.wayin.ai/api/v2/clips/find-moments/results/<project_id>"`. Then parse the JSON to get the new `export_link`. > - If no matching moments are found, you can refine your query based on the returned video information and ask the user for permission to try again.

path: skill.json
content: {
  "name": "find-moments",
  "version": "1.0.0",
  "description": "Find specific moments in a video based on a natural language query using WayinVideo API.",
  "commands": []
}


path: assets/supported_languages.md
content: Language Name	ISO 639 Language Code
Afrikaans	af
Albanian	sq
Amharic	am
Arabic	ar
Armenian	hy
Assamese	as
Azerbaijani	az
Bashkir	ba
Basque	eu
Belarusian	be
Bengali	bn
Bosnian	bs
Breton	br
Bulgarian	bg
Burmese	my
Catalan	ca
Chinese Simplified	zh-cn
Chinese Traditional	zh-tw
Croatian	hr
Czech	cs
Danish	da
Dutch	nl
English	en
Estonian	et
Faroese	fo
Finnish	fi
French	fr
Galician	gl
Georgian	ka
German	de
Greek	el
Gujarati	gu
Haitian Creole	ht
Hausa	ha
Hawaiian	haw
Hebrew	he
Hindi	hi
Hungarian	hu
Icelandic	is
Indonesian	id
Italian	it
Japanese	ja
Javanese	jw
Kannada	kn
Kazakh	kk
Khmer	km
Korean	ko
Lao	lo
Latin	la
Latvian	lv
Lingala	ln
Lithuanian	lt
Luxembourgish	lb
Macedonian	mk
Malagasy	mg
Malay	ms
Malayalam	ml
Maltese	mt
Maori	mi
Marathi	mr
Mongolian	mn
Nepali	ne
Norwegian	no
Norwegian Nynorsk	nn
Occitan	oc
Pashto	ps
Persian	fa
Polish	pl
Portuguese	pt
Punjabi	pa
Romanian	ro
Russian	ru
Sanskrit	sa
Serbian	sr
Shona	sn
Sindhi	sd
Sinhala	si
Slovak	sk
Slovenian	sl
Somali	so
Spanish	es
Sundanese	su
Swahili	sw
Swedish	sv
Tagalog	tl
Tajik	tg
Tamil	ta
Tatar	tt
Telugu	te
Thai	th
Tibetan	bo
Turkish	tr
Turkmen	tk
Ukrainian	uk
Urdu	ur
Uzbek	uz
Vietnamese	vi
Welsh	cy
Yiddish	yi
Yoruba	yo

path: assets/platform_ratio.md
content: | Ratio Enum | Aspect Ratio | Typical Platforms |
|------------|--------------|------------------|
| RATIO_16_9 | 16:9 | YouTube, Vimeo, Twitch, X (Twitter) |
| RATIO_1_1  | 1:1  | Instagram Feed, Facebook Feed, LinkedIn, X (Twitter) |
| RATIO_4_5  | 4:5  | Instagram Feed, Facebook Feed, X (Twitter) |
| RATIO_9_16 | 9:16 | TikTok, Instagram Reels, YouTube Shorts, Snapchat |

path: assets/caption_style.md
content: | Style ID | Type | Visual Effect Description |
| :--- | :--- | :--- |
| `smart-bg-focus` | Dynamic | Minimal smart-background style. Text is dark gray on a soft white rounded background, with upcoming words faded and spoken words shown in full opacity for a clean progressive-reading effect. Displays one line at a time. |
| `box-highlight` | Dynamic | Bold editorial box style. Text is white in a heavy sans-serif font on a dark rounded rectangular background, creating a strong high-contrast title-card look. Displays one line at a time, with no more than 2 words per line.
 |
| `word-focus` | Dynamic | Punchy focus-tag style. Base text is white in a bold condensed font, with the current spoken word highlighted by a vivid purple rounded background block. |
| `underline-focus` | Dynamic | Modern emphasis style. Base text is white in an uppercase bold font, with the current spoken word slightly enlarged and marked by an orange underline for a clean attention-guiding effect. Displays one line at a time. |
| `temp-0` | Dynamic | Playful comic/graffiti bold style. Base text is white, with the current spoken word highlighted in green. |
| `temp-1` | Dynamic | Casual handwriting/marker style. Base text is white, with the current spoken word highlighted in light blue. |
| `temp-2` | Dynamic | Clean and clear sans-serif bold font. Base text is white, with the current spoken word highlighted in light blue. |
| `temp-3` | Dynamic | Rounded and cute cartoon bold font. Base text is white, with the current spoken word highlighted in green. |
| `temp-4` | Dynamic | Clean sans-serif font. Base text features a white/yellow neon glow, while the current spoken word is highlighted in solid yellow. |
| `temp-5` | Dynamic | Wide sans-serif font with generous letter spacing. Base text is white, with the current spoken word highlighted in green. |
| `temp-6` | Dynamic | Ultra-heavy/black font with strong visual impact. Base text is white, with the current spoken word highlighted in purple. |
| `temp-7` | Dynamic | Bold italic font with a forward-leaning look. Base text is white, with the current spoken word highlighted in yellow. |
| `temp-8` | Dynamic | Typewriter/slab-serif style with a retro vibe. Base text is white, with the current spoken word highlighted in purple. |
| `temp-9` | Dynamic | All-caps cyberpunk vibe. Base text features a white/cyan neon glow, while the current spoken word is highlighted in solid cyan. |
| `temp-10` | Dynamic | Clean sans-serif font. Base text features a white/pink neon glow, while the current spoken word is highlighted in solid pink. |
| `temp-11` | Dynamic | Clean sans-serif font. Base text features a strong yellow neon glow, while the current spoken word is highlighted in solid yellow. |
| `temp-12` | Dynamic | Extremely thick bold font with tight character spacing. Base text is white, with the current spoken word highlighted in orange. |
| `temp-13` | Dynamic | Cursive/handwriting italic font. Base text is white with a green neon glow, while the current spoken word is highlighted in green. |
| `temp-14` | Dynamic | Clean sans-serif font. Base text is white with an orange neon glow, while the current spoken word is highlighted in solid orange. |
| `temp-15` | Dynamic | Christmas Theme. Hollow 3D font. Base text is green with dark outlines, while the current spoken word is highlighted in red fill with a bright white neon glow. |
| `temp-16` | Dynamic | Christmas Theme. Rounded bubble font. Base text is white with a cyan neon glow, while the current spoken word is highlighted in festive green. |
| `temp-17` | Dynamic | Christmas Theme. Comic pop style. Base text is dark muted festive colors, while the current spoken word is highlighted in bright red with a strong white neon glow. |
| `temp-static-1` | Static | Pure white text, standard sans-serif bold font, clean and neat. |
| `temp-static-2` | Static | Pure white text, all caps, ultra-thick bold (Impact style), extremely strong visual tension. |
| `temp-static-3` | Static | Pure white text, slightly condensed sans-serif bold font, ideal for longer text lines. |
| `temp-static-4` | Static | Bright green text, standard sans-serif bold font, eye-catching color. |
| `temp-static-5` | Static | Bright yellow text, bold italic sans-serif font, dynamic feel. |
| `temp-static-6` | Static | Orange text, all caps, blocky bold font, retro pixel or American varsity style. |

path: scripts/submit_task.py
content: import argparse
import json
import os
import sys
from datetime import datetime
import urllib.request
import urllib.error

# Get API Key from environment variable
[REDACTED_SECRET]"WAYIN_API_KEY")

if not [REDACTED_SECRET]"Error: WAYIN_API_KEY environment variable is not set.", file=sys.stderr)
    sys.exit(1)

BASE_URL = "https://wayinvideo-api.wayin.ai/api/v2/clips/find-moments"
HEADERS = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {API_KEY}",
    "x-wayinvideo-api-version": "v2",
    "user-agent": "find-moments skill"
}

def submit_task(payload):
    data = json.dumps(payload).encode("utf-8")
    req = urllib.request.Request(BASE_URL, data=data, headers=HEADERS, method="POST")
    
    try:
        with urllib.request.urlopen(req, timeout=15) as response:
            status = response.getcode()
            response_text = response.read().decode("utf-8")
            
            if status != 200:
                print(f"DEBUG: Response Status: {status}", file=sys.stderr)
                print(f"DEBUG: Response Text: {response_text}", file=sys.stderr)
                raise Exception(f"API request failed with status {status}: {response_text}")

            response_json = json.loads(response_text)
            data_res = response_json.get("data")

            if not data_res or ("project_id" not in data_res and "id" not in data_res):
                raise Exception(f"Unexpected response structure from submit API: {response_text}")
            return data_res.get("project_id") or data_res.get("id")
            
    except urllib.error.HTTPError as e:
        error_text = e.read().decode("utf-8")
        print(f"DEBUG: HTTP Error {e.code}: {error_text}", file=sys.stderr)
        raise Exception(f"HTTP Error {e.code}: {error_text}")
    except Exception as e:
        print(f"DEBUG: Request failed: {str(e)}", file=sys.stderr)
        raise e

def save_initial_state(prefix, project_id, submit_payload, output_dir=None):
    # Try to find workspace root if output_dir is not provided
    if not output_dir:
        current_path = os.path.abspath(__file__)
        workspace_root = None
        while True:
            parent_path = os.path.dirname(current_path)
            if parent_path == current_path: break
            if os.path.exists(os.path.join(parent_path, "AGENTS.md")):
                workspace_root = parent_path
                break
            current_path = parent_path
        
        output_dir = os.path.join(workspace_root or os.getcwd(), "api_results")

    if not os.path.exists(output_dir):
        os.makedirs(output_dir, exist_ok=True)
    
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    save_data = {
        "_metadata": {
            "id": project_id,
            "query": submit_payload.get("query"),
            "submit_payload": submit_payload,
            "api_endpoint": BASE_URL,
            "task_submitted_at": datetime.now().isoformat(),
            "status": "SUBMITTED"
        }
    }
    
    filename = os.path.join(output_dir, f"{prefix}_{project_id}_{timestamp}.json")
    with open(filename, "w", encoding="utf-8") as f:
        json.dump(save_data, f, ensure_ascii=False, indent=2)
    
    return os.path.abspath(filename)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--url", required=True)
    parser.add_argument("--query", required=True)
    parser.add_argument("--source", default=None)
    parser.add_argument("--target", default=None)
    parser.add_argument("--name", default=None)
    parser.add_argument("--export", action="store_true", default=False)
    parser.add_argument("--ratio", choices=["RATIO_16_9", "RATIO_1_1", "RATIO_4_5", "RATIO_9_16"], default=None)
    parser.add_argument("--resolution", choices=["SD_480", "HD_720", "FHD_1080", "QHD_2K", "UHD_4K"], default="FHD_1080")
    parser.add_argument("--caption-display", choices=["none", "both", "original", "translation"], default=None)
    parser.add_argument("--cc-style-tpl", default=None)
    parser.add_argument("--ai-hook", action="store_true", default=False)
    parser.add_argument("--ai-hook-style", choices=["serious", "casual", "informative", "conversational", "humorous", "parody", "inspirational", "dramatic", "empathetic", "persuasive", "neutral", "excited", "calm"], default="serious")
    parser.add_argument("--ai-hook-pos", choices=["beginning", "end"], default="beginning")
    parser.add_argument("--top-k", type=int, default=10, help="The best K moments to return. Defaults to 10. Pass -1 to return all matching moments.")
    parser.add_argument("--save-dir", default=None, help="Directory to save the initial state JSON file.")
    args = parser.parse_args()

    # Determine caption setting
    enable_caption = False
    caption_display = args.caption_display
    
    if caption_display == "none":
        enable_caption = False
        caption_display = None
    elif caption_display:
        enable_caption = True
    else: 
        enable_caption = True
        caption_display = "translation" if args.target else "original"

    # Default caption style logic
    cc_style_tpl = args.cc_style_tpl
    if cc_style_tpl is None:
        if caption_display == "both":
            cc_style_tpl = "temp-static-2"
        else:
            cc_style_tpl = "word-focus"

    if caption_display == "both" and not (cc_style_tpl and cc_style_tpl.startswith("temp-static-")):
        print(f"ERROR: `--cc-style-tpl` can only be `temp-static-...` when `--caption-display both`", file=sys.stderr)
        sys.exit(1)

    # Non-sensitive data will be persisted for debugging; sensitive data such as API keys in environment variables will not be persisted.
    submit_payload = {
        "video_url": args.url,
        "query": args.query,
        "enable_export": args.export,
        "enable_caption": enable_caption,
        "enable_ai_reframe": True if args.ratio else False,
        "enable_ai_hook": args.ai_hook
    }
    if args.ai_hook_style: submit_payload["ai_hook_script_style"] = args.ai_hook_style
    if args.ai_hook_pos: submit_payload["ai_hook_position"] = args.ai_hook_pos
    if args.source: submit_payload["source_lang"] = args.source.lower().replace("_", "-")
    if args.target: submit_payload["target_lang"] = args.target.lower().replace("_", "-")
    if args.name: submit_payload["project_name"] = args.name
    if args.ratio: submit_payload["ratio"] = args.ratio
    if args.resolution: submit_payload["resolution"] = args.resolution
    if caption_display: submit_payload["caption_display"] = caption_display
    if cc_style_tpl: submit_payload["cc_style_tpl"] = cc_style_tpl
    if args.top_k != -1: submit_payload["limit"] = args.top_k

    try:
        project_id = submit_task(submit_payload)
        abs_path = save_initial_state("moments", project_id, submit_payload, output_dir=args.save_dir)
        print(f"SUCCESS: Task submitted. Project ID: {project_id}")
        print(f"Initial state saved to: {abs_path}")
    except Exception as e:
        print(f"ERROR: {str(e)}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()


path: scripts/polling_results.py
content: import argparse
import json
import time
import os
import sys
import subprocess
import urllib.request
import urllib.error
import socket
from datetime import datetime, timedelta

# Get API Key from environment variable
[REDACTED_SECRET]"WAYIN_API_KEY")

if not [REDACTED_SECRET]"Error: WAYIN_API_KEY environment variable is not set.", file=sys.stderr)
    sys.exit(1)

BASE_URL = "https://wayinvideo-api.wayin.ai/api/v2/clips/find-moments"
HEADERS = {
    "Content-Type": "application/json",
    "Authorization": f"Bearer {API_KEY}",
    "x-wayinvideo-api-version": "v2",
    "user-agent": "find-moments skill"
}

def get_result(project_id, save_file, sleep_interval=15, max_timeout_seconds=14400, event_interval=300):
    url = f"{BASE_URL}/results/{project_id}"
    start_time = time.time()
    last_reminder_time = time.time()
    last_result_count = 0
    last_update_time = time.time()
    has_updated_ongoing = False

    while True:
        try:
            req = urllib.request.Request(url, headers=HEADERS, method="GET")
            with urllib.request.urlopen(req, timeout=30) as response:
                response_text = response.read().decode("utf-8")

                data_res = json.loads(response_text).get("data", {})
                status = data_res.get("status")
                current_moments = data_res.get("clips", [])
                current_count = len(current_moments)

                # Update save file if status is SUCCEEDED, FAILED or ONGOING with new results
                # Also update once when status first becomes ONGOING even if no results yet
                should_update = (status in ["SUCCEEDED", "FAILED"]) or \
                               (status == "ONGOING" and current_count > last_result_count) or \
                               (status == "ONGOING" and not has_updated_ongoing)

                if should_update:
                    msg = None
                    try:
                        abs_path = update_save_file(save_file, project_id, data_res, status=status)
                        
                        if event_interval > 0:
                            if status == "SUCCEEDED":
                                msg = f"✅ WayinVideo [Find Moments] task {project_id} SUCCEEDED! Total moments: {current_count}. View at {abs_path}"
                            elif status == "FAILED":
                                msg = f"❌ WayinVideo [Find Moments] task {project_id} FAILED. View details at {abs_path}"
                            elif status == "ONGOING" and current_count > last_result_count:
                                new_count = current_count - last_result_count
                                msg = f"🔔 WayinVideo [Find Moments] task {project_id} has {new_count} new results (Total: {current_count}). View at {abs_path}"
                            elif status == "ONGOING" and not has_updated_ongoing:
                                msg = f"🔄 WayinVideo [Find Moments] task {project_id} is now ONGOING. View at {abs_path}"
                            
                            if msg:
                                send_system_event(msg)
                                last_reminder_time = time.time()

                        if status == "ONGOING":
                            has_updated_ongoing = True

                        last_result_count = current_count
                        last_update_time = time.time()
                    except Exception as e:
                        print(f"DEBUG: Failed to update save file: {e}", file=sys.stderr)

                if status == "SUCCEEDED":
                    return data_res, None
                elif status == "FAILED":
                    return None, f"WayinVideo task failed. Raw response: {json.dumps(data_res)}"
                
                print(f"Status: {status}... (Moments: {current_count}) waiting {sleep_interval}s", file=sys.stderr)

                current_time = time.time()
                if current_time - start_time > max_timeout_seconds:
                    timeout_msg = (
                        f"Timeout: Task {project_id} did not finish within {max_timeout_seconds} seconds. "
                        f"The task is likely still processing on the server."
                    )
                    return None, timeout_msg
                
                # Send "still running" reminder only if no updates in the last event_interval
                if event_interval > 0 and (current_time - last_reminder_time >= event_interval) and (current_time - last_update_time >= event_interval):
                    reminder_text = f"Reminder: WayinVideo [Find Moments] task {project_id} is still running (Status: {status})."
                    send_system_event(reminder_text)
                    last_reminder_time = current_time
        except urllib.error.HTTPError as e:
            print(f"DEBUG: HTTP error during polling: status {e.code}", file=sys.stderr)
            if e.code in (401, 403, 404):
                return False, f"Fatal HTTP Error {e.code}. Stopping."
        except (urllib.error.URLError, TimeoutError, socket.timeout) as e:
            # Catch network timeouts and keep polling
            print(f"DEBUG: Network timeout or connection error: {str(e)}. Retrying in {sleep_interval}s...", file=sys.stderr)
        except Exception as e:
            print(f"DEBUG: Error during polling: {str(e)}", file=sys.stderr)
            return None, f"Error during polling: {str(e)}"

        time.sleep(sleep_interval)

def send_system_event(text):
    try:
        subprocess.run([
            "openclaw", "system", "event",
            "--text", text,
            "--mode", "now"
        ], check=True, capture_output=True, text=True)
        print(f"DEBUG: Sent system event: {text}", file=sys.stderr)
    except Exception as e:
        print(f"DEBUG: Failed to send system event: {e}", file=sys.stderr)

def update_save_file(file_path, project_id, api_response, status=None, error=None):
    with open(file_path, "r", encoding="utf-8") as f:
        data = json.load(f)
    
    # Validate project ID
    existing_id = data.get("_metadata", {}).get("id")
    if str(existing_id) != str(project_id):
        raise ValueError(f"Project ID mismatch: Expected {existing_id}, but got {project_id}.")

    now = datetime.now()
    data["_metadata"]["updated_at"] = now.isoformat()
    
    if api_response:
        data["api_response"] = api_response
        data["_metadata"]["status"] = status or api_response.get("status")

        # Add project name if available in response
        if "name" in api_response:
            data["_metadata"]["name"] = api_response["name"]
        
        if status == "SUCCEEDED":
            data["_metadata"]["task_finished_at"] = now.isoformat()
            data["_metadata"]["task_expires_at"] = (now + timedelta(days=3)).isoformat()
            
            moments = api_response.get("clips", [])
            if moments and any(moment.get("export_link") for moment in moments):
                data["_metadata"]["export_links_expire_at"] = (now + timedelta(hours=24)).isoformat()
            
    if error:
        data["error"] = error
        if "Timeout" in error:
            data["_metadata"]["status"] = "TIMEOUT"
        else:
            data["_metadata"]["status"] = "FAILED"

    with open(file_path, "w", encoding="utf-8") as f:
        json.dump(data, f, ensure_ascii=False, indent=2)
    
    return os.path.abspath(file_path)

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--project-id", required=True)
    parser.add_argument("--save-file", required=True)
    parser.add_argument("--event-interval", type=int, default=0, help="Interval (seconds) for status/update events. 0 to disable.")
    args = parser.parse_args()

    try:
        update_save_file(args.save_file, args.project_id, None)
    except Exception as e:
        print(f"ERROR: Initial file check failed: {e}", file=sys.stderr)
        sys.exit(1)

    try:
        result, error = get_result(args.project_id, args.save_file, event_interval=args.event_interval)
        abs_path = update_save_file(args.save_file, args.project_id, result, error=error)
        
        if error:
            print(f"ERROR: {error}. Result saved to {abs_path}")
            sys.exit(1)
        else:
            print(f"SUCCESS: Raw API result updated at {abs_path}")
    except Exception as e:
        print(f"ERROR: {str(e)}", file=sys.stderr)
        sys.exit(1)

if __name__ == "__main__":
    main()


path: scripts/upload_video.py
content: import os
import sys
import mimetypes
import urllib.request
import urllib.error
import json
from typing import Optional
import argparse

# Get API Key from environment variable
[REDACTED_SECRET]"WAYIN_API_KEY")

if not [REDACTED_SECRET]"Error: WAYIN_API_KEY environment variable is not set.")
    sys.exit(1)

def upload_local_file_to_wayinvideo(file_path: str) -> Optional[str]:
    """
    Upload a local video or audio file to WayinVideo and return the file identifier (identity).
    
    Note:
    1. The file identifier (identity) is for one-time use.
    2. The maximum file size limit for a single upload is 5 GB.
    3. The returned identity can be used as the video_url in subsequent APIs (e.g., AI editing, video summary, etc.).
    
    :param file_path: The absolute or relative path of the local file to be uploaded
    :return: Returns the identity string of the file on success, raises an exception or returns None on failure
    """
    if not os.path.exists(file_path):
        raise FileNotFoundError(f"File not found: {file_path}")

    file_name = os.path.basename(file_path)
    file_size = os.path.getsize(file_path)
    
    # Limit check: 5GB
    if file_size > 5 * 1024 * 1024 * 1024:
        raise ValueError("File size exceeds the 5GB limit.")

    content_type, _ = mimetypes.guess_type(file_path)
    allowed_mimetypes = ["video/x-msvideo", "video/mp4", "video/quicktime", "video/webm"]
    
    if content_type not in allowed_mimetypes:
        # Fallback for common extensions if mimetype detection is incomplete
        file_extension = os.path.splitext(file_name)[1].lower().strip(".")
        allowed_extensions = ["avi", "mp4", "mov", "webm"]
        if file_extension not in allowed_extensions:
            raise ValueError(f"Invalid file type: {content_type or 'unknown'}. Only MP4, AVI, MOV, and WEBM video files are supported.")

    # ---------------------------------------------------------
    # Step 1: Get the presigned upload URL and identity
    # ---------------------------------------------------------
    init_url = "https://wayinvideo-api.wayin.ai/api/v2/upload/single-file"
    init_headers = {
        "Content-Type": "application/json",
        "Authorization": f"Bearer {API_KEY}",
        "x-wayinvideo-api-version": "v2",
        "user-agent": "find-moments skill"
    }
    init_payload = json.dumps({"name": file_name}).encode("utf-8")

    try:
        req = urllib.request.Request(init_url, data=init_payload, headers=init_headers, method="POST")
        with urllib.request.urlopen(req, timeout=30) as response:
            init_data = json.loads(response.read().decode("utf-8"))
        
        upload_url = init_data["data"]["upload_url"]
        identity = init_data["data"]["identity"]
        
    except urllib.error.URLError as e:
        raise Exception(f"Network exception when requesting WayinVideo API: {str(e)}")

    # ---------------------------------------------------------
    # Step 2: Upload the actual file using the presigned URL
    # ---------------------------------------------------------
    if not content_type:
        content_type = "application/octet-stream"

    upload_headers = {
        "Content-Type": content_type
    }

    try:
        # Use stream reading to avoid loading large files directly into memory
        with open(file_path, 'rb') as f:
            req = urllib.request.Request(upload_url, data=f, headers=upload_headers, method="PUT")
            req.add_header("Content-Length", str(file_size))
            with urllib.request.urlopen(req) as response:
                if response.getcode() not in (200, 201, 204):
                    raise Exception(f"Failed to upload file. Status code: {response.getcode()}")
            
    except urllib.error.URLError as e:
        raise Exception(f"Failed to upload file to the presigned link: {str(e)}")

    # Return the credential for subsequent API calls
    return identity

def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("--file-path", required=True)
    args = parser.parse_args()

    try:
        print(f"Starting to upload file: {args.file_path}")
        file_identity = upload_local_file_to_wayinvideo(args.file_path)
        print("Upload successful!")
        print(f"Please use the following identity as the video_url in subsequent API requests:\n{file_identity}")
    except Exception as e:
        print(f"Error occurred during upload: {e}")

if __name__ == "__main__":
    main()


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