mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-06-08 18:24:20 +08:00
Compare commits
6 Commits
master-dev
...
clippy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7760793ab1 | ||
|
|
4a613aa0e4 | ||
|
|
a95d91f77a | ||
|
|
8b210c9bdb | ||
|
|
ebc2cf1da7 | ||
|
|
0de4dfcafc |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -109,3 +109,7 @@ Pipfile
|
||||
!.idea/customTargets.xml
|
||||
!.idea/tools/*
|
||||
!.run/*
|
||||
|
||||
### clippy ###
|
||||
clippy_stats.json
|
||||
clippy.log
|
||||
|
||||
@@ -73,6 +73,10 @@ dependencies = [
|
||||
|
||||
# ui
|
||||
"qrcode",
|
||||
|
||||
# clippy
|
||||
"discord-py",
|
||||
"flask",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -118,7 +118,7 @@ void AnnotatedCameraWidget::paintGL() {
|
||||
} else if (v_ego > 15) {
|
||||
wide_cam_requested = false;
|
||||
}
|
||||
wide_cam_requested = wide_cam_requested && sm["selfdriveState"].getSelfdriveState().getExperimentalMode();
|
||||
// wide_cam_requested = wide_cam_requested && sm["selfdriveState"].getSelfdriveState().getExperimentalMode();
|
||||
}
|
||||
CameraWidget::setStreamType(wide_cam_requested ? VISION_STREAM_WIDE_ROAD : VISION_STREAM_ROAD);
|
||||
CameraWidget::setFrameId(sm["modelV2"].getModelV2().getFrameId());
|
||||
|
||||
676
tools/clip/clippy.py
Normal file
676
tools/clip/clippy.py
Normal file
@@ -0,0 +1,676 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import html
|
||||
from collections import deque
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from openpilot.tools.lib.api import CommaApi, UnauthorizedError
|
||||
from openpilot.tools.lib.route import Route
|
||||
|
||||
import threading
|
||||
from flask import Flask, send_file, abort, make_response
|
||||
from pathlib import Path
|
||||
|
||||
if not (CLIPPY_TOKEN := os.getenv("CLIPPY_TOKEN")):
|
||||
sys.exit("❌ CLIPPY_TOKEN is missing – set it in the environment")
|
||||
|
||||
ALLOWED_GUILD_IDS = {880416502577266699, 1368811404689276958}
|
||||
|
||||
CLIPPY_BASE_URL = "https://clippy.royjr.com"
|
||||
|
||||
WORKING_DIR = os.path.expanduser("~/github/sunnypilot/tools/clip")
|
||||
CLIPS_DIR = os.path.join(WORKING_DIR, "clips")
|
||||
STATS_PATH = os.path.join(WORKING_DIR, "clippy_stats.json")
|
||||
LOG_PATH = os.path.join(WORKING_DIR, "clippy.log")
|
||||
os.makedirs(CLIPS_DIR, exist_ok=True)
|
||||
|
||||
MAX_TOTAL_JOBS = 20
|
||||
MAX_CONCURRENT_CLIPS = 3
|
||||
MAX_CONCURRENT_CLIPS_PER_USER = 3
|
||||
MAX_CLIP_DURATION = 60 * 5
|
||||
|
||||
CLIPPY_STATS_ALLOWED_ROLES = ["sunnypilot-dev"]
|
||||
CLIPPY_UNLIMITED_ALLOWED_ROLES = ["sunnypilot-dev"]
|
||||
|
||||
TAIL_LINES = 25
|
||||
tail_buffer = deque(maxlen=TAIL_LINES)
|
||||
|
||||
intents = discord.Intents.default()
|
||||
intents.message_content = True
|
||||
bot = commands.Bot(command_prefix=lambda bot, msg: [], intents=intents)
|
||||
|
||||
clip_queue = []
|
||||
clip_semaphore = asyncio.Semaphore(MAX_CONCURRENT_CLIPS)
|
||||
user_cooldowns = {}
|
||||
|
||||
|
||||
async def queue_monitor():
|
||||
while True:
|
||||
print("\033c", end="")
|
||||
w = shutil.get_terminal_size().columns
|
||||
bar = "-" * w
|
||||
print(f"{bar}\nTotal: {stats['total']} | ✅ {stats['success']} | ❌ {stats['fail']}\n{bar}")
|
||||
print("\n".join(f"{i+1:02d}. {j['status']} {j['user']}: {j['route']}" for i, j in enumerate(clip_queue)) or "No jobs in queue.")
|
||||
print(f"{bar}\n" + "\n".join(line[:w] for line in tail_buffer) + f"\n{bar}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
def start_clip_server():
|
||||
clip_dir_resolved = Path(CLIPS_DIR).resolve()
|
||||
app = Flask("clippy")
|
||||
|
||||
@app.route('/<path:filename>')
|
||||
def get_clip(filename):
|
||||
full_path = (clip_dir_resolved / filename).resolve()
|
||||
try:
|
||||
full_path.relative_to(clip_dir_resolved)
|
||||
except ValueError:
|
||||
abort(404)
|
||||
if not full_path.name.endswith(".mp4"):
|
||||
abort(404)
|
||||
try:
|
||||
if not full_path.is_file() or not full_path.samefile(full_path):
|
||||
abort(404)
|
||||
except Exception:
|
||||
abort(404)
|
||||
response = make_response(send_file(
|
||||
str(full_path),
|
||||
mimetype="video/mp4",
|
||||
as_attachment=False,
|
||||
conditional=True,
|
||||
))
|
||||
response.headers.update({
|
||||
"Cache-Control": "no-store",
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Disposition": f'inline; filename="{filename}"',
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
})
|
||||
return response
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(_):
|
||||
return "clip not found", 404
|
||||
|
||||
app.run(host="127.0.0.1", port=5000)
|
||||
|
||||
def has_any_role(user, role_list):
|
||||
if isinstance(user, discord.Member):
|
||||
return any(role.name in role_list for role in user.roles)
|
||||
return False
|
||||
|
||||
def user_tag(user: discord.User) -> str:
|
||||
return f"{user.display_name} ({user.name})"
|
||||
|
||||
def load_stats():
|
||||
if os.path.exists(STATS_PATH):
|
||||
with open(STATS_PATH, "r") as f:
|
||||
return json.load(f)
|
||||
return {"total": 0, "success": 0, "fail": 0}
|
||||
|
||||
def save_stats():
|
||||
with open(STATS_PATH, "w") as f:
|
||||
json.dump(stats, f)
|
||||
|
||||
stats = load_stats()
|
||||
|
||||
|
||||
class SanitizeFilter(logging.Filter):
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
if isinstance(record.msg, str):
|
||||
record.msg = re.compile(r'[\x00-\x1f\x7f-\x9f]').sub('', record.msg)
|
||||
return True
|
||||
|
||||
|
||||
class DequeHandler(logging.Handler):
|
||||
def __init__(self, buf):
|
||||
super().__init__()
|
||||
self.buf = buf
|
||||
self.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||
|
||||
def emit(self, record):
|
||||
try:
|
||||
self.buf.append(self.format(record))
|
||||
except Exception:
|
||||
self.handleError(record)
|
||||
|
||||
log_handler = RotatingFileHandler(LOG_PATH, maxBytes=5*1024*1024, backupCount=3)
|
||||
log_handler.setFormatter(logging.Formatter("%(asctime)s [%(levelname)s] %(message)s"))
|
||||
|
||||
root = logging.getLogger()
|
||||
root.setLevel(logging.INFO)
|
||||
root.addHandler(log_handler)
|
||||
root.addHandler(DequeHandler(tail_buffer))
|
||||
root.addFilter(SanitizeFilter())
|
||||
|
||||
|
||||
class DeletePublishedView(discord.ui.View):
|
||||
def __init__(self, message: discord.Message, author_id: int, video_path):
|
||||
super().__init__(timeout=300)
|
||||
self.message = message
|
||||
self.author_id = author_id
|
||||
self.video_path = video_path
|
||||
|
||||
@discord.ui.button(label="Unpublish Clip", style=discord.ButtonStyle.primary)
|
||||
async def unpublish(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
if interaction.user.id != self.author_id:
|
||||
logging.error(f"🚫 {user_tag(interaction.user)} cant unpublish {self.message.id}")
|
||||
await interaction.response.send_message("🚫 You can't unpublish this clip.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
await self.message.delete()
|
||||
logging.info(f"🗑️ {user_tag(interaction.user)} unpublished {self.message.id}")
|
||||
await interaction.response.edit_message(content="🗑️ Unpublished clip.", view=None)
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Failed to unpublish clip {self.message.id}: {e}")
|
||||
await interaction.response.send_message(f"❌ Failed to unpublish clip.", ephemeral=True)
|
||||
|
||||
@discord.ui.button(label="Unpublish + Delete Clip", style=discord.ButtonStyle.danger)
|
||||
async def delete(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
if interaction.user.id != self.author_id:
|
||||
logging.error(f"🚫 {user_tag(interaction.user)} cant unpublish and delete {self.message.id}")
|
||||
await interaction.response.send_message("🚫 You can't unpublish and delete this clip.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if not os.path.realpath(self.video_path).startswith(os.path.realpath(CLIPS_DIR) + os.sep):
|
||||
logging.error(f"❌ Unsafe delete attempt: {self.video_path}")
|
||||
await interaction.response.send_message("❌ Unsafe delete attempt.", ephemeral=True)
|
||||
return
|
||||
os.remove(self.video_path)
|
||||
await self.message.delete()
|
||||
logging.info(f"🗑️ {user_tag(interaction.user)} unpublished {self.message.id}")
|
||||
await interaction.response.edit_message(content="🗑️ Unpublished and deleted clip.", view=None)
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Failed to unpublish clip {self.message.id}: {e}")
|
||||
await interaction.response.send_message(f"❌ Failed to unpublish and delete clip.", ephemeral=True)
|
||||
|
||||
|
||||
class PublishView(discord.ui.View):
|
||||
def __init__(self, route_str, title, video_path, author_id, file_size, safe_name):
|
||||
super().__init__(timeout=300)
|
||||
self.route_str = route_str
|
||||
self.title = title
|
||||
self.video_path = video_path
|
||||
self.author_id = author_id
|
||||
self.file_size = file_size
|
||||
self.safe_name = safe_name
|
||||
|
||||
@discord.ui.button(label="Publish Clip", style=discord.ButtonStyle.success)
|
||||
async def publish(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
if interaction.user.id != self.author_id:
|
||||
await interaction.response.send_message("🚫 You can't publish this clip.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not os.path.exists(self.video_path):
|
||||
logging.error(f"❌ {user_tag(interaction.user)} failed to publish {self.route_str} – file missing")
|
||||
await interaction.response.edit_message(
|
||||
content="❌ Clip could not be published. File missing.",
|
||||
attachments=[], view=None
|
||||
)
|
||||
self.stop()
|
||||
return
|
||||
|
||||
logging.info(f"✅ {user_tag(interaction.user)} published {self.route_str}")
|
||||
|
||||
if not (1 <= self.file_size <= 9):
|
||||
published_msg = await interaction.channel.send(
|
||||
f"{interaction.user.mention} shared a [clip]({CLIPPY_BASE_URL}/{self.safe_name}.mp4) from [{self.route_str}](https://connect.comma.ai/{self.route_str})\n{self.title}"
|
||||
)
|
||||
else:
|
||||
published_msg = await interaction.channel.send(
|
||||
f"{interaction.user.mention} shared a clip from [{self.route_str}](https://connect.comma.ai/{self.route_str})\n{self.title}",
|
||||
file=discord.File(self.video_path)
|
||||
)
|
||||
await interaction.response.edit_message(
|
||||
content="✅ Clip published to channel.", attachments=[], view=DeletePublishedView(published_msg, interaction.user.id, self.video_path),
|
||||
)
|
||||
self.stop()
|
||||
|
||||
@discord.ui.button(label="Delete Clip", style=discord.ButtonStyle.danger)
|
||||
async def delete(self, interaction: discord.Interaction, _button: discord.ui.Button):
|
||||
if interaction.user.id != self.author_id:
|
||||
logging.error(f"🚫 {user_tag(interaction.user)} cant delete {self.video_path}")
|
||||
await interaction.response.send_message("🚫 You can't delete this clip.", ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
if not os.path.realpath(self.video_path).startswith(os.path.realpath(CLIPS_DIR) + os.sep):
|
||||
logging.error(f"❌ Unsafe delete attempt: {self.video_path}")
|
||||
await interaction.response.send_message("❌ Unsafe delete attempt.", ephemeral=True)
|
||||
return
|
||||
os.remove(self.video_path)
|
||||
logging.info(f"🗑️ {user_tag(interaction.user)} deleted {self.route_str}")
|
||||
await interaction.response.edit_message(content="🗑️ Clip deleted.", attachments=[], view=None)
|
||||
self.stop()
|
||||
except Exception as e:
|
||||
logging.error(f"❌ Failed to delete {self.route_str}")
|
||||
await interaction.response.edit_message(content=f"❌ Failed to delete clip.", view=None)
|
||||
|
||||
|
||||
@bot.tree.command(name="clippy", description="Generate a driving clip - make sure you upload logs first!")
|
||||
@app_commands.describe(
|
||||
input="connect link or dongle/route/starttime/endtime or dongle/route/startsegment-endsegment",
|
||||
title="Title (default: none)",
|
||||
quality="Video quality (default: high)",
|
||||
wide="Use wide view if uploaded (default: true)",
|
||||
speed="Playback speed (default: 1)",
|
||||
cache="Set to false to regenerate clip if its already cached (default: true)",
|
||||
private="If true, only you will see the preview (default: true)",
|
||||
bookmarks="Automatically clip bookmarks (default: false)",
|
||||
filesize="Max filesize (MB), set to 0 for unlimited (default: 9)",
|
||||
developer="Show the developer UI (default: Off)"
|
||||
)
|
||||
@app_commands.choices(
|
||||
quality=[
|
||||
app_commands.Choice(name="high", value="high"),
|
||||
app_commands.Choice(name="low", value="low"),
|
||||
],
|
||||
developer=[
|
||||
app_commands.Choice(name="Right", value="1"),
|
||||
app_commands.Choice(name="Right & Bottom", value="2"),
|
||||
]
|
||||
)
|
||||
async def clippy(
|
||||
interaction: discord.Interaction,
|
||||
input: str,
|
||||
title: str = None,
|
||||
quality: app_commands.Choice[str] | None = None,
|
||||
wide: bool = True,
|
||||
speed: int = 1,
|
||||
cache: bool = True,
|
||||
private: bool = True,
|
||||
bookmarks: bool = False,
|
||||
filesize: int = 9,
|
||||
developer: app_commands.Choice[str] | None = None,
|
||||
):
|
||||
|
||||
if interaction.guild_id not in ALLOWED_GUILD_IDS:
|
||||
logging.error(f"❌ This bot is not available in this server {interaction.guild_id}")
|
||||
await interaction.response.send_message("❌ This bot is not available in this server.", ephemeral=True)
|
||||
return
|
||||
|
||||
if len(clip_queue) >= MAX_TOTAL_JOBS:
|
||||
await interaction.response.send_message(
|
||||
"🚫 Server busy – too many jobs in queue. Please try again later.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
user_id = interaction.user.id
|
||||
if not has_any_role(interaction.user, CLIPPY_UNLIMITED_ALLOWED_ROLES):
|
||||
if user_cooldowns.get(user_id, 0) >= MAX_CONCURRENT_CLIPS_PER_USER:
|
||||
logging.error(f"🚫 {user_tag(interaction.user)} hit the cooldown limit")
|
||||
await interaction.response.send_message(
|
||||
"🚫 You already have a clip running. Wait for it to finish.",
|
||||
ephemeral=True
|
||||
)
|
||||
return
|
||||
|
||||
user_cooldowns[user_id] = user_cooldowns.get(user_id, 0) + 1
|
||||
|
||||
try:
|
||||
await interaction.response.defer(ephemeral=True)
|
||||
|
||||
quality_value = quality.value if quality else "high"
|
||||
title_cmd = title[:80] if title else ""
|
||||
title = f"> ### **{html.unescape(title[:80])}**" if title else ""
|
||||
stats["total"] += 1
|
||||
|
||||
# ── fast‑fail validation ────────────────────────────────────────────────────
|
||||
def fail(msg: str):
|
||||
stats["fail"] += 1
|
||||
save_stats()
|
||||
return interaction.followup.send(f"❌ {msg}", ephemeral=True)
|
||||
|
||||
input = input.removeprefix("https://connect.comma.ai/")
|
||||
|
||||
if bookmarks:
|
||||
match = re.match(r'^([a-z0-9]+)/([a-zA-Z0-9\-]+)$', input)
|
||||
if not match:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} entered bad input {input}")
|
||||
await fail("Use connect link, `dongle/route/starttime/endtime` or `dongle/route/startsegment-endsegment` (endsegment optional).\n```\n--- CONNECT ---\nhttps://connect.comma.ai/a2a0ccea32023010/2023-07-27--13-01-19/5/10\n\n--- EXAMPLES ---\na2a0ccea32023010/2023-07-27--13-01-19/0 segment 0\na2a0ccea32023010/2023-07-27--13-01-19/0-1 segments 0 through 1\na2a0ccea32023010/2023-07-27--13-01-19/5/10 from 5 to 10 seconds\na2a0ccea32023010/2023-07-27--13-01-19 when using bookmark option\n```")
|
||||
return
|
||||
else:
|
||||
dongle, route = match.groups()
|
||||
start = 0
|
||||
end = 0
|
||||
else:
|
||||
|
||||
match = re.match(r'^([a-z0-9]+)/([a-zA-Z0-9\-]+)/(\d+)/(\d+)$', input)
|
||||
if not match:
|
||||
match = re.match(r"^([a-z0-9]+)/([A-Za-z0-9\-]+)/(\d+)(?:-(\d+))?$", input)
|
||||
if not match:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} entered bad input {input}")
|
||||
await fail("Use connect link, `dongle/route/starttime/endtime` or `dongle/route/startsegment-endsegment` (endsegment optional).\n```\n--- CONNECT ---\nhttps://connect.comma.ai/a2a0ccea32023010/2023-07-27--13-01-19/5/10\n\n--- EXAMPLES ---\na2a0ccea32023010/2023-07-27--13-01-19/0 segment 0\na2a0ccea32023010/2023-07-27--13-01-19/0-1 segments 0 through 1\na2a0ccea32023010/2023-07-27--13-01-19/5/10 from 5 to 10 seconds\na2a0ccea32023010/2023-07-27--13-01-19 when using bookmark option\n```")
|
||||
return
|
||||
else:
|
||||
dongle, route, seg_start, seg_end = match.groups()
|
||||
|
||||
if int(seg_start) == 0:
|
||||
# in_start = 2 # fix for 2s
|
||||
in_start = 0
|
||||
else:
|
||||
in_start = int(seg_start) * 60
|
||||
|
||||
if seg_end is None:
|
||||
in_end = 60 if int(seg_start) == 0 else in_start + 60
|
||||
else:
|
||||
in_end = 60 if int(seg_end) == 0 else (int(seg_end) + 1) * 60
|
||||
else:
|
||||
dongle, route, in_start, in_end = match.groups()
|
||||
|
||||
start = int(in_start)
|
||||
end = int(in_end)
|
||||
|
||||
# fix for 2s
|
||||
# if start < 2 or end <= start:
|
||||
# await fail("Start must be at least 2 and end must be greater than start.")
|
||||
# return
|
||||
|
||||
if end <= start:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} entered bad times {input}")
|
||||
await fail("End must be greater than start time.")
|
||||
return
|
||||
duration = end - start
|
||||
if duration > MAX_CLIP_DURATION:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} hit the max duration limit {input}")
|
||||
await fail(f"Clips must be {int(MAX_CLIP_DURATION / 60)} minutes or less.")
|
||||
return
|
||||
|
||||
status_msg = await interaction.followup.send(
|
||||
"🕐 Waiting in queue..", ephemeral=private
|
||||
)
|
||||
|
||||
if speed == 0:
|
||||
speed = 1
|
||||
if speed > 1:
|
||||
end = start + int(duration / speed)
|
||||
elif speed < 1:
|
||||
end = start + int(duration / speed)
|
||||
if bookmarks:
|
||||
route_str = f"{dongle}/{route}"
|
||||
connect_route_str = f"{dongle}/{route}"
|
||||
base = f"{dongle}_{route}_bookmarks_{quality_value}"
|
||||
else:
|
||||
route_str = f"{dongle}/{route}/{start}/{end}"
|
||||
connect_start = 1 if start == 0 else start
|
||||
connect_route_str = f"{dongle}/{route}/{connect_start}/{end}"
|
||||
base = f"{dongle}_{route}_{start}_{end}_{quality_value}"
|
||||
if wide:
|
||||
base += "_wide"
|
||||
if speed:
|
||||
base += f"_{speed}"
|
||||
base += f"_s{filesize}"
|
||||
clean_base = re.sub(r'[^A-Za-z0-9_-]+', '_', base)
|
||||
if title_cmd:
|
||||
title_hash = hashlib.sha1(title_cmd.encode()).hexdigest()[:10]
|
||||
safe_name = f"{clean_base}_{title_hash}"
|
||||
else:
|
||||
safe_name = clean_base
|
||||
|
||||
safe_name = re.sub(r'[^a-zA-Z0-9_-]', '_', safe_name)
|
||||
|
||||
if any(job["route"] == safe_name for job in clip_queue):
|
||||
await status_msg.edit(content="❌ That clip is already in the queue or processing – wait for it to finish.")
|
||||
return
|
||||
|
||||
try:
|
||||
logs = CommaApi().get(f"/v1/route/{dongle}|{route}/files").get("logs")
|
||||
|
||||
segments = [
|
||||
re.search(r'/(\d+)/rlog\.(?:zst|bz2)', url).group(1)
|
||||
for url in logs
|
||||
if re.search(r'/(\d+)/rlog\.(?:zst|bz2)', url)
|
||||
]
|
||||
|
||||
startsegment = start // 60
|
||||
endsegment = (end - 1) // 60
|
||||
|
||||
segment_set = set(int(s) for s in segments)
|
||||
if bookmarks:
|
||||
missing = False
|
||||
else:
|
||||
missing = [i for i in range(startsegment, endsegment + 1) if i not in segment_set]
|
||||
|
||||
if missing:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} segments missing {missing}")
|
||||
await status_msg.edit(content=f"❌ You need to upload the missing logs for segments `{missing}` using [connect.comma.ai](https://connect.comma.ai/{connect_route_str})")
|
||||
return
|
||||
else:
|
||||
if bookmarks:
|
||||
logging.info(f"🕐 {user_tag(interaction.user)} getting bookmarks {route_str}")
|
||||
await status_msg.edit(content=f"🕐 Getting bookmarks")
|
||||
else:
|
||||
logging.info(f"☑️ {user_tag(interaction.user)} segments present {route_str}")
|
||||
await status_msg.edit(content=f"☑️ All required segments are present.")
|
||||
|
||||
except UnauthorizedError as e:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} unauthorized: {e}")
|
||||
await status_msg.edit(content=f"❌ You need to make the route public using [connect.comma.ai](https://connect.comma.ai/{route_str}). `/clippy-auth` is no longer supported.")
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} unexpected error: {e}")
|
||||
await status_msg.edit(content=f"❌ Error: unexpected error")
|
||||
return
|
||||
|
||||
if bookmarks:
|
||||
try:
|
||||
route = Route(route_str)
|
||||
user_flags_at_time = []
|
||||
|
||||
for segment in route.segments:
|
||||
for event in segment.events:
|
||||
if event['type'] == 'user_flag':
|
||||
user_flags_at_time.append(round(event['route_offset_millis'] / 1000))
|
||||
|
||||
except Exception as e:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} unauthorized: {e}")
|
||||
await status_msg.edit(content=f"❌ You need to make the route public using [connect.comma.ai](https://connect.comma.ai/{route_str}). `/clippy-auth` is no longer supported.")
|
||||
return
|
||||
|
||||
if len(user_flags_at_time) == 0:
|
||||
logging.error(f"❌ {user_tag(interaction.user)} no bookmarks found")
|
||||
await status_msg.edit(content=f"❌ No bookmarks found")
|
||||
return
|
||||
else:
|
||||
bookmarklinks = ''
|
||||
for user_flag_at_time in user_flags_at_time:
|
||||
bookmarklinks += f"```{connect_route_str}/{user_flag_at_time - 10}/{user_flag_at_time + 5}```"
|
||||
logging.info(f"✅ {user_tag(interaction.user)} {len(user_flags_at_time)} bookmarks found! - {user_flags_at_time}")
|
||||
await status_msg.edit(content=f"✅ {len(user_flags_at_time)} bookmarks found! - {user_flags_at_time}{bookmarklinks}")
|
||||
return
|
||||
|
||||
full_path = os.path.join(CLIPS_DIR, f"{safe_name}.mp4")
|
||||
|
||||
clip_queue.append({"user": interaction.user.display_name,
|
||||
"route": safe_name,
|
||||
"duration": duration,
|
||||
"status": "🕐"})
|
||||
save_stats()
|
||||
if private:
|
||||
logging.info(f"🕐 {user_tag(interaction.user)} queued (PRIVATE) {route_str}")
|
||||
else:
|
||||
logging.info(f"🕐 {user_tag(interaction.user)} queued {route_str}")
|
||||
|
||||
if os.path.exists(full_path) and cache:
|
||||
stats["success"] += 1
|
||||
save_stats()
|
||||
for j in clip_queue:
|
||||
if j["route"] == safe_name:
|
||||
j["status"] = "✅"
|
||||
|
||||
if private:
|
||||
logging.info(f"📁 {user_tag(interaction.user)} used cache (PRIVATE) {route_str}")
|
||||
await status_msg.edit(content="📁 Used cached clip.")
|
||||
if not (1 <= filesize <= 9):
|
||||
await interaction.followup.send(
|
||||
content=f"Preview for [`{route_str}`]({CLIPPY_BASE_URL}/{safe_name}.mp4)\n{title}",
|
||||
view=PublishView(route_str, title, full_path, interaction.user.id, filesize, safe_name),
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
content=f"Preview for `{route_str}`\n{title}",
|
||||
file=discord.File(full_path),
|
||||
view=PublishView(route_str, title, full_path, interaction.user.id, filesize, safe_name),
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
logging.info(f"📁 {user_tag(interaction.user)} used cache {route_str}")
|
||||
|
||||
if not (1 <= filesize <= 9):
|
||||
published_msg = await interaction.channel.send(
|
||||
f"{interaction.user.mention} shared a [clip]({CLIPPY_BASE_URL}/{safe_name}.mp4) from [{route_str}](https://connect.comma.ai/{route_str})\n{title}"
|
||||
)
|
||||
else:
|
||||
published_msg = await interaction.channel.send(
|
||||
f"{interaction.user.mention} shared a clip from [{route_str}](https://connect.comma.ai/{route_str})\n{title}",
|
||||
file=discord.File(full_path)
|
||||
)
|
||||
await status_msg.edit(
|
||||
content="📁 Used cached clip.", attachments=[], view=DeletePublishedView(published_msg, interaction.user.id, full_path),
|
||||
)
|
||||
|
||||
else:
|
||||
async with clip_semaphore:
|
||||
for j in clip_queue:
|
||||
if j["route"] == safe_name:
|
||||
j["status"] = "🔄"
|
||||
logging.info(f"🔄 {user_tag(interaction.user)} processing {route_str}")
|
||||
await status_msg.edit(content=f"🔄 Processing {j['duration']}s clip..")
|
||||
|
||||
cmd = ["python3", "run.py", route_str, "-q", quality_value, "-x", str(speed), "-o", full_path]
|
||||
if not (in_start and in_end):
|
||||
if in_start != 0: # fix for 2s
|
||||
cmd += ["-s", str(start), "-e", str(end)]
|
||||
|
||||
if title_cmd:
|
||||
cmd += ["-t", str(title_cmd)]
|
||||
|
||||
if wide:
|
||||
cmd += ["-w"]
|
||||
|
||||
if filesize:
|
||||
cmd += ["-f", str(filesize)]
|
||||
|
||||
if developer:
|
||||
dev_mode = int(developer.value)
|
||||
else:
|
||||
dev_mode = 0
|
||||
cmd += ["-z", str(dev_mode)]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd, cwd=WORKING_DIR,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE
|
||||
)
|
||||
stdout, stderr = await proc.communicate()
|
||||
clean_err = "\n".join(stderr.decode().splitlines()[3:]) if stderr else ""
|
||||
|
||||
if proc.returncode != 0 or not os.path.exists(full_path):
|
||||
for j in clip_queue:
|
||||
if j["route"] == safe_name:
|
||||
j["status"] = "❌"
|
||||
stats["fail"] += 1
|
||||
save_stats()
|
||||
logging.error(f"❌ {user_tag(interaction.user)} failed {route_str}\n{clean_err}")
|
||||
|
||||
if clean_err == "clip.py: error: failed to get route: Unauthorized. Authenticate with tools/lib/auth.py":
|
||||
await status_msg.edit(content=f"❌ You need to make the route public using [connect.comma.ai](https://connect.comma.ai/{route_str}). `/clippy-auth` is no longer supported.")
|
||||
elif clean_err == "clip.py: error: failed to get route: 404:The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.":
|
||||
await status_msg.edit(content="❌ This route does not exist, please try another.")
|
||||
else:
|
||||
await status_msg.edit(content="❌ Clip failed to generate.")
|
||||
else:
|
||||
for j in clip_queue:
|
||||
if j["route"] == safe_name:
|
||||
j["status"] = "✅"
|
||||
stats["success"] += 1
|
||||
save_stats()
|
||||
|
||||
if private:
|
||||
logging.info(f"✅ {user_tag(interaction.user)} success (PRIVATE) {route_str}")
|
||||
await status_msg.edit(content="✅ Clip ready.")
|
||||
|
||||
if not (1 <= filesize <= 9):
|
||||
await interaction.followup.send(
|
||||
content=f"Preview for [`{route_str}`]({CLIPPY_BASE_URL}/{safe_name}.mp4)\n{title}",
|
||||
view=PublishView(route_str, title, full_path, interaction.user.id, filesize, safe_name),
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
await interaction.followup.send(
|
||||
content=f"Preview for `{route_str}`\n{title}",
|
||||
file=discord.File(full_path),
|
||||
view=PublishView(route_str, title, full_path, interaction.user.id, filesize, safe_name),
|
||||
ephemeral=True
|
||||
)
|
||||
else:
|
||||
logging.info(f"✅ {user_tag(interaction.user)} success {route_str}")
|
||||
|
||||
if not (1 <= filesize <= 9):
|
||||
published_msg = await interaction.channel.send(
|
||||
f"{interaction.user.mention} shared a [clip]({CLIPPY_BASE_URL}/{safe_name}.mp4) from [{route_str}](https://connect.comma.ai/{route_str})\n{title}"
|
||||
)
|
||||
else:
|
||||
published_msg = await interaction.channel.send(
|
||||
f"{interaction.user.mention} shared a clip from [{route_str}](https://connect.comma.ai/{route_str})\n{title}",
|
||||
file=discord.File(full_path)
|
||||
)
|
||||
await status_msg.edit(
|
||||
content="✅ Clip ready.", attachments=[], view=DeletePublishedView(published_msg, interaction.user.id, full_path),
|
||||
)
|
||||
|
||||
await asyncio.sleep(1)
|
||||
clip_queue[:] = [j for j in clip_queue if j["route"] != safe_name]
|
||||
|
||||
finally:
|
||||
if user_id in user_cooldowns:
|
||||
user_cooldowns[user_id] = max(0, user_cooldowns[user_id] - 1)
|
||||
clip_queue[:] = [j for j in clip_queue if j["route"] != safe_name]
|
||||
|
||||
|
||||
@bot.tree.command(name="clippy-stats", description="View clippy stats")
|
||||
async def clippy_stats(interaction: discord.Interaction):
|
||||
|
||||
if interaction.guild_id not in ALLOWED_GUILD_IDS:
|
||||
logging.error(f"❌ This bot is not available in this server {interaction.guild_id}")
|
||||
await interaction.response.send_message("❌ This bot is not available in this server.", ephemeral=True)
|
||||
return
|
||||
|
||||
if not has_any_role(interaction.user, CLIPPY_STATS_ALLOWED_ROLES):
|
||||
logging.error(f"🚫 {user_tag(interaction.user)} not allowed to use /clippy-stats")
|
||||
await interaction.response.send_message("🚫 You don't have permission.", ephemeral=True)
|
||||
return
|
||||
|
||||
stat = f"Total: {stats['total']} | ✅ {stats['success']} | ❌ {stats['fail']}"
|
||||
queue = "\n".join(f"{j['status']} {j['user']}: {j['route']}" for j in clip_queue) or "No active jobs."
|
||||
tail = "\n".join(list(tail_buffer)[-5:][::-1]) or "[no log records yet]"
|
||||
|
||||
content = f"```{stat}``````{queue}``````{tail}"
|
||||
await interaction.response.send_message(content[:1997] + "```", ephemeral=True)
|
||||
logging.info(f"✅ {user_tag(interaction.user)} used /clippy-stats")
|
||||
|
||||
|
||||
@bot.event
|
||||
async def on_ready():
|
||||
await bot.tree.sync()
|
||||
for guild in bot.guilds:
|
||||
logging.info(f"Connected to guild: {guild.name} ({guild.id})")
|
||||
await bot.change_presence(activity=discord.Game(name="your clips"))
|
||||
asyncio.create_task(queue_monitor())
|
||||
print(f"Logged in as {bot.user}")
|
||||
|
||||
threading.Thread(target=start_clip_server, daemon=True).start()
|
||||
bot.run(CLIPPY_TOKEN)
|
||||
@@ -28,7 +28,7 @@ DEMO_ROUTE = 'a2a0ccea32023010/2023-07-27--13-01-19'
|
||||
FRAMERATE = 20
|
||||
PIXEL_DEPTH = '24'
|
||||
RESOLUTION = '2160x1080'
|
||||
SECONDS_TO_WARM = 2
|
||||
SECONDS_TO_WARM = 0.5 # fix for 2s
|
||||
PROC_WAIT_SECONDS = 30*10
|
||||
|
||||
OPENPILOT_FONT = str(Path(BASEDIR, 'selfdrive/assets/fonts/Inter-Regular.ttf').resolve())
|
||||
@@ -104,8 +104,9 @@ def parse_args(parser: ArgumentParser):
|
||||
args.end = int(parts[3])
|
||||
if args.end <= args.start:
|
||||
parser.error(f'end ({args.end}) must be greater than start ({args.start})')
|
||||
if args.start < SECONDS_TO_WARM:
|
||||
parser.error(f'start must be greater than {SECONDS_TO_WARM}s to allow the UI time to warm up')
|
||||
# fix for 2s
|
||||
# if args.start < SECONDS_TO_WARM:
|
||||
# parser.error(f'start must be greater than {SECONDS_TO_WARM}s to allow the UI time to warm up')
|
||||
|
||||
try:
|
||||
args.route = Route(args.route, data_dir=args.data_dir)
|
||||
@@ -113,16 +114,16 @@ def parse_args(parser: ArgumentParser):
|
||||
parser.error(f'failed to get route: {e}')
|
||||
|
||||
# FIXME: length isn't exactly max segment seconds, simplify to replay exiting at end of data
|
||||
length = round(args.route.max_seg_number * 60)
|
||||
if args.start >= length:
|
||||
parser.error(f'start ({args.start}s) cannot be after end of route ({length}s)')
|
||||
if args.end > length:
|
||||
parser.error(f'end ({args.end}s) cannot be after end of route ({length}s)')
|
||||
# length = round(args.route.max_seg_number * 60)
|
||||
# if args.start >= length:
|
||||
# parser.error(f'start ({args.start}s) cannot be after end of route ({length}s)')
|
||||
# if args.end > length:
|
||||
# parser.error(f'end ({args.end}s) cannot be after end of route ({length}s)')
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def populate_car_params(lr: LogReader):
|
||||
def populate_car_params(lr: LogReader, developer: int):
|
||||
init_data = lr.first('initData')
|
||||
assert init_data is not None
|
||||
|
||||
@@ -131,10 +132,14 @@ def populate_car_params(lr: LogReader):
|
||||
for cp in entries:
|
||||
key, value = cp.key, cp.value
|
||||
try:
|
||||
if key == "OSMDownloadProgress":
|
||||
continue
|
||||
params.put(key, params.cpp2python(key, value))
|
||||
except UnknownKeyName:
|
||||
# forks of openpilot may have other Params keys configured. ignore these
|
||||
logger.warning(f"unknown Params key '{key}', skipping")
|
||||
pass
|
||||
if developer is not None:
|
||||
params.put("DevUIInfo", developer)
|
||||
logger.debug('persisted CarParams')
|
||||
|
||||
|
||||
@@ -179,6 +184,7 @@ def wait_for_frames(procs: list[Popen]):
|
||||
def clip(
|
||||
data_dir: str | None,
|
||||
quality: Literal['low', 'high'],
|
||||
wide: bool,
|
||||
prefix: str,
|
||||
route: Route,
|
||||
out: str,
|
||||
@@ -187,8 +193,9 @@ def clip(
|
||||
speed: int,
|
||||
target_mb: int,
|
||||
title: str | None,
|
||||
developer: int,
|
||||
):
|
||||
logger.info(f'clipping route {route.name.canonical_name}, start={start} end={end} quality={quality} target_filesize={target_mb}MB')
|
||||
logger.info(f'clipping route {route.name.canonical_name}, start={start} end={end} quality={quality} wide={wide} target_filesize={target_mb}MB')
|
||||
lr = get_logreader(route)
|
||||
|
||||
begin_at = max(start - SECONDS_TO_WARM, 0)
|
||||
@@ -224,8 +231,6 @@ def clip(
|
||||
'-draw_mouse', '0',
|
||||
'-i', display,
|
||||
'-c:v', 'libx264',
|
||||
'-maxrate', f'{bit_rate_kbps}k',
|
||||
'-bufsize', f'{bit_rate_kbps*2}k',
|
||||
'-crf', '23',
|
||||
'-filter:v', ','.join(overlays),
|
||||
'-preset', 'ultrafast',
|
||||
@@ -234,12 +239,19 @@ def clip(
|
||||
'-movflags', '+faststart',
|
||||
'-f', 'mp4',
|
||||
'-t', str(duration),
|
||||
out,
|
||||
]
|
||||
|
||||
replay_cmd = [REPLAY, '--ecam', '-c', '1', '-s', str(begin_at), '--prefix', prefix]
|
||||
if target_mb > 0:
|
||||
ffmpeg_cmd += ['-maxrate', f'{bit_rate_kbps}k']
|
||||
ffmpeg_cmd += ['-bufsize', f'{bit_rate_kbps*2}k']
|
||||
|
||||
ffmpeg_cmd.append(out)
|
||||
|
||||
replay_cmd = [REPLAY, '-c', '1', '-s', str(begin_at), '--prefix', prefix]
|
||||
if data_dir:
|
||||
replay_cmd.extend(['--data_dir', data_dir])
|
||||
if wide:
|
||||
replay_cmd.append('--ecam')
|
||||
if quality == 'low':
|
||||
replay_cmd.append('--qcam')
|
||||
replay_cmd.append(route.name.canonical_name)
|
||||
@@ -248,7 +260,7 @@ def clip(
|
||||
xvfb_cmd = ['Xvfb', display, '-terminate', '-screen', '0', f'{RESOLUTION}x{PIXEL_DEPTH}']
|
||||
|
||||
with OpenpilotPrefix(prefix, shared_download_cache=True):
|
||||
populate_car_params(lr)
|
||||
populate_car_params(lr, developer)
|
||||
env = os.environ.copy()
|
||||
env['DISPLAY'] = display
|
||||
|
||||
@@ -262,7 +274,7 @@ def clip(
|
||||
with managed_proc(ffmpeg_cmd, env) as ffmpeg_proc:
|
||||
procs.append(ffmpeg_proc)
|
||||
logger.info(f'recording in progress ({duration}s)...')
|
||||
ffmpeg_proc.wait(duration + PROC_WAIT_SECONDS)
|
||||
ffmpeg_proc.wait((duration * 2) + PROC_WAIT_SECONDS)
|
||||
check_for_failure(procs)
|
||||
logger.info(f'recording complete: {Path(out).resolve()}')
|
||||
|
||||
@@ -279,15 +291,18 @@ def main():
|
||||
p.add_argument('-o', '--output', help='output clip to (.mp4)', type=validate_output_file, default=DEFAULT_OUTPUT)
|
||||
p.add_argument('-p', '--prefix', help='openpilot prefix', default=f'clip_{randint(100, 99999)}')
|
||||
p.add_argument('-q', '--quality', help='quality of camera (low = qcam, high = hevc)', choices=['low', 'high'], default='high')
|
||||
p.add_argument('-w', '--wide', help='enable wide view if uploaded', action='store_true',)
|
||||
p.add_argument('-x', '--speed', help='record the clip at this speed multiple', type=int, default=1)
|
||||
p.add_argument('-s', '--start', help='start clipping at <start> seconds', type=int)
|
||||
p.add_argument('-t', '--title', help='overlay this title on the video (e.g. "Chill driving across the Golden Gate Bridge")', type=validate_title)
|
||||
p.add_argument('-z', '--developer', help='developer', type=int, default=0)
|
||||
args = parse_args(p)
|
||||
exit_code = 1
|
||||
try:
|
||||
clip(
|
||||
data_dir=args.data_dir,
|
||||
quality=args.quality,
|
||||
wide=args.wide,
|
||||
prefix=args.prefix,
|
||||
route=args.route,
|
||||
out=args.output,
|
||||
@@ -296,6 +311,7 @@ def main():
|
||||
speed=args.speed,
|
||||
target_mb=args.file_size,
|
||||
title=args.title,
|
||||
developer=args.developer,
|
||||
)
|
||||
exit_code = 0
|
||||
except KeyboardInterrupt as e:
|
||||
|
||||
@@ -191,6 +191,7 @@ void Replay::startStream(const std::shared_ptr<Segment> segment) {
|
||||
auto bytes = words.asBytes();
|
||||
Params().put("CarParams", (const char *)bytes.begin(), bytes.size());
|
||||
Params().put("CarParamsPersistent", (const char *)bytes.begin(), bytes.size());
|
||||
publishMessage(&(*it));
|
||||
} else {
|
||||
rWarning("failed to read CarParams from current segment");
|
||||
}
|
||||
|
||||
63
uv.lock
generated
63
uv.lock
generated
@@ -195,6 +195,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/64/63dbfdd83b31200ac58820a7951ddfdeed1fbee9285b0f3eae12d1357155/azure_storage_blob-12.26.0-py3-none-any.whl", hash = "sha256:8c5631b8b22b4f53ec5fff2f3bededf34cfef111e2af613ad42c9e6de00a77fe", size = 412907, upload-time = "2025-07-16T21:34:09.367Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blinker"
|
||||
version = "1.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "casadi"
|
||||
version = "3.7.1"
|
||||
@@ -475,6 +484,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/47/ef/4cb333825d10317a36a1154341ba37e6e9c087bac99c1990ef07ffdb376f/dictdiffer-0.9.0-py2.py3-none-any.whl", hash = "sha256:442bfc693cfcadaf46674575d2eba1c53b42f5e404218ca2c2ff549f2df56595", size = 16754, upload-time = "2021-07-22T13:24:26.783Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-py"
|
||||
version = "2.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7f/dd/5817c7af5e614e45cdf38cbf6c3f4597590c442822a648121a34dee7fa0f/discord_py-2.5.2.tar.gz", hash = "sha256:01cd362023bfea1a4a1d43f5280b5ef00cad2c7eba80098909f98bf28e578524", size = 1054879, upload-time = "2025-03-05T01:15:29.798Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/57/a8/dc908a0fe4cd7e3950c9fa6906f7bf2e5d92d36b432f84897185e1b77138/discord_py-2.5.2-py3-none-any.whl", hash = "sha256:81f23a17c50509ffebe0668441cb80c139e74da5115305f70e27ce821361295a", size = 1155105, upload-time = "2025-03-05T01:15:27.323Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dnspython"
|
||||
version = "2.7.0"
|
||||
@@ -523,6 +544,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/42/14/42b2651a2f46b022ccd948bca9f2d5af0fd8929c4eec235b8d6d844fbe67/filelock-3.19.1-py3-none-any.whl", hash = "sha256:d38e30481def20772f5baf097c122c3babc4fcdb7e14e57049eb9d88c6dc017d", size = 15988, upload-time = "2025-08-14T16:56:01.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
{ name = "click" },
|
||||
{ name = "itsdangerous" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "markupsafe" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/de/e47735752347f4128bcf354e0da07ef311a78244eba9e3dc1d4a5ab21a98/flask-3.1.1.tar.gz", hash = "sha256:284c7b8f2f58cb737f0cf1c30fd7eaf0ccfcde196099d24ecede3fc2005aa59e", size = 753440, upload-time = "2025-05-13T15:01:17.447Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/68/9d4508e893976286d2ead7f8f571314af6c2037af34853a30fd769c02e9d/flask-3.1.1-py3-none-any.whl", hash = "sha256:07aae2bb5eaf77993ef57e357491839f5fd9f4dc281593a81a9e4d79a24f295c", size = 103305, upload-time = "2025-05-13T15:01:15.591Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fonttools"
|
||||
version = "4.59.2"
|
||||
@@ -705,6 +743,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/aa/0aca39a37d3c7eb941ba736ede56d689e7be91cab5d9ca846bde3999eba6/isodate-0.7.2-py3-none-any.whl", hash = "sha256:28009937d8031054830160fce6d409ed342816b543597cece116d966c6d99e15", size = 22320, upload-time = "2024-10-08T23:04:09.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itsdangerous"
|
||||
version = "2.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jeepney"
|
||||
version = "0.9.0"
|
||||
@@ -1261,6 +1308,8 @@ dependencies = [
|
||||
{ name = "cffi" },
|
||||
{ name = "crcmod" },
|
||||
{ name = "cython" },
|
||||
{ name = "discord-py" },
|
||||
{ name = "flask" },
|
||||
{ name = "dearpygui" },
|
||||
{ name = "future-fstrings" },
|
||||
{ name = "inputs" },
|
||||
@@ -1355,6 +1404,8 @@ requires-dist = [
|
||||
{ name = "dbus-next", marker = "extra == 'dev'" },
|
||||
{ name = "dearpygui", specifier = ">=2.1.0" },
|
||||
{ name = "dictdiffer", marker = "extra == 'dev'" },
|
||||
{ name = "discord-py" },
|
||||
{ name = "flask" },
|
||||
{ name = "future-fstrings" },
|
||||
{ name = "hypothesis", marker = "extra == 'testing'", specifier = "==6.47.*" },
|
||||
{ name = "inputs" },
|
||||
@@ -4942,6 +4993,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/84/44687a29792a70e111c5c477230a72c4b957d88d16141199bf9acb7537a3/websocket_client-1.8.0-py3-none-any.whl", hash = "sha256:17b44cc997f5c498e809b22cdf2d9c7a9e71c02c8cc2b6c56e7c2d1239bfa526", size = 58826, upload-time = "2024-04-23T22:16:14.422Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9f/69/83029f1f6300c5fb2471d621ab06f6ec6b3324685a2ce0f9777fd4a8b71e/werkzeug-3.1.3.tar.gz", hash = "sha256:60723ce945c19328679790e3282cc758aa4a6040e4bb330f53d30fa546d44746", size = 806925, upload-time = "2024-11-08T15:52:18.093Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/52/24/ab44c871b0f07f491e5d2ad12c9bd7358e527510618cb1b803a88e986db1/werkzeug-3.1.3-py3-none-any.whl", hash = "sha256:54b78bf3716d19a65be4fceccc0d1d7b89e608834989dfae50ea87564639213e", size = 224498, upload-time = "2024-11-08T15:52:16.132Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.2.0"
|
||||
|
||||
Reference in New Issue
Block a user