Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions tools/audio/mixkit_music.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
"""Music search and download from Mixkit (free, no API key).

Reads the schema.org JSON-LD that Mixkit embeds in its music listing pages
(tag pages and discover/search pages). Tracks are covered by the Mixkit
Stock Music Free License: commercial use, no attribution required.
"""

from __future__ import annotations

import json
import re
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any

from tools.base_tool import (
BaseTool,
Determinism,
ExecutionMode,
ResourceProfile,
RetryPolicy,
ToolResult,
ToolRuntime,
ToolStability,
ToolStatus,
ToolTier,
)

_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)"
_LD_RE = re.compile(r'<script type="application/ld\+json">(.*?)</script>', re.S)
_ISO_DUR = re.compile(r"PT(?:(\d+)M)?(?:(\d+)S)?")


def _seconds(iso: str) -> int:
m = _ISO_DUR.fullmatch(iso or "")
return int(m.group(1) or 0) * 60 + int(m.group(2) or 0) if m else 0


class MixkitMusic(BaseTool):
name = "mixkit_music"
version = "0.1.0"
tier = ToolTier.SOURCE
capability = "music_search"
provider = "mixkit_music"
stability = ToolStability.EXPERIMENTAL
execution_mode = ExecutionMode.SYNC
determinism = Determinism.DETERMINISTIC
runtime = ToolRuntime.API

dependencies = []
install_instructions = "No setup required. Mixkit music is free and needs no API key."
agent_skills = ["music"]
capabilities = ["search_music", "download_music", "stock_music"]
supports = {"duration_filter": True, "free_commercial_use": True, "no_api_key": True}
best_for = ["free royalty-free background beds without attribution", "quick keyless music search"]
not_good_for = ["custom-composed scores", "exact-length tracks"]
fallback_tools = ["pixabay_music", "music_gen"]

input_schema = {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string", "description": "Mixkit tag (e.g. 'cartoons') or free-text search"},
"min_duration": {"type": "number", "default": 30},
"max_duration": {"type": "number", "default": 600},
"pick": {
"type": "integer",
"description": "Index into the filtered results to download. Omit to only list tracks.",
},
"output_path": {"type": "string", "description": "Where to save the MP3 when pick is given"},
},
}

resource_profile = ResourceProfile(cpu_cores=1, ram_mb=128, vram_mb=0, disk_mb=50, network_required=True)
retry_policy = RetryPolicy(max_retries=2, retryable_errors=["timeout"])
idempotency_key_fields = ["query", "min_duration", "max_duration", "pick"]
side_effects = ["writes audio file to output_path when pick is given", "reads mixkit.co pages"]
user_visible_verification = ["Listen to the track for mood and loopability"]

def get_status(self) -> ToolStatus:
return ToolStatus.AVAILABLE

def estimate_cost(self, inputs: dict[str, Any]) -> float:
return 0.0

def execute(self, inputs: dict[str, Any]) -> ToolResult:
start = time.time()
try:
tracks = self._search(inputs)
except Exception as exc:
return ToolResult(success=False, error=f"Mixkit music search failed: {exc}")

data: dict[str, Any] = {"provider": self.provider, "query": inputs["query"], "tracks": tracks}
artifacts: list[str] = []
pick = inputs.get("pick")
if pick is not None:
if not 0 <= pick < len(tracks):
return ToolResult(success=False, error=f"pick {pick} out of range ({len(tracks)} tracks)")
out = Path(inputs.get("output_path") or f"mixkit_{tracks[pick]['id']}.mp3")
out.parent.mkdir(parents=True, exist_ok=True)
req = urllib.request.Request(tracks[pick]["url"], headers={"User-Agent": _UA})
out.write_bytes(urllib.request.urlopen(req, timeout=60).read())
data["selected"] = tracks[pick] | {"output": str(out)}
artifacts.append(str(out))
return ToolResult(success=True, data=data, artifacts=artifacts,
duration_seconds=round(time.time() - start, 2), cost_usd=0.0)

def _search(self, inputs: dict[str, Any]) -> list[dict[str, Any]]:
q = inputs["query"].strip().lower()
slug = urllib.parse.quote(q.replace(" ", "-"))
urls = [f"https://mixkit.co/free-stock-music/tag/{slug}/",
f"https://mixkit.co/free-stock-music/discover/{slug}/"]
lo, hi = inputs.get("min_duration", 30), inputs.get("max_duration", 600)
for url in urls:
tracks = [t for t in self._parse(url) if lo <= t["duration_seconds"] <= hi]
if tracks:
return tracks
return []

def _parse(self, url: str) -> list[dict[str, Any]]:
req = urllib.request.Request(url, headers={"User-Agent": _UA})
try:
html = urllib.request.urlopen(req, timeout=30).read().decode("utf-8", "replace")
except urllib.error.HTTPError as exc:
if exc.code == 404:
return []
raise
out = []
for block in _LD_RE.findall(html):
for node in json.loads(block).get("@graph", []):
for item in node.get("itemListElement", []) if node.get("@type") == "ItemList" else []:
if item.get("@type") != "MusicRecording":
continue
out.append({
"id": item["url"].rstrip("/").rsplit("/", 2)[-2],
"title": item.get("name", ""),
"artist": item.get("byArtist", ""),
"genre": item.get("genre", ""),
"duration_seconds": _seconds(item.get("duration", "")),
"url": item["url"],
"license": item.get("license", "https://mixkit.co/license/#musicFree"),
"page": url,
})
return out
15 changes: 13 additions & 2 deletions tools/video/stock_sources/mixkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def search(self, query: str, filters: SearchFilters) -> list[Candidate]:
out: list[Candidate] = []

# Mixkit lists video cards with preview videos and download links
cards = soup.select(".item-grid__item, .video-item, article, [class*='VideoCard']")
cards = soup.select(".item-grid-card, .item-grid__item, .video-item, article, [class*='VideoCard']")
for card in cards[:filters.per_page]:
link_el = card.select_one("a[href]")
if not link_el:
Expand Down Expand Up @@ -115,13 +115,18 @@ def search(self, query: str, filters: SearchFilters) -> list[Candidate]:

# Extract ID from URL
clip_id = href.rstrip("/").rsplit("/", 1)[-1] if href else ""
numeric_id = clip_id.rsplit("-", 1)[-1]
direct_url = (
f"https://assets.mixkit.co/videos/{numeric_id}/{numeric_id}-1080.mp4"
if numeric_id.isdigit() else href
)

out.append(
Candidate(
source=self.name,
source_id=f"mixkit_{clip_id}",
source_url=href,
download_url=href, # Resolved in download()
download_url=direct_url, # direct 1080p file; 720p fallback in download()
kind="video",
width=0,
height=0,
Expand All @@ -147,6 +152,12 @@ def download(self, candidate: Candidate, out_path: Path) -> Path:
out_path = Path(out_path)
out_path.parent.mkdir(parents=True, exist_ok=True)

if candidate.download_url.endswith("-1080.mp4"):
try:
return self._stream_download(candidate.download_url, out_path)
except Exception:
return self._stream_download(candidate.download_url.replace("-1080.mp4", "-720.mp4"), out_path)

detail_url = candidate.extra.get("detail_url", candidate.download_url)

# Direct media URL
Expand Down
2 changes: 1 addition & 1 deletion tools/video/video_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -1046,7 +1046,7 @@ def _render_via_atelier(
try:
# Run from inside the composer dir so npx resolves the local
# remotion binary (mirrors _remotion_render).
self.run_command(cmd, timeout=1800, cwd=composer_dir)
self.run_command(cmd, timeout=int(bespoke.get("timeout_seconds", 1800)), cwd=composer_dir)
except Exception as e:
return ToolResult(success=False, error=f"Atelier (bespoke) Remotion render failed: {e}")

Expand Down