diff --git a/.gitignore b/.gitignore index 65affc67..58120f11 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,10 @@ static/drf-yasg cms/local_settings.py deploy/docker/local_settings.py yt.readme.md +/frontend-tools/video-editor/node_modules +/frontend-tools/video-editor/client/node_modules +/static_collected +/frontend-tools/video-editor-v1 +frontend-tools/.DS_Store +static/video_editor/videos/sample-video-30s.mp4 +static/video_editor/videos/sample-video-37s.mp4 diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 00000000..f59ec20a --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +* \ No newline at end of file diff --git a/README.md b/README.md index 45308015..a77de3e6 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ A demo is available at https://demo.mediacms.io - **Multiple media types support**: video, audio, image, pdf - **Multiple media classification options**: categories, tags and custom - **Multiple media sharing options**: social media share, videos embed code generation +- **Video Trimmer**: trim video, replace, save as new or create segments - **Role-Based Access Control (RBAC)**: create RBAC categories and connect users to groups with view/edit access on their media - **SAML support**: with ability to add mappings to system roles and groups - **Easy media searching**: enriched with live search functionality diff --git a/cms/settings.py b/cms/settings.py index 359d75b3..cd8918e1 100644 --- a/cms/settings.py +++ b/cms/settings.py @@ -497,7 +497,9 @@ USE_RBAC = False USE_IDENTITY_PROVIDERS = False JAZZMIN_UI_TWEAKS = {"theme": "flatly"} +USE_ROUNDED_CORNERS = True +ALLOW_VIDEO_TRIMMER = True try: # keep a local_settings.py file for local overrides from .local_settings import * # noqa @@ -508,6 +510,8 @@ except ImportError: # local_settings not in use pass +# Don't add new settings below that could be overridden in local_settings.py!!! + if "http" not in FRONTEND_HOST: # FRONTEND_HOST needs a http:// preffix FRONTEND_HOST = f"http://{FRONTEND_HOST}" # noqa diff --git a/cms/version.py b/cms/version.py index 762fa9c4..d8fc4261 100644 --- a/cms/version.py +++ b/cms/version.py @@ -1 +1 @@ -VERSION = "5.0.1" +VERSION = "6.0.0" diff --git a/deploy/scripts/build_and_deploy.sh b/deploy/scripts/build_and_deploy.sh new file mode 100644 index 00000000..53e6e387 --- /dev/null +++ b/deploy/scripts/build_and_deploy.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# This script builds the video editor package and deploys the frontend assets to the static directory. + +# Exit on any error +set -e + +echo "Starting build process..." + +# Build video editor package +echo "Building video editor package..." +cd frontend-tools/video-editor +yarn build:django +cd ../../ + +# Run npm build in the frontend container +echo "Building frontend assets..." +docker compose -f docker-compose-dev.yaml exec frontend npm run dist + +# Copy static assets to the static directory +echo "Copying static assets..." +cp -r frontend/dist/static/* static/ + +# Restart the web service +echo "Restarting web service..." +docker compose -f docker-compose-dev.yaml restart web + +echo "Build and deployment completed successfully!" \ No newline at end of file diff --git a/docker-compose/docker-compose-dev-updated.yaml b/docker-compose/docker-compose-dev-updated.yaml index 5bac9c06..f055661f 100644 --- a/docker-compose/docker-compose-dev-updated.yaml +++ b/docker-compose/docker-compose-dev-updated.yaml @@ -1,15 +1,16 @@ +name: mediacms-dev services: migrations: platform: linux/amd64 build: - context: . - dockerfile: ./Dockerfile + context: .. + dockerfile: Dockerfile args: - DEVELOPMENT_MODE=True image: mediacms/mediacms:latest volumes: - - ./:/home/mediacms.io/mediacms/ - command: "./deploy/docker/prestart.sh" + - ../:/home/mediacms.io/mediacms/ + command: "/home/mediacms.io/mediacms/deploy/docker/prestart.sh" environment: DEVELOPMENT_MODE: True ENABLE_UWSGI: 'no' @@ -95,13 +96,13 @@ services: ports: - "80:80" volumes: - - ./:/home/mediacms.io/mediacms/ + - ../:/home/mediacms.io/mediacms/ depends_on: - migrations db: image: postgres:17.2-alpine volumes: - - ./postgres_data:/var/lib/postgresql/data/ + - ../postgres_data:/var/lib/postgresql/data/ restart: always environment: POSTGRES_USER: mediacms @@ -127,7 +128,7 @@ services: deploy: replicas: 1 volumes: - - ./:/home/mediacms.io/mediacms/ + - ../:/home/mediacms.io/mediacms/ environment: ENABLE_UWSGI: 'no' ENABLE_NGINX: 'no' diff --git a/docs/admins_docs.md b/docs/admins_docs.md index 16aa23b8..095ec8d1 100644 --- a/docs/admins_docs.md +++ b/docs/admins_docs.md @@ -809,14 +809,8 @@ This will disable the transcoding process and only the original file will be sho ## 19. Rounded corners on videos -By default the video player and media items are now having rounded corners, on larger screens (not in mobile). If you don't like this change, remove the `border-radius` added on the following files: +By default the video player and media items are now having rounded corners, on larger screens (not in mobile). If you don't like this change, set `USE_ROUNDED_CORNERS = False` in `local_settings.py`. -``` -frontend/src/static/css/_extra.css -frontend/src/static/js/components/list-item/Item.scss -frontend/src/static/js/components/media-page/MediaPage.scss -``` -you now have to re-run the frontend build in order to see the changes (check docs/dev_exp.md) ## 20. Translations @@ -879,7 +873,7 @@ By default there are 3 statuses for any Media that lives on the system, public, Now user can view the Media even if it is in private state. User also sees all media in Category page -When user is added to group, they can be set as Member, Contributor, Manager. +When user is added to group, they can be set as Member, Contributor, Manager. - Member: user can view media that are published on one or more categories that this group is associated with - Contributor: besides viewing, user can also edit the Media in a category associated with this Group. They can also publish Media to this category @@ -891,17 +885,17 @@ Use cases facilitated with RBAC: - viewing all media of a category: if RBAC is enabled, and user visits a Category, they are able to see the listing of all media that are published in this category, independent of their state, provided that the category is associated with a group that the user is member of - viewing all categories associated with groups the user is member of: if RBAC is enabled, and user visits the listing of categories, they can view all categories that are associated with a group the user is member -How to enable RBAC support: +How to enable RBAC support: ``` USE_RBAC = True ``` -on `local_settings.py` and restart the instance. +on `local_settings.py` and restart the instance. ## 23. SAML setup -SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups. +SAML authentication is supported along with the option to utilize the SAML response and do useful things as setting up the user role in MediaCMS or participation in groups. To enable SAML support, edit local_settings.py and set the following options: @@ -942,8 +936,8 @@ Select the SAML Configurations tab, create a new one and set: 1. **IDP ID**: Must be a URL 2. **IDP Certificate**: x509cert from your SAML provider -3. **SSO URL**: -4. **SLO URL**: +3. **SSO URL**: +4. **SLO URL**: 5. **SP Metadata URL**: The metadata URL that the IDP will utilize. This can be https://{portal}/saml/metadata and is autogenerated by MediaCMS - Step 3: Set other Options @@ -971,5 +965,5 @@ to enable the identity providers, set the following setting on `local_settings.p USE_IDENTITY_PROVIDERS = True ``` -Visiting the admin, you will see the Identity Providers tab and you can add one. +Visiting the admin, you will see the Identity Providers tab and you can add one. diff --git a/docs/user_docs.md b/docs/user_docs.md index 8e8afb6f..da23f5a6 100644 --- a/docs/user_docs.md +++ b/docs/user_docs.md @@ -11,6 +11,7 @@ - [Share media](#share-media) - [Embed media](#embed-media) - [Customize my profile options](#customize-my-profile-options) +- [Trim videos](#trim-videos) ## Uploading media @@ -198,7 +199,7 @@ You can now watch the captions/subtitles play back in the video player - and tog

-## Using Timestamps for sharing +## Using Timestamps for sharing ### Using Timestamp in the URL @@ -240,7 +241,7 @@ Comments send with mentions will contain a link to the user page, and can be set When enabled, comments including a timestamp will also be displayed in the current video Timebar as a little colorful dot. The comment can be previewed by hovering the dot (left image) and it will be displayed on top of the video when reaching the correct time (right image). Only comments with correct timestamps formats (HH:MM:SS or MM:SS) will be picked up and appear in the Timebar. - +

Comment preview on hover Comment shown when the timestamp is reached @@ -257,3 +258,7 @@ How to use the embed media option ## Customize my profile options Customize profile and channel + +## Trim videos +Once a video is uploaded, you can trim it to create a new video or to replace the original one. You can also create segments of the video, which will be available as separate videos. Edit the video and click on the "Trime Video" option. If the original video has finished processing (encodings are created for all resolutions), then this is an action that runs instantly. If the original video hasn't processed, which is the case when you upload a video and edit it right away, then the trim action will trigger processing of the video and will take some time to finish. In all cases, you get to see the original video (or the trimmed versions) immediately, so you are sure of what you have uploaded or trimmed, with a message that the video is being processed. + diff --git a/files/admin.py b/files/admin.py index 47a0b6d5..e346ab4e 100644 --- a/files/admin.py +++ b/files/admin.py @@ -15,6 +15,7 @@ from .models import ( Media, Subtitle, Tag, + VideoTrimRequest, ) @@ -199,6 +200,10 @@ class SubtitleAdmin(admin.ModelAdmin): pass +class VideoTrimRequestAdmin(admin.ModelAdmin): + pass + + class EncodingAdmin(admin.ModelAdmin): list_display = ["get_title", "chunk", "profile", "progress", "status", "has_file"] list_filter = ["chunk", "profile", "status"] @@ -222,5 +227,6 @@ admin.site.register(Category, CategoryAdmin) admin.site.register(Tag, TagAdmin) admin.site.register(Subtitle, SubtitleAdmin) admin.site.register(Language, LanguageAdmin) +admin.site.register(VideoTrimRequest, VideoTrimRequestAdmin) Media._meta.app_config.verbose_name = "Media" diff --git a/files/context_processors.py b/files/context_processors.py index a19785da..2e540e62 100644 --- a/files/context_processors.py +++ b/files/context_processors.py @@ -35,6 +35,9 @@ def stuff(request): ret["TRANSLATION"] = get_translation(request.LANGUAGE_CODE) ret["REPLACEMENTS"] = get_translation_strings(request.LANGUAGE_CODE) ret["USE_SAML"] = settings.USE_SAML + ret["USE_RBAC"] = settings.USE_RBAC + ret["USE_ROUNDED_CORNERS"] = settings.USE_ROUNDED_CORNERS + if request.user.is_superuser: ret["DJANGO_ADMIN_URL"] = settings.DJANGO_ADMIN_URL diff --git a/files/forms.py b/files/forms.py index fdc75ba6..b48704ff 100644 --- a/files/forms.py +++ b/files/forms.py @@ -1,49 +1,128 @@ +from crispy_forms.bootstrap import FormActions +from crispy_forms.helper import FormHelper +from crispy_forms.layout import Field, Layout, Submit from django import forms from django.conf import settings from .methods import get_next_state, is_mediacms_editor -from .models import Category, Media, Subtitle +from .models import MEDIA_STATES, Category, Media, Subtitle + + +class CustomField(Field): + template = 'cms/crispy_custom_field.html' class MultipleSelect(forms.CheckboxSelectMultiple): input_type = "checkbox" -class MediaForm(forms.ModelForm): - new_tags = forms.CharField(label="Tags", help_text="a comma separated list of new tags.", required=False) +class MediaMetadataForm(forms.ModelForm): + new_tags = forms.CharField(label="Tags", help_text="a comma separated list of tags.", required=False) class Meta: model = Media fields = ( "title", - "category", "new_tags", "add_date", "uploaded_poster", "description", - "state", "enable_comments", - "featured", "thumbnail_time", - "reported_times", - "is_reviewed", - "allow_download", ) + widgets = { - "tags": MultipleSelect(), + "new_tags": MultipleSelect(), + "description": forms.Textarea(attrs={'rows': 4}), + "add_date": forms.DateInput(attrs={'type': 'date'}), + "thumbnail_time": forms.NumberInput(attrs={'min': 0, 'step': 0.1}), + } + labels = { + "uploaded_poster": "Poster Image", + "thumbnail_time": "Thumbnail Time (seconds)", + } + help_texts = { + "title": "", + "thumbnail_time": "Select the time in seconds for the video thumbnail", + "uploaded_poster": "Maximum file size: 5MB", } def __init__(self, user, *args, **kwargs): self.user = user - super(MediaForm, self).__init__(*args, **kwargs) + super(MediaMetadataForm, self).__init__(*args, **kwargs) if self.instance.media_type != "video": self.fields.pop("thumbnail_time") + if self.instance.media_type == "image": + self.fields.pop("uploaded_poster") + + self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()]) + + self.helper = FormHelper() + self.helper.form_tag = True + self.helper.form_class = 'post-form' + self.helper.form_method = 'post' + self.helper.form_enctype = "multipart/form-data" + self.helper.form_show_errors = False + self.helper.layout = Layout( + CustomField('title'), + CustomField('new_tags'), + CustomField('add_date'), + CustomField('description'), + CustomField('uploaded_poster'), + CustomField('enable_comments'), + ) + + if self.instance.media_type == "video": + self.helper.layout.append(CustomField('thumbnail_time')) + + self.helper.layout.append(FormActions(Submit('submit', 'Update Media', css_class='primaryAction'))) + + def clean_uploaded_poster(self): + image = self.cleaned_data.get("uploaded_poster", False) + if image: + if image.size > 5 * 1024 * 1024: + raise forms.ValidationError("Image file too large ( > 5mb )") + return image + + def save(self, *args, **kwargs): + data = self.cleaned_data # noqa + + media = super(MediaMetadataForm, self).save(*args, **kwargs) + return media + + +class MediaPublishForm(forms.ModelForm): + confirm_state = forms.BooleanField(required=False, initial=False, label="Acknowledge sharing status", help_text="") + + class Meta: + model = Media + fields = ( + "category", + "state", + "featured", + "reported_times", + "is_reviewed", + "allow_download", + ) + + widgets = { + "category": MultipleSelect(), + } + + def __init__(self, user, *args, **kwargs): + self.user = user + super(MediaPublishForm, self).__init__(*args, **kwargs) if not is_mediacms_editor(user): - self.fields.pop("featured") - self.fields.pop("reported_times") - self.fields.pop("is_reviewed") - # if settings.PORTAL_WORKFLOW == 'private': - # self.fields.pop("state") + for field in ["featured", "reported_times", "is_reviewed"]: + self.fields[field].disabled = True + self.fields[field].widget.attrs['class'] = 'read-only-field' + self.fields[field].widget.attrs['title'] = "This field can only be modified by MediaCMS admins or editors" + + if settings.PORTAL_WORKFLOW not in ["public"]: + valid_states = ["unlisted", "private"] + if self.instance.state and self.instance.state not in valid_states: + valid_states.append(self.instance.state) + self.fields["state"].choices = [(state, dict(MEDIA_STATES).get(state, state)) for state in valid_states] if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields: if is_mediacms_editor(user): @@ -61,14 +140,52 @@ class MediaForm(forms.ModelForm): self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title') - self.fields["new_tags"].initial = ", ".join([tag.title for tag in self.instance.tags.all()]) + self.helper = FormHelper() + self.helper.form_tag = True + self.helper.form_class = 'post-form' + self.helper.form_method = 'post' + self.helper.form_enctype = "multipart/form-data" + self.helper.form_show_errors = False + self.helper.layout = Layout( + CustomField('category'), + CustomField('state'), + CustomField('featured'), + CustomField('reported_times'), + CustomField('is_reviewed'), + CustomField('allow_download'), + ) - def clean_uploaded_poster(self): - image = self.cleaned_data.get("uploaded_poster", False) - if image: - if image.size > 5 * 1024 * 1024: - raise forms.ValidationError("Image file too large ( > 5mb )") - return image + self.helper.layout.append(FormActions(Submit('submit', 'Publish Media', css_class='primaryAction'))) + + def clean(self): + cleaned_data = super().clean() + state = cleaned_data.get("state") + categories = cleaned_data.get("category") + + if getattr(settings, 'USE_RBAC', False) and 'category' in self.fields: + rbac_categories = categories.filter(is_rbac_category=True).values_list('title', flat=True) + + if rbac_categories and state in ['private', 'unlisted']: + # Make the confirm_state field visible and add it to the layout + self.fields['confirm_state'].widget = forms.CheckboxInput() + + # add it after the state field + state_index = None + for i, layout_item in enumerate(self.helper.layout): + if isinstance(layout_item, CustomField) and layout_item.fields[0] == 'state': + state_index = i + break + + if state_index: + layout_items = list(self.helper.layout) + layout_items.insert(state_index + 1, CustomField('confirm_state')) + self.helper.layout = Layout(*layout_items) + + if not cleaned_data.get('confirm_state'): + error_message = f"I understand that although media state is {state}, the media is also shared with users that have access to the following categories: {', '.join(rbac_categories)}" + self.add_error('confirm_state', error_message) + + return cleaned_data def save(self, *args, **kwargs): data = self.cleaned_data @@ -76,7 +193,8 @@ class MediaForm(forms.ModelForm): if state != self.initial["state"]: self.instance.state = get_next_state(self.user, self.initial["state"], self.instance.state) - media = super(MediaForm, self).save(*args, **kwargs) + media = super(MediaPublishForm, self).save(*args, **kwargs) + return media diff --git a/files/helpers.py b/files/helpers.py index a6914cb2..d7110e36 100644 --- a/files/helpers.py +++ b/files/helpers.py @@ -3,6 +3,7 @@ import hashlib import json +import logging import os import random import shutil @@ -15,6 +16,9 @@ from django.conf import settings CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +logger = logging.getLogger(__name__) + + CRF_ENCODING_NUM_SECONDS = 2 # 0 * 60 # videos with greater duration will get # CRF encoding and not two-pass # Encoding individual chunks may yield quality variations if you use a @@ -787,6 +791,179 @@ def clean_query(query): return query.lower() +def timestamp_to_seconds(timestamp): + """Convert a timestamp in format HH:MM:SS.mmm to seconds + + Args: + timestamp (str): Timestamp in format HH:MM:SS.mmm + + Returns: + float: Timestamp in seconds + """ + h, m, s = timestamp.split(':') + s, ms = s.split('.') + return int(h) * 3600 + int(m) * 60 + int(s) + float('0.' + ms) + + +def seconds_to_timestamp(seconds): + """Convert seconds to timestamp in format HH:MM:SS.mmm + + Args: + seconds (float): Time in seconds + + Returns: + str: Timestamp in format HH:MM:SS.mmm + """ + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + seconds_remainder = seconds % 60 + seconds_int = int(seconds_remainder) + milliseconds = int((seconds_remainder - seconds_int) * 1000) + + return f"{hours:02d}:{minutes:02d}:{seconds_int:02d}.{milliseconds:03d}" # noqa + + +def get_trim_timestamps(media_file_path, timestamps_list, run_ffprobe=False): + """Process a list of timestamps to align start times with I-frames for better video trimming + + Args: + media_file_path (str): Path to the media file + timestamps_list (list): List of dictionaries with startTime and endTime + + Returns: + list: Processed timestamps with adjusted startTime values + """ + if not isinstance(timestamps_list, list): + return [] + + timestamps_results = [] + timestamps_to_process = [] + + for item in timestamps_list: + if isinstance(item, dict) and 'startTime' in item and 'endTime' in item: + timestamps_to_process.append(item) + + if not timestamps_to_process: + return [] + + # just a single timestamp with no startTime, no need to process + if len(timestamps_to_process) == 1 and timestamps_to_process[0]['startTime'] == "00:00:00.000": + return timestamps_list + + # Process each timestamp + for item in timestamps_to_process: + startTime = item['startTime'] + endTime = item['endTime'] + + # with ffmpeg -ss -i that is getting run, there is no need to call ffprobe to find the I-frame, + # as ffmpeg will do that. Keeping this for now in case it is needed + + i_frames = [] + if run_ffprobe: + SEC_TO_SUBTRACT = 10 + start_seconds = timestamp_to_seconds(startTime) + search_start = max(0, start_seconds - SEC_TO_SUBTRACT) + + # Create ffprobe command to find nearest I-frame + cmd = [ + settings.FFPROBE_COMMAND, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "frame=pts_time,pict_type", + "-of", + "csv=p=0", + "-read_intervals", + f"{search_start}%{startTime}", + media_file_path, + ] + cmd = [str(s) for s in cmd] + logger.info(f"trim cmd: {cmd}") + + stdout = run_command(cmd).get("out") + + if stdout: + for line in stdout.strip().split('\n'): + if line and line.endswith(',I'): + i_frames.append(line.replace(',I', '')) + + if i_frames: + adjusted_startTime = seconds_to_timestamp(float(i_frames[-1])) + + if not i_frames: + adjusted_startTime = startTime + + timestamps_results.append({'startTime': adjusted_startTime, 'endTime': endTime}) + + return timestamps_results + + +def trim_video_method(media_file_path, timestamps_list): + """Trim a video file based on a list of timestamps + + Args: + media_file_path (str): Path to the media file + timestamps_list (list): List of dictionaries with startTime and endTime + + Returns: + bool: True if successful, False otherwise + """ + if not isinstance(timestamps_list, list) or not timestamps_list: + return False + + if not os.path.exists(media_file_path): + return False + + with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as temp_dir: + output_file = os.path.join(temp_dir, "output.mp4") + + segment_files = [] + for i, item in enumerate(timestamps_list): + start_time = timestamp_to_seconds(item['startTime']) + end_time = timestamp_to_seconds(item['endTime']) + duration = end_time - start_time + + # For single timestamp, we can use the output file directly + # For multiple timestamps, we need to create segment files + segment_file = output_file if len(timestamps_list) == 1 else os.path.join(temp_dir, f"segment_{i}.mp4") + + cmd = [settings.FFMPEG_COMMAND, "-y", "-ss", str(item['startTime']), "-i", media_file_path, "-t", str(duration), "-c", "copy", "-avoid_negative_ts", "1", segment_file] + + result = run_command(cmd) # noqa + + if os.path.exists(segment_file) and os.path.getsize(segment_file) > 0: + if len(timestamps_list) > 1: + segment_files.append(segment_file) + else: + return False + + if len(timestamps_list) > 1: + if not segment_files: + return False + + concat_list_path = os.path.join(temp_dir, "concat_list.txt") + with open(concat_list_path, "w") as f: + for segment in segment_files: + f.write(f"file '{segment}'\n") + concat_cmd = [settings.FFMPEG_COMMAND, "-y", "-f", "concat", "-safe", "0", "-i", concat_list_path, "-c", "copy", output_file] + + concat_result = run_command(concat_cmd) # noqa + + if not os.path.exists(output_file) or os.path.getsize(output_file) == 0: + return False + + # Replace the original file with the trimmed version + try: + rm_file(media_file_path) + shutil.copy2(output_file, media_file_path) + return True + except Exception as e: + logger.info(f"Failed to replace original file: {str(e)}") + return False + + def get_alphanumeric_only(string): """Returns a query that contains only alphanumeric characters This include characters other than the English alphabet too diff --git a/files/methods.py b/files/methods.py index 2fdcaa20..9696244c 100644 --- a/files/methods.py +++ b/files/methods.py @@ -5,16 +5,19 @@ import itertools import logging import random import re +import subprocess from datetime import datetime from django.conf import settings from django.core.cache import cache +from django.core.files import File from django.core.mail import EmailMessage from django.db.models import Q +from django.utils import timezone from cms import celery_app -from . import models +from . import helpers, models from .helpers import mask_ip logger = logging.getLogger(__name__) @@ -262,7 +265,7 @@ def show_related_media_content(media, request, limit): "user_featured", "-user_featured", ] - # TODO: MAke this mess more readable, and add TAGS support - aka related + # TODO: Make this mess more readable, and add TAGS support - aka related # tags rather than random media if len(m) < limit: category = media.category.first() @@ -398,6 +401,111 @@ def clean_comment(raw_comment): return cleaned_comment +def kill_ffmpeg_process(filepath): + """Kill ffmpeg process that is processing a specific file + + Args: + filepath: Path to the file being processed by ffmpeg + + Returns: + subprocess.CompletedProcess: Result of the kill command + """ + if not filepath: + return False + cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath + result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) + pid = result.stdout.decode("utf-8").strip() + if pid: + cmd = "kill -9 %s" % pid + result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) + return result + + +def copy_video(original_media, copy_encodings=True, title_suffix="(Trimmed)"): + """Create a copy of a media object + + Args: + original_media: Original Media object to copy + copy_encodings: Whether to copy the encodings too + + Returns: + New Media object + """ + + with open(original_media.media_file.path, "rb") as f: + myfile = File(f) + new_media = models.Media( + media_file=myfile, + title=f"{original_media.title} {title_suffix}", + description=original_media.description, + user=original_media.user, + media_type="video", + enable_comments=original_media.enable_comments, + allow_download=original_media.allow_download, + state=original_media.state, + is_reviewed=original_media.is_reviewed, + encoding_status=original_media.encoding_status, + listable=original_media.listable, + add_date=timezone.now(), + video_height=original_media.video_height, + media_info=original_media.media_info, + ) + models.Media.objects.bulk_create([new_media]) + # avoids calling signals since signals will call media_init and we don't want that + + if copy_encodings: + for encoding in original_media.encodings.filter(chunk=False, status="success"): + if encoding.media_file: + with open(encoding.media_file.path, "rb") as f: + myfile = File(f) + new_encoding = models.Encoding( + media_file=myfile, media=new_media, profile=encoding.profile, status="success", progress=100, chunk=False, logs=f"Copied from encoding {encoding.id}" + ) + models.Encoding.objects.bulk_create([new_encoding]) + # avoids calling signals as this is still not ready + + # Copy categories and tags + for category in original_media.category.all(): + new_media.category.add(category) + + for tag in original_media.tags.all(): + new_media.tags.add(tag) + + if original_media.thumbnail: + with open(original_media.thumbnail.path, 'rb') as f: + thumbnail_name = helpers.get_file_name(original_media.thumbnail.path) + new_media.thumbnail.save(thumbnail_name, File(f)) + + if original_media.poster: + with open(original_media.poster.path, 'rb') as f: + poster_name = helpers.get_file_name(original_media.poster.path) + new_media.poster.save(poster_name, File(f)) + + return new_media + + +def create_video_trim_request(media, data): + """Create a video trim request for a media + + Args: + media: Media object + data: Dictionary with trim request data + + Returns: + VideoTrimRequest object + """ + + video_action = "replace" + if data.get('saveIndividualSegments'): + video_action = "create_segments" + elif data.get('saveAsCopy'): + video_action = "save_new" + + video_trim_request = models.VideoTrimRequest.objects.create(media=media, status="initial", video_action=video_action, media_trim_style='no_encoding', timestamps=data.get('segments', {})) + + return video_trim_request + + def list_tasks(): """Lists celery tasks To be used in an admin dashboard @@ -448,3 +556,14 @@ def list_tasks(): ret["task_ids"] = task_ids ret["media_profile_pairs"] = media_profile_pairs return ret + + +def handle_video_chapters(media, chapters): + video_chapter = models.VideoChapterData.objects.filter(media=media).first() + if video_chapter: + video_chapter.data = chapters + video_chapter.save() + else: + video_chapter = models.VideoChapterData.objects.create(media=media, data=chapters) + + return media.chapter_data diff --git a/files/migrations/0007_alter_media_state_videochapterdata.py b/files/migrations/0007_alter_media_state_videochapterdata.py new file mode 100644 index 00000000..f86965d0 --- /dev/null +++ b/files/migrations/0007_alter_media_state_videochapterdata.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.6 on 2025-04-15 07:26 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('files', '0006_alter_category_title'), + ] + + operations = [ + migrations.CreateModel( + name='VideoChapterData', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.JSONField(help_text='Chapter data')), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='chapters', to='files.media')), + ], + options={ + 'unique_together': {('media',)}, + }, + ), + ] diff --git a/files/migrations/0008_alter_media_state_videotrimrequest.py b/files/migrations/0008_alter_media_state_videotrimrequest.py new file mode 100644 index 00000000..cdd906e6 --- /dev/null +++ b/files/migrations/0008_alter_media_state_videotrimrequest.py @@ -0,0 +1,30 @@ +# Generated by Django 5.1.6 on 2025-05-02 14:23 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('files', '0007_alter_media_state_videochapterdata'), + ] + + operations = [ + migrations.AlterField( + model_name='media', + name='state', + field=models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('unlisted', 'Unlisted')], db_index=True, default='public', help_text='state of Media', max_length=20), + ), + migrations.CreateModel( + name='VideoTrimRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('status', models.CharField(choices=[('initial', 'Initial'), ('running', 'Running'), ('success', 'Success'), ('fail', 'Fail')], default='initial', max_length=20)), + ('add_date', models.DateTimeField(auto_now_add=True)), + ('video_action', models.CharField(choices=[('replace', 'Replace Original'), ('save_new', 'Save as New'), ('create_segments', 'Create Segments')], max_length=20)), + ('media_trim_style', models.CharField(choices=[('no_encoding', 'No Encoding'), ('precise', 'Precise')], default='no_encoding', max_length=20)), + ('timestamps', models.JSONField(help_text='Timestamps for trimming')), + ('media', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='trim_requests', to='files.media')), + ], + ), + ] diff --git a/files/models.py b/files/models.py index 05f18e42..9c4d8510 100644 --- a/files/models.py +++ b/files/models.py @@ -387,6 +387,7 @@ class Media(models.Model): Update SearchVector field of SearchModel using raw SQL search field is used to store SearchVector """ + db_table = self._meta.db_table # first get anything interesting out of the media @@ -524,8 +525,12 @@ class Media(models.Model): with open(self.media_file.path, "rb") as f: myfile = File(f) thumbnail_name = helpers.get_file_name(self.media_file.path) + ".jpg" - self.thumbnail.save(content=myfile, name=thumbnail_name) - self.poster.save(content=myfile, name=thumbnail_name) + # avoid saving the whole object, because something might have been changed + # on the meanwhile + self.thumbnail.save(content=myfile, name=thumbnail_name, save=False) + self.poster.save(content=myfile, name=thumbnail_name, save=False) + self.save(update_fields=["thumbnail", "poster"]) + return True def produce_thumbnails_from_video(self): @@ -559,8 +564,11 @@ class Media(models.Model): with open(tf, "rb") as f: myfile = File(f) thumbnail_name = helpers.get_file_name(self.media_file.path) + ".jpg" - self.thumbnail.save(content=myfile, name=thumbnail_name) - self.poster.save(content=myfile, name=thumbnail_name) + # avoid saving the whole object, because something might have been changed + # on the meanwhile + self.thumbnail.save(content=myfile, name=thumbnail_name, save=False) + self.poster.save(content=myfile, name=thumbnail_name, save=False) + self.save(update_fields=["thumbnail", "poster"]) helpers.rm_file(tf) return True @@ -637,15 +645,20 @@ class Media(models.Model): self.preview_file_path = "" else: self.preview_file_path = encoding.media_file.path - self.save(update_fields=["listable", "preview_file_path"]) - self.save(update_fields=["encoding_status", "listable"]) + self.save(update_fields=["encoding_status", "listable", "preview_file_path"]) - if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add": + if encoding and encoding.status == "success" and encoding.profile.codec == "h264" and action == "add" and not encoding.chunk: from . import tasks - tasks.create_hls(self.friendly_token) + tasks.create_hls.delay(self.friendly_token) + # TODO: ideally would ensure this is run only at the end when the last encoding is done... + vt_request = VideoTrimRequest.objects.filter(media=self, status="running").first() + if vt_request: + tasks.post_trim_action.delay(self.friendly_token) + vt_request.status = "success" + vt_request.save(update_fields=["status"]) return True def set_encoding_status(self): @@ -667,6 +680,29 @@ class Media(models.Model): return True + @property + def trim_video_url(self): + if self.media_type not in ["video"]: + return None + + ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first() + if ret: + return helpers.url_from_path(ret.media_file.path) + + # showing the original file + return helpers.url_from_path(self.media_file.path) + + @property + def trim_video_path(self): + if self.media_type not in ["video"]: + return None + + ret = self.encodings.filter(status="success", profile__extension='mp4', chunk=False).order_by("-profile__resolution").first() + if ret: + return ret.media_file.path + + return None + @property def encodings_info(self, full=False): """Property used on serializers""" @@ -678,12 +714,17 @@ class Media(models.Model): for key in ENCODE_RESOLUTIONS_KEYS: ret[key] = {} - # if this is enabled, return original file on a way - # that video.js can consume + # if DO_NOT_TRANSCODE_VIDEO enabled, return original file on a way + # that video.js can consume. Or also if encoding_status is running, do the + # same so that the video appears on the player if settings.DO_NOT_TRANSCODE_VIDEO: ret['0-original'] = {"h264": {"url": helpers.url_from_path(self.media_file.path), "status": "success", "progress": 100}} return ret + if self.encoding_status in ["running", "pending"]: + ret['0-original'] = {"h264": {"url": helpers.url_from_path(self.media_file.path), "status": "success", "progress": 100}} + return ret + for encoding in self.encodings.select_related("profile").filter(chunk=False): if encoding.profile.extension == "gif": continue @@ -948,6 +989,19 @@ class Media(models.Model): ) return ret + @property + def video_chapters_folder(self): + custom_folder = f"{settings.THUMBNAIL_UPLOAD_DIR}{self.user.username}/{self.friendly_token}_chapters" + return os.path.join(settings.MEDIA_ROOT, custom_folder) + + @property + def chapter_data(self): + data = [] + chapter_data = self.chapters.first() + if chapter_data: + return chapter_data.chapter_data + return data + class License(models.Model): """A Base license model to be used in Media""" @@ -1184,11 +1238,25 @@ class Encoding(models.Model): super(Encoding, self).save(*args, **kwargs) + def update_size_without_save(self): + """Update the size of an encoding without saving to avoid calling signals""" + if self.media_file: + cmd = ["stat", "-c", "%s", self.media_file.path] + stdout = helpers.run_command(cmd).get("out") + if stdout: + size = int(stdout.strip()) + size = helpers.show_file_size(size) + Encoding.objects.filter(pk=self.pk).update(size=size) + return True + return False + def set_progress(self, progress, commit=True): if isinstance(progress, int): if 0 <= progress <= 100: self.progress = progress - self.save(update_fields=["progress"]) + # save object with filter update + # to avoid calling signals + Encoding.objects.filter(pk=self.pk).update(progress=progress) return True return False @@ -1440,6 +1508,82 @@ class Comment(MPTTModel): return self.get_absolute_url() +class VideoChapterData(models.Model): + data = models.JSONField(null=False, blank=False, help_text="Chapter data") + media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='chapters') + + class Meta: + unique_together = ['media'] + + def save(self, *args, **kwargs): + from . import tasks + + is_new = self.pk is None + if is_new or (not is_new and self._check_data_changed()): + super().save(*args, **kwargs) + tasks.produce_video_chapters.delay(self.pk) + else: + super().save(*args, **kwargs) + + def _check_data_changed(self): + if self.pk: + old_instance = VideoChapterData.objects.get(pk=self.pk) + return old_instance.data != self.data + return False + + @property + def chapter_data(self): + # ensure response is consistent + data = [] + for item in self.data: + if item.get("start") and item.get("title"): + thumbnail = item.get("thumbnail") + if thumbnail: + thumbnail = helpers.url_from_path(thumbnail) + else: + thumbnail = "static/images/chapter_default.jpg" + data.append( + { + "start": item.get("start"), + "title": item.get("title"), + "thumbnail": thumbnail, + } + ) + return data + + +class VideoTrimRequest(models.Model): + """Model to handle video trimming requests""" + + VIDEO_TRIM_STATUS = ( + ("initial", "Initial"), + ("running", "Running"), + ("success", "Success"), + ("fail", "Fail"), + ) + + VIDEO_ACTION_CHOICES = ( + ("replace", "Replace Original"), + ("save_new", "Save as New"), + ("create_segments", "Create Segments"), + ) + + TRIM_STYLE_CHOICES = ( + ("no_encoding", "No Encoding"), + ("precise", "Precise"), + ) + + media = models.ForeignKey('Media', on_delete=models.CASCADE, related_name='trim_requests') + status = models.CharField(max_length=20, choices=VIDEO_TRIM_STATUS, default="initial") + add_date = models.DateTimeField(auto_now_add=True) + video_action = models.CharField(max_length=20, choices=VIDEO_ACTION_CHOICES) + media_trim_style = models.CharField(max_length=20, choices=TRIM_STYLE_CHOICES, default="no_encoding") + timestamps = models.JSONField(null=False, blank=False, help_text="Timestamps for trimming") + + def __str__(self): + return f"Trim request for {self.media.title} ({self.status})" + + @receiver(post_save, sender=Media) def media_save(sender, instance, created, **kwargs): # media_file path is not set correctly until mode is saved @@ -1447,6 +1591,9 @@ def media_save(sender, instance, created, **kwargs): # once model is saved # SOS: do not put anything here, as if more logic is added, # we have to disconnect signal to avoid infinite recursion + if not instance.friendly_token: + return False + if created: from .methods import notify_users @@ -1479,13 +1626,17 @@ def media_file_pre_delete(sender, instance, **kwargs): tag.update_tag_media() +@receiver(post_delete, sender=VideoChapterData) +def videochapterdata_delete(sender, instance, **kwargs): + helpers.rm_dir(instance.media.video_chapters_folder) + + @receiver(post_delete, sender=Media) def media_file_delete(sender, instance, **kwargs): """ Deletes file from filesystem when corresponding `Media` object is deleted. """ - if instance.media_file: helpers.rm_file(instance.media_file.path) if instance.thumbnail: @@ -1501,6 +1652,7 @@ def media_file_delete(sender, instance, **kwargs): if instance.hls_file: p = os.path.dirname(instance.hls_file) helpers.rm_dir(p) + instance.user.update_user_media() # remove extra zombie thumbnails diff --git a/files/serializers.py b/files/serializers.py index cd1f0c79..96a708ff 100644 --- a/files/serializers.py +++ b/files/serializers.py @@ -161,6 +161,7 @@ class SingleMediaSerializer(serializers.ModelSerializer): "hls_info", "license", "subtitles_info", + "chapter_data", "ratings_info", "add_subtitle_url", "allow_download", diff --git a/files/tasks.py b/files/tasks.py index 93ed226f..feed7748 100644 --- a/files/tasks.py +++ b/files/tasks.py @@ -2,13 +2,11 @@ import json import os import re import shutil -import subprocess import tempfile from datetime import datetime, timedelta from celery import Task from celery import shared_task as task -from celery.exceptions import SoftTimeLimitExceeded from celery.signals import task_revoked # from celery.task.control import revoke @@ -16,6 +14,7 @@ from celery.utils.log import get_task_logger from django.conf import settings from django.core.cache import cache from django.core.files import File +from django.db import DatabaseError from django.db.models import Q from actions.models import USER_MEDIA_ACTIONS, MediaAction @@ -28,14 +27,31 @@ from .helpers import ( create_temp_file, get_file_name, get_file_type, + get_trim_timestamps, media_file_info, produce_ffmpeg_commands, produce_friendly_token, rm_file, run_command, + trim_video_method, +) +from .methods import ( + copy_video, + kill_ffmpeg_process, + list_tasks, + notify_users, + pre_save_action, +) +from .models import ( + Category, + EncodeProfile, + Encoding, + Media, + Rating, + Tag, + VideoChapterData, + VideoTrimRequest, ) -from .methods import list_tasks, notify_users, pre_save_action -from .models import Category, EncodeProfile, Encoding, Media, Rating, Tag logger = get_task_logger(__name__) @@ -48,6 +64,69 @@ ERRORS_LIST = [ ] +def handle_pending_running_encodings(media): + """Handle pending and running encodings for a media object. + + we are trimming the original file. If there are encodings in success state, this means that the encoding has run + and has succeeded, so we can keep them (they will be trimmed) or if we dont keep them we dont have to delete them + here + + However for encodings that are in pending or running phase, just delete them + + Args: + media: The media object to handle encodings for + + Returns: + bool: True if any encodings were deleted, False otherwise + """ + encodings = media.encodings.exclude(status="success") + deleted = False + for encoding in encodings: + if encoding.temp_file: + kill_ffmpeg_process(encoding.temp_file) + if encoding.chunk_file_path: + kill_ffmpeg_process(encoding.chunk_file_path) + deleted = True + encoding.delete() + + return deleted + + +def pre_trim_video_actions(media): + # the reason for this function is to perform tasks before trimming a video + + # avoid re-running unnecessary encodings (or chunkize_media, which is the first step for them) + # if the video is already completed + # however if it is a new video (user uploded the video and starts trimming + # before the video is processed), this is necessary, so encode has to be called to give it a chance to encode + + # if a video is fully processed (all encodings are success), or if a video is new, then things are clear + + # HOWEVER there is a race condition and this is that some encodings are success and some are pending/running + # Since we are making speed cutting, we will perform an ffmpeg -c copy on all of them and the result will be + # that they will end up differently cut, because ffmpeg checks for I-frames + # The result is fine if playing the video but is bad in case of HLS + # So we need to delete all encodings inevitably to produce same results, if there are some that are success and some that + # are still not finished. + + profiles = EncodeProfile.objects.filter(active=True, extension='mp4', resolution__lte=media.video_height) + media_encodings = EncodeProfile.objects.filter(encoding__in=media.encodings.filter(status="success", chunk=False), extension='mp4').distinct() + + picked = [] + for profile in profiles: + if profile in media_encodings: + continue + else: + picked.append(profile) + + if picked: + # by calling encode will re-encode all. The logic is explained above... + logger.info(f"Encoding media {media.friendly_token} will have to be performed for all profiles") + media.encode() + + return True + + @task(name="chunkize_media", bind=True, queue="short_tasks", soft_time_limit=60 * 30 * 4) def chunkize_media(self, friendly_token, profiles, force=True): """Break media in chunks and start encoding tasks""" @@ -145,6 +224,7 @@ class EncodingTask(Task): self.encoding.status = "fail" self.encoding.save(update_fields=["status"]) kill_ffmpeg_process(self.encoding.temp_file) + kill_ffmpeg_process(self.encoding.chunk_file_path) if hasattr(self.encoding, "media"): self.encoding.media.post_encode_actions() except BaseException: @@ -171,7 +251,13 @@ def encode_media( ): """Encode a media to given profile, using ffmpeg, storing progress""" - logger.info("Encode Media started, friendly token {0}, profile id {1}, force {2}".format(friendly_token, profile_id, force)) + logger.info(f"encode_media for {friendly_token}/{profile_id}/{encoding_id}/{force}/{chunk}") + # TODO: this is new behavior, check whether it performs well. Before that check it would end up saving the Encoding + # at some point below. Now it exits the task. Could it be that before it would give it a chance to re-run? Or it was + # not being used at all? + if not Encoding.objects.filter(id=encoding_id).exists(): + logger.info(f"Exiting for {friendly_token}/{profile_id}/{encoding_id}/{force} since encoding id not found") + return False if self.request.id: task_id = self.request.id @@ -311,28 +397,37 @@ def encode_media( percent = duration * 100 / media.duration if n_times % 60 == 0: encoding.progress = percent - try: - encoding.save(update_fields=["progress", "update_date"]) - logger.info("Saved {0}".format(round(percent, 2))) - except BaseException: - pass + encoding.save(update_fields=["progress", "update_date"]) + logger.info("Saved {0}".format(round(percent, 2))) n_times += 1 + except DatabaseError: + # primary reason for this is that the encoding has been deleted, because + # the media file was deleted, or also that there was a trim video request + # so it would be redundant to let it complete the encoding + kill_ffmpeg_process(encoding.temp_file) + kill_ffmpeg_process(encoding.chunk_file_path) + return False + except StopIteration: break except VideoEncodingError: # ffmpeg error, or ffmpeg was killed raise + except Exception as e: try: # output is empty, fail message is on the exception output = e.message except AttributeError: output = "" - if isinstance(e, SoftTimeLimitExceeded): - kill_ffmpeg_process(encoding.temp_file) + kill_ffmpeg_process(encoding.temp_file) + kill_ffmpeg_process(encoding.chunk_file_path) encoding.logs = output encoding.status = "fail" - encoding.save(update_fields=["status", "logs"]) + try: + encoding.save(update_fields=["status", "logs"]) + except DatabaseError: + return False raise_exception = True # if this is an ffmpeg's valid error # no need for the task to be re-run @@ -397,10 +492,10 @@ def produce_sprite_from_video(friendly_token): if os.path.exists(output_name) and get_file_type(output_name) == "image": with open(output_name, "rb") as f: myfile = File(f) - media.sprites.save( - content=myfile, - name=get_file_name(media.media_file.path) + "sprites.jpg", - ) + # SOS: avoid race condition, since this runs for a long time and will replace any other media changes on the meanwhile!!! + media.sprites.save(content=myfile, name=get_file_name(media.media_file.path) + "sprites.jpg", save=False) + media.save(update_fields=["sprites"]) + except Exception as e: print(e) return True @@ -452,8 +547,7 @@ def create_hls(friendly_token): pp = os.path.join(output_dir, "master.m3u8") if os.path.exists(pp): if media.hls_file != pp: - media.hls_file = pp - media.save(update_fields=["hls_file"]) + Media.objects.filter(pk=media.pk).update(hls_file=pp) return True @@ -776,23 +870,189 @@ def task_sent_handler(sender=None, headers=None, body=None, **kwargs): return True -def kill_ffmpeg_process(filepath): - # this is not ideal, ffmpeg pid could be linked to the Encoding object - cmd = "ps aux|grep 'ffmpeg'|grep %s|grep -v grep |awk '{print $2}'" % filepath - result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) - pid = result.stdout.decode("utf-8").strip() - if pid: - cmd = "kill -9 %s" % pid - result = subprocess.run(cmd, stdout=subprocess.PIPE, shell=True) - return result - - @task(name="remove_media_file", base=Task, queue="long_tasks") def remove_media_file(media_file=None): rm_file(media_file) return True +@task(name="update_encoding_size", queue="short_tasks") +def update_encoding_size(encoding_id): + """Update the size of an encoding without saving to avoid calling signals""" + encoding = Encoding.objects.filter(id=encoding_id).first() + if encoding: + encoding.update_size_without_save() + return True + return False + + +@task(name="produce_video_chapters", queue="short_tasks") +def produce_video_chapters(chapter_id): + # this is not used + return False + chapter_object = VideoChapterData.objects.filter(id=chapter_id).first() + if not chapter_object: + return False + + media = chapter_object.media + video_path = media.media_file.path + output_folder = media.video_chapters_folder + + chapters = chapter_object.data + + width = 336 + height = 188 + + if not os.path.exists(output_folder): + os.makedirs(output_folder) + + results = [] + + for i, chapter in enumerate(chapters): + timestamp = chapter["start"] + title = chapter["title"] + + output_filename = f"thumbnail_{i:02d}.jpg" # noqa + output_path = os.path.join(output_folder, output_filename) + + command = [settings.FFMPEG_COMMAND, "-y", "-ss", str(timestamp), "-i", video_path, "-vframes", "1", "-q:v", "2", "-s", f"{width}x{height}", output_path] + ret = run_command(command) # noqa + if os.path.exists(output_path) and get_file_type(output_path) == "image": + results.append({"start": timestamp, "title": title, "thumbnail": output_path}) + + chapter_object.data = results + chapter_object.save(update_fields=["data"]) + return True + + +@task(name="post_trim_action", queue="short_tasks", soft_time_limit=600) +def post_trim_action(friendly_token): + """Perform post-processing actions after video trimming + + Args: + friendly_token: The friendly token of the media + + Returns: + bool: True if successful, False otherwise + """ + logger.info(f"Post trim action for {friendly_token}") + try: + media = Media.objects.get(friendly_token=friendly_token) + except Media.DoesNotExist: + logger.info(f"Media with friendly token {friendly_token} not found") + return False + + media.set_media_type() + encodings = media.encodings.filter(status="success", profile__extension='mp4', chunk=False) + # if they are still not encoded, when the first one will be encoded, it will have the chance to + # call post_trim_action again + if encodings: + for encoding in encodings: + # update encoding size, in case they don't have one, due to the + # way the copy_video took place + update_encoding_size(encoding.id) + + media.produce_thumbnails_from_video() + produce_sprite_from_video.delay(friendly_token) + create_hls.delay(friendly_token) + + vt_request = VideoTrimRequest.objects.filter(media=media, status="running").first() + if vt_request: + vt_request.status = "success" + vt_request.save(update_fields=["status"]) + + return True + + +@task(name="video_trim_task", bind=True, queue="short_tasks", soft_time_limit=600) +def video_trim_task(self, trim_request_id): + # SOS: if at some point we move from ffmpeg copy, then this need be changed + # to long_tasks + try: + trim_request = VideoTrimRequest.objects.get(id=trim_request_id) + except VideoTrimRequest.DoesNotExist: + logger.info(f"VideoTrimRequest with ID {trim_request_id} not found") + return False + + trim_request.status = "running" + trim_request.save(update_fields=["status"]) + + timestamps_encodings = get_trim_timestamps(trim_request.media.trim_video_path, trim_request.timestamps) + timestamps_original = get_trim_timestamps(trim_request.media.media_file.path, trim_request.timestamps) + + if not timestamps_encodings: + trim_request.status = "fail" + trim_request.save(update_fields=["status"]) + return False + + target_media = trim_request.media + original_media = trim_request.media + + # splitting the logic for single file and multiple files + if trim_request.video_action in ["save_new", "replace"]: + proceed_with_single_file = True + if trim_request.video_action == "create_segments": + if len(timestamps_encodings) == 1: + proceed_with_single_file = True + else: + proceed_with_single_file = False + + if proceed_with_single_file: + if trim_request.video_action == "save_new" or trim_request.video_action == "create_segments" and len(timestamps_encodings) == 1: + new_media = copy_video(original_media, copy_encodings=True) + + target_media = new_media + trim_request.media = new_media + trim_request.save(update_fields=["media"]) + + # processing timestamps differently on encodings and original file, in case we do accuracy trimming (currently not) + # these have different I-frames and the cut is made based on the I-frames + + original_trim_result = trim_video_method(target_media.media_file.path, timestamps_original) + if not original_trim_result: + logger.info(f"Failed to trim original file for media {target_media.friendly_token}") + + deleted_encodings = handle_pending_running_encodings(target_media) + # the following could be un-necessary, read commend in pre_trim_video_actions to see why + encodings = target_media.encodings.filter(status="success", profile__extension='mp4', chunk=False) + for encoding in encodings: + trim_result = trim_video_method(encoding.media_file.path, timestamps_encodings) + if not trim_result: + logger.info(f"Failed to trim encoding {encoding.id} for media {target_media.friendly_token}") + encoding.delete() + + pre_trim_video_actions(target_media) + post_trim_action.delay(target_media.friendly_token) + + else: + for i, timestamp in enumerate(timestamps_encodings, start=1): + # copy the original file for each of the segments. This could be optimized to avoid the overhead but + # for now is necessary because the ffmpeg trim command will be run towards the original + # file on different times. + target_media = copy_video(original_media, title_suffix=f"(Trimmed) {i}", copy_encodings=True) + + video_trim_request = VideoTrimRequest.objects.create(media=target_media, status="running", video_action="create_segments", media_trim_style='no_encoding', timestamps=[timestamp]) # noqa + + original_trim_result = trim_video_method(target_media.media_file.path, [timestamp]) + deleted_encodings = handle_pending_running_encodings(target_media) # noqa + # the following could be un-necessary, read commend in pre_trim_video_actions to see why + encodings = target_media.encodings.filter(status="success", profile__extension='mp4', chunk=False) + for encoding in encodings: + trim_result = trim_video_method(encoding.media_file.path, [timestamp]) + if not trim_result: + logger.info(f"Failed to trim encoding {encoding.id} for media {target_media.friendly_token}") + encoding.delete() + + pre_trim_video_actions(target_media) + post_trim_action.delay(target_media.friendly_token) + + # set as completed the initial trim_request + trim_request.status = "success" + trim_request.save(update_fields=["status"]) + + return True + + # TODO LIST # 1 chunks are deleted from original server when file is fully encoded. # however need to enter this logic in cases of fail as well diff --git a/files/urls.py b/files/urls.py index cdb4ec1c..2f0b8378 100644 --- a/files/urls.py +++ b/files/urls.py @@ -16,6 +16,9 @@ urlpatterns = [ re_path(r"^edit_subtitle", views.edit_subtitle, name="edit_subtitle"), re_path(r"^categories$", views.categories, name="categories"), re_path(r"^contact$", views.contact, name="contact"), + re_path(r"^publish", views.publish_media, name="publish_media"), + re_path(r"^edit_chapters", views.edit_chapters, name="edit_chapters"), + re_path(r"^edit_video", views.edit_video, name="edit_video"), re_path(r"^edit", views.edit_media, name="edit_media"), re_path(r"^embed", views.embed_media, name="get_embed"), re_path(r"^featured$", views.featured_media), @@ -62,6 +65,14 @@ urlpatterns = [ r"^api/v1/media/(?P[\w]*)/actions$", views.MediaActions.as_view(), ), + re_path( + r"^api/v1/media/(?P[\w]*)/chapters$", + views.video_chapters, + ), + re_path( + r"^api/v1/media/(?P[\w]*)/trim_video$", + views.trim_video, + ), re_path(r"^api/v1/categories$", views.CategoryList.as_view()), re_path(r"^api/v1/tags$", views.TagList.as_view()), re_path(r"^api/v1/comments$", views.CommentList.as_view()), diff --git a/files/views.py b/files/views.py index d518a024..5e04eeea 100644 --- a/files/views.py +++ b/files/views.py @@ -1,3 +1,4 @@ +import json from datetime import datetime, timedelta from allauth.socialaccount.models import SocialApp @@ -7,9 +8,10 @@ from django.contrib.auth.decorators import login_required from django.contrib.postgres.search import SearchQuery from django.core.mail import EmailMessage from django.db.models import Q -from django.http import Http404, HttpResponse, HttpResponseRedirect +from django.http import Http404, HttpResponse, HttpResponseRedirect, JsonResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse +from django.views.decorators.csrf import csrf_exempt from drf_yasg import openapi as openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import permissions, status @@ -36,14 +38,22 @@ from cms.version import VERSION from identity_providers.models import LoginOption from users.models import User -from .forms import ContactForm, EditSubtitleForm, MediaForm, SubtitleForm +from . import helpers +from .forms import ( + ContactForm, + EditSubtitleForm, + MediaMetadataForm, + MediaPublishForm, + SubtitleForm, +) from .frontend_translations import translate_string from .helpers import clean_query, get_alphanumeric_only, produce_ffmpeg_commands from .methods import ( check_comment_for_mention, + create_video_trim_request, get_user_or_session, + handle_video_chapters, is_mediacms_editor, - is_mediacms_manager, list_tasks, notify_user_on_comment, show_recommended_media, @@ -60,6 +70,7 @@ from .models import ( PlaylistMedia, Subtitle, Tag, + VideoTrimRequest, ) from .serializers import ( CategorySerializer, @@ -73,7 +84,7 @@ from .serializers import ( TagSerializer, ) from .stop_words import STOP_WORDS -from .tasks import save_user_action +from .tasks import save_user_action, video_trim_task VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS] @@ -103,7 +114,7 @@ def add_subtitle(request): if not media: return HttpResponseRedirect("/") - if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)): + if not (request.user == media.user or is_mediacms_editor(request.user)): return HttpResponseRedirect("/") if request.method == "POST": @@ -138,7 +149,7 @@ def edit_subtitle(request): if not subtitle: return HttpResponseRedirect("/") - if not (request.user == subtitle.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)): + if not (request.user == subtitle.user or is_mediacms_editor(request.user)): return HttpResponseRedirect("/") context = {"subtitle": subtitle, "action": action} @@ -233,6 +244,43 @@ def history(request): return render(request, "cms/history.html", context) +@csrf_exempt +@login_required +def video_chapters(request, friendly_token): + # this is not ready... + return False + if not request.method == "POST": + return HttpResponseRedirect("/") + + media = Media.objects.filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + if not (request.user == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + try: + data = json.loads(request.body)["chapters"] + chapters = [] + for _, chapter_data in enumerate(data): + start_time = chapter_data.get('start') + title = chapter_data.get('title') + if start_time and title: + chapters.append( + { + 'start': start_time, + 'title': title, + } + ) + except Exception as e: # noqa + return JsonResponse({'success': False, 'error': 'Request data must be a list of video chapters with start and title'}, status=400) + + ret = handle_video_chapters(media, chapters) + + return JsonResponse(ret, safe=False) + + @login_required def edit_media(request): """Edit a media view""" @@ -245,10 +293,10 @@ def edit_media(request): if not media: return HttpResponseRedirect("/") - if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)): + if not (request.user == media.user or is_mediacms_editor(request.user)): return HttpResponseRedirect("/") if request.method == "POST": - form = MediaForm(request.user, request.POST, request.FILES, instance=media) + form = MediaMetadataForm(request.user, request.POST, request.FILES, instance=media) if form.is_valid(): media = form.save() for tag in media.tags.all(): @@ -267,11 +315,145 @@ def edit_media(request): messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited")) return HttpResponseRedirect(media.get_absolute_url()) else: - form = MediaForm(request.user, instance=media) + form = MediaMetadataForm(request.user, instance=media) return render( request, "cms/edit_media.html", - {"form": form, "add_subtitle_url": media.add_subtitle_url}, + {"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url}, + ) + + +@login_required +def publish_media(request): + """Publish media""" + + friendly_token = request.GET.get("m", "").strip() + if not friendly_token: + return HttpResponseRedirect("/") + media = Media.objects.filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + if not (request.user == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + if request.method == "POST": + form = MediaPublishForm(request.user, request.POST, request.FILES, instance=media) + if form.is_valid(): + media = form.save() + messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Media was edited")) + return HttpResponseRedirect(media.get_absolute_url()) + else: + form = MediaPublishForm(request.user, instance=media) + + return render( + request, + "cms/publish_media.html", + {"form": form, "media_object": media, "add_subtitle_url": media.add_subtitle_url}, + ) + + +@login_required +def edit_chapters(request): + """Edit chapters""" + # not implemented yet + return False + friendly_token = request.GET.get("m", "").strip() + if not friendly_token: + return HttpResponseRedirect("/") + media = Media.objects.filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + if not (request.user == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + return render( + request, + "cms/edit_chapters.html", + {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": helpers.url_from_path(media.media_file.path), "media_id": media.friendly_token}, + ) + + +@csrf_exempt +@login_required +def trim_video(request, friendly_token): + if not settings.ALLOW_VIDEO_TRIMMER: + return JsonResponse({"success": False, "error": "Video trimming is not allowed"}, status=400) + + if not request.method == "POST": + return HttpResponseRedirect("/") + + media = Media.objects.filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + if not (request.user == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + existing_requests = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists() + + if existing_requests: + return JsonResponse({"success": False, "error": "A trim request is already in progress for this video"}, status=400) + + try: + data = json.loads(request.body) + video_trim_request = create_video_trim_request(media, data) + video_trim_task.delay(video_trim_request.id) + ret = {"success": True, "request_id": video_trim_request.id} + return JsonResponse(ret, safe=False, status=200) + except Exception as e: # noqa + ret = {"success": False, "error": "Incorrect request data"} + return JsonResponse(ret, safe=False, status=400) + + +@login_required +def edit_video(request): + """Edit video""" + + friendly_token = request.GET.get("m", "").strip() + if not friendly_token: + return HttpResponseRedirect("/") + media = Media.objects.filter(friendly_token=friendly_token).first() + + if not media: + return HttpResponseRedirect("/") + + if not (request.user == media.user or is_mediacms_editor(request.user)): + return HttpResponseRedirect("/") + + if not media.media_type == "video": + messages.add_message(request, messages.INFO, "Media is not video") + return HttpResponseRedirect(media.get_absolute_url()) + + if not settings.ALLOW_VIDEO_TRIMMER: + messages.add_message(request, messages.INFO, "Video Trimmer is not enabled") + return HttpResponseRedirect(media.get_absolute_url()) + + # Check if there's a running trim request + running_trim_request = VideoTrimRequest.objects.filter(media=media, status__in=["initial", "running"]).exists() + + if running_trim_request: + messages.add_message(request, messages.INFO, "Video trim request is already running") + return HttpResponseRedirect(media.get_absolute_url()) + + media_file_path = media.trim_video_url + + if not media_file_path: + messages.add_message(request, messages.INFO, "Media processing has not finished yet") + return HttpResponseRedirect(media.get_absolute_url()) + + if media.encoding_status in ["pending", "running"]: + video_msg = "Media encoding hasn't finished yet. Attempting to show the original video file" + messages.add_message(request, messages.INFO, video_msg) + + return render( + request, + "cms/edit_video.html", + {"media_object": media, "add_subtitle_url": media.add_subtitle_url, "media_file_path": media_file_path}, ) @@ -428,10 +610,22 @@ def view_media(request): context["CAN_DELETE_COMMENTS"] = False if request.user.is_authenticated: - if (media.user.id == request.user.id) or is_mediacms_editor(request.user) or is_mediacms_manager(request.user): + if media.user.id == request.user.id or is_mediacms_editor(request.user): context["CAN_DELETE_MEDIA"] = True context["CAN_EDIT_MEDIA"] = True context["CAN_DELETE_COMMENTS"] = True + + # in case media is video and is processing (eg the case a video was just uploaded) + # attempt to show it (rather than showing a blank video player) + if media.media_type == 'video': + video_msg = None + if media.encoding_status == "pending": + video_msg = "Media encoding hasn't started yet. Attempting to show the original video file" + if media.encoding_status == "running": + video_msg = "Media encoding is under processing. Attempting to show the original video file" + if video_msg: + messages.add_message(request, messages.INFO, video_msg) + return render(request, "cms/media.html", context) @@ -621,7 +815,7 @@ class MediaDetail(APIView): if isinstance(media, Response): return media - if not (is_mediacms_editor(request.user) or is_mediacms_manager(request.user)): + if not is_mediacms_editor(request.user): return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) action = request.data.get("type") @@ -738,7 +932,7 @@ class MediaActions(APIView): def get(self, request, friendly_token, format=None): # show date and reason for each time media was reported media = self.get_object(friendly_token) - if not (request.user == media.user or is_mediacms_editor(request.user) or is_mediacms_manager(request.user)): + if not (request.user == media.user or is_mediacms_editor(request.user)): return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST) if isinstance(media, Response): diff --git a/frontend-tools/static/video_editor/video-editor.css b/frontend-tools/static/video_editor/video-editor.css new file mode 100644 index 00000000..31343841 --- /dev/null +++ b/frontend-tools/static/video_editor/video-editor.css @@ -0,0 +1 @@ +#video-editor-trim-root .video-player-container{position:relative;background-color:#000;border-radius:.5rem;overflow:hidden;margin-bottom:1rem;aspect-ratio:16/9}#video-editor-trim-root .video-player-container video{width:100%;height:100%;cursor:pointer}#video-editor-trim-root .play-pause-indicator{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:60px;height:60px;background-color:#0009;border-radius:50%;opacity:0;transition:opacity .3s}#video-editor-trim-root .play-pause-indicator:before{content:"";position:absolute;top:50%;left:50%;transform:translate(-50%,-50%)}#video-editor-trim-root .play-pause-indicator.play-icon:before{width:0;height:0;border-top:15px solid transparent;border-bottom:15px solid transparent;border-left:25px solid white;margin-left:3px}#video-editor-trim-root .play-pause-indicator.pause-icon:before{width:20px;height:25px;border-left:6px solid white;border-right:6px solid white}#video-editor-trim-root .video-player-container:hover .play-pause-indicator{opacity:1}#video-editor-trim-root .video-controls{position:absolute;bottom:0;left:0;right:0;padding:.75rem;background:linear-gradient(transparent,#000000b3);opacity:0;transition:opacity .3s}#video-editor-trim-root .video-player-container:hover .video-controls{opacity:1}#video-editor-trim-root .video-current-time,#video-editor-trim-root .video-duration{color:#fff;font-size:.875rem}#video-editor-trim-root .video-time-display{display:flex;justify-content:space-between;margin-bottom:.5rem;color:#fff;font-size:.875rem}#video-editor-trim-root .video-progress{width:100%;height:4px;background-color:#ffffff4d;border-radius:2px;position:relative;cursor:pointer;margin-bottom:.75rem}#video-editor-trim-root .video-progress:hover{height:6px}#video-editor-trim-root .video-progress:hover .video-scrubber{transform:translate(-50%,-50%) scale(1.2)}#video-editor-trim-root .video-progress-fill{height:100%;background-color:#ef4444;border-radius:2px;position:absolute;top:0;left:0}#video-editor-trim-root .video-scrubber{width:12px;height:12px;background-color:#ef4444;border-radius:50%;position:absolute;top:50%;left:0;transform:translate(-50%,-50%);transition:transform .2s;border:1px solid rgba(255,255,255,.7)}#video-editor-trim-root .video-controls-buttons{display:flex;align-items:center;justify-content:flex-end;gap:.75rem}#video-editor-trim-root .mute-button,#video-editor-trim-root .fullscreen-button{min-width:auto;color:#fff;background:none;border:none;cursor:pointer;padding:.25rem;transition:transform .2s}#video-editor-trim-root .mute-button:hover,#video-editor-trim-root .fullscreen-button:hover{transform:scale(1.1)}#video-editor-trim-root .mute-button svg,#video-editor-trim-root .fullscreen-button svg{width:1.25rem;height:1.25rem}.modal-overlay{position:fixed;top:0;left:0;right:0;bottom:0;background-color:#00000080;display:flex;align-items:center;justify-content:center;z-index:1000}.modal-container{background-color:#fff;border-radius:8px;box-shadow:0 4px 12px #00000026;width:90%;max-width:500px;max-height:90vh;overflow-y:auto;animation:modal-fade-in .3s ease-out}@keyframes modal-fade-in{0%{opacity:0;transform:translateY(-20px)}to{opacity:1;transform:translateY(0)}}.modal-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;border-bottom:1px solid #eee}.modal-title{margin:0;font-size:1.25rem;font-weight:600;color:#333}.modal-close-button{background:none;border:none;cursor:pointer;color:#666;padding:4px;display:flex;align-items:center;justify-content:center;transition:color .2s}.modal-close-button:hover{color:#000}.modal-content{padding:20px;color:#333;font-size:1rem;line-height:1.5}.modal-actions{display:flex;justify-content:flex-end;padding:16px 20px;border-top:1px solid #eee;gap:12px}.modal-button{padding:8px 16px;border-radius:4px;font-weight:500;cursor:pointer;transition:all .2s;border:none}.modal-button-primary{background-color:#06c;color:#fff}.modal-button-primary:hover{background-color:#05a}.modal-button-secondary{background-color:#f0f0f0;color:#333}.modal-button-secondary:hover{background-color:#e0e0e0}.modal-button-danger{background-color:#dc3545;color:#fff}.modal-button-danger:hover{background-color:#bd2130}.modal-message{margin-bottom:16px;font-size:1rem}.modal-spinner{display:flex;align-items:center;justify-content:center;margin:20px 0}.spinner{border:4px solid rgba(0,0,0,.1);border-radius:50%;border-top:4px solid #0066cc;width:30px;height:30px;animation:spin 1s linear infinite}@keyframes spin{0%{transform:rotate(0)}to{transform:rotate(360deg)}}.modal-success-icon{display:flex;justify-content:center;margin-bottom:16px;color:#28a745;font-size:2rem}.modal-choices{display:flex;flex-direction:column;gap:10px;margin-top:20px}.modal-choice-button{padding:12px 16px;border:1px solid #ddd;border-radius:4px;background-color:#f8f8f8;text-align:center;cursor:pointer;transition:all .2s;display:flex;align-items:center;justify-content:center;font-weight:500;text-decoration:none;color:#333}.modal-choice-button:hover{background-color:#eee;border-color:#ccc}.modal-choice-button svg{margin-right:8px}@media (max-width: 480px){.modal-container{width:95%}.modal-actions{flex-direction:column}.modal-button{width:100%}}#video-editor-trim-root .timeline-container-card{background-color:#fff;border-radius:.5rem;padding:1rem;box-shadow:0 1px 2px #0000000d}#video-editor-trim-root .timeline-header{margin-bottom:.75rem;display:flex;justify-content:space-between;align-items:center}#video-editor-trim-root .timeline-title{font-size:.875rem;font-weight:500;color:var(--foreground, #333)}#video-editor-trim-root .timeline-title-text{font-weight:700}#video-editor-trim-root .current-time{font-size:.875rem;color:var(--foreground, #333)}#video-editor-trim-root .time-code{font-family:monospace;background-color:#f3f4f6;padding:0 .5rem;border-radius:.25rem}#video-editor-trim-root .duration-time{font-size:.875rem;color:var(--foreground, #333)}#video-editor-trim-root .timeline-scroll-container{position:relative}#video-editor-trim-root .timeline-container{position:relative;min-width:100%;background-color:#fafbfc;height:70px;border-radius:.25rem}#video-editor-trim-root .timeline-marker{position:absolute;top:0;bottom:0;width:1px;background-color:red;z-index:30;pointer-events:none}#video-editor-trim-root .timeline-marker:after{content:"";position:absolute;top:0;left:-3px;width:7px;height:7px;background-color:red;border-radius:50%}#video-editor-trim-root .trim-line-marker{position:absolute;top:0;bottom:0;width:1px;background-color:#00000080;z-index:20}#video-editor-trim-root .trim-handle{position:absolute;width:10px;height:20px;background-color:#000;cursor:ew-resize}#video-editor-trim-root .trim-handle.left{right:0;top:10px;border-radius:3px 0 0 3px}#video-editor-trim-root .trim-handle.right{left:0;top:10px;border-radius:0 3px 3px 0}#video-editor-trim-root .timeline-thumbnail{display:inline-block;height:70px;border-right:1px solid rgba(0,0,0,.03)}#video-editor-trim-root .split-point{position:absolute;top:0;bottom:0;width:1px;background-color:#ff000080;z-index:15}#video-editor-trim-root .clip-segment{position:absolute;height:70px;border-radius:4px;z-index:10;border:2px solid rgba(0,0,0,.15);cursor:pointer}#video-editor-trim-root .clip-segment:hover{box-shadow:0 0 0 2px #0000004d;border-color:#0006;background-color:#f0f0f0cc!important}#video-editor-trim-root .clip-segment.selected{box-shadow:0 0 0 2px #3b82f6b3;border-color:#3b82f6e6}#video-editor-trim-root .clip-segment.selected:hover{background-color:#f0f8ffd9!important}#video-editor-trim-root .clip-segment-info{position:absolute;bottom:0;left:0;right:0;padding:.4rem;background-color:#0006;color:#fff;opacity:1;transition:background-color .2s;line-height:1.3}#video-editor-trim-root .clip-segment:hover .clip-segment-info{background-color:#00000080}#video-editor-trim-root .clip-segment.selected .clip-segment-info{background-color:#3b82f680}#video-editor-trim-root .clip-segment.selected:hover .clip-segment-info{background-color:#3b82f666}#video-editor-trim-root .clip-segment-name{font-weight:700;font-size:12px}#video-editor-trim-root .clip-segment-time,#video-editor-trim-root .clip-segment-duration{font-size:10px}#video-editor-trim-root .clip-segment-handle{position:absolute;top:0;bottom:0;width:6px;background-color:#0003;cursor:ew-resize}#video-editor-trim-root .clip-segment-handle:hover{background-color:#0006}#video-editor-trim-root .clip-segment-handle.left{left:0;border-radius:2px 0 0 2px}#video-editor-trim-root .clip-segment-handle.right{right:0;border-radius:0 2px 2px 0}#video-editor-trim-root .segment-tooltip,#video-editor-trim-root .empty-space-tooltip{position:fixed;background-color:#fff;border-radius:4px;box-shadow:0 2px 8px #0000004d;padding:.5rem;z-index:100;min-width:150px;text-align:center}#video-editor-trim-root .segment-tooltip:after,#video-editor-trim-root .empty-space-tooltip:after{content:"";position:absolute;bottom:-5px;left:50%;transform:translate(-50%);width:0;height:0;border-left:5px solid transparent;border-right:5px solid transparent;border-top:5px solid white}#video-editor-trim-root .tooltip-time{font-weight:600;font-size:.875rem;margin-bottom:.5rem;color:#333}#video-editor-trim-root .tooltip-actions{display:flex;justify-content:center;gap:.5rem}#video-editor-trim-root .tooltip-action-btn{background-color:#f3f4f6;border:none;border-radius:.25rem;padding:.375rem;display:flex;align-items:center;justify-content:center;cursor:pointer;color:#4b5563}#video-editor-trim-root .tooltip-action-btn:hover{background-color:#e5e7eb;color:#111827}#video-editor-trim-root .tooltip-action-btn.delete{color:#ef4444}#video-editor-trim-root .tooltip-action-btn.delete:hover{background-color:#fee2e2}#video-editor-trim-root .tooltip-action-btn.new-segment{padding:.375rem .5rem}#video-editor-trim-root .tooltip-action-btn.new-segment .tooltip-btn-text{margin-left:.25rem;font-size:.75rem}#video-editor-trim-root .tooltip-action-btn svg{width:1rem;height:1rem}#video-editor-trim-root .timeline-controls{display:flex;align-items:center;justify-content:space-between;margin-top:.75rem}#video-editor-trim-root .time-navigation{display:flex;align-items:center;gap:.5rem}#video-editor-trim-root .time-nav-label{font-size:.875rem;font-weight:500}#video-editor-trim-root .time-input{border:1px solid #d1d5db;border-radius:.25rem;padding:.25rem .5rem;width:8rem;font-size:.875rem}#video-editor-trim-root .time-button-group{display:flex}#video-editor-trim-root .time-button{background-color:#e5e7eb;color:#000;padding:.25rem .5rem;font-size:.875rem;border:none;cursor:pointer;margin-right:.5rem}#video-editor-trim-root .time-button:hover{background-color:#d1d5db}#video-editor-trim-root .time-button:first-child{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}#video-editor-trim-root .time-button:last-child{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}#video-editor-trim-root .controls-right{display:flex;align-items:center;gap:.5rem}#video-editor-trim-root .zoom-dropdown-container{position:relative;z-index:100;display:none}#video-editor-trim-root .zoom-button{background-color:#374151;color:#fff;border:none;border-radius:.25rem;padding:.25rem .75rem;font-size:.875rem;display:flex;align-items:center;cursor:pointer}#video-editor-trim-root .zoom-button:hover{background-color:#1f2937}#video-editor-trim-root .zoom-button svg{margin-left:.25rem}#video-editor-trim-root .zoom-dropdown{position:absolute;top:100%;left:0;margin-top:.25rem;width:9rem;background-color:#374151;color:#fff;border-radius:.25rem;box-shadow:0 4px 6px -1px #0000001a;z-index:50;max-height:300px;overflow-y:auto}#video-editor-trim-root .zoom-option{padding:.25rem .75rem;cursor:pointer}#video-editor-trim-root .zoom-option:hover{background-color:#4b5563}#video-editor-trim-root .zoom-option.selected{background-color:#6b7280;display:flex;align-items:center}#video-editor-trim-root .zoom-option svg{margin-right:.25rem}#video-editor-trim-root .save-button{color:#fff;background:#06c;border-radius:.25rem;font-size:.875rem;padding:.25rem .5rem;cursor:pointer;border:none}#video-editor-trim-root .save-button:hover{background-color:#093b6de6}#video-editor-trim-root .save-copy-button{background:#06c;color:#fff;border:none;border-radius:.25rem;font-size:.875rem;padding:.25rem .5rem;cursor:pointer}#video-editor-trim-root .save-copy-button:hover{background-color:#093b6de6}#video-editor-trim-root [data-tooltip]{position:relative}#video-editor-trim-root [data-tooltip]:before{content:attr(data-tooltip);position:absolute;bottom:100%;left:50%;transform:translate(-50%);margin-bottom:5px;background-color:#000c;color:#fff;text-align:center;padding:5px 10px;border-radius:3px;font-size:12px;white-space:nowrap;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;z-index:1000;pointer-events:none}#video-editor-trim-root [data-tooltip]:after{content:"";position:absolute;bottom:100%;left:50%;transform:translate(-50%);border-width:5px;border-style:solid;border-color:rgba(0,0,0,.8) transparent transparent transparent;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;pointer-events:none}#video-editor-trim-root [data-tooltip]:hover:before,#video-editor-trim-root [data-tooltip]:hover:after{opacity:1;visibility:visible}#video-editor-trim-root .editing-tools-container{background-color:#fff;border-radius:.5rem;padding:1rem;margin-bottom:1rem;box-shadow:0 1px 2px #0000000d}#video-editor-trim-root .flex-container{display:flex;justify-content:space-between;align-items:center;position:relative;flex-wrap:wrap;gap:15px}#video-editor-trim-root .button-group{display:flex}#video-editor-trim-root .button-group.preview-group{gap:1rem}#video-editor-trim-root .button-group.center-play{position:absolute;left:50%;transform:translate(-50%);z-index:10}#video-editor-trim-root .button-group.secondary{gap:.75rem;align-items:center}#video-editor-trim-root .button-group button{display:flex;align-items:center;color:#333;background:none;border:none;cursor:pointer;transition:color .2s;min-width:auto}#video-editor-trim-root .button-group button:hover:not(:disabled){color:#06c}#video-editor-trim-root .button-group button:disabled{opacity:.5;cursor:not-allowed}#video-editor-trim-root .button-group button svg{height:1.25rem;width:1.25rem;margin-right:.25rem}#video-editor-trim-root .divider{border-right:1px solid #d1d5db;height:1.5rem;margin:0 .5rem}#video-editor-trim-root .play-button,#video-editor-trim-root .preview-button{font-weight:600;display:flex;align-items:center;position:relative;overflow:hidden;transform-origin:center center;transition:transform .2s ease,color .2s ease;min-width:80px;justify-content:center}#video-editor-trim-root .play-button:hover:not(:disabled),#video-editor-trim-root .preview-button:hover:not(:disabled){color:#06c;transform:scale(1.05);font-size:inherit;width:auto}#video-editor-trim-root .play-button svg,#video-editor-trim-root .preview-button svg{height:1.5rem;width:1.5rem;flex-shrink:0}@media (max-width: 768px){#video-editor-trim-root .flex-container{justify-content:space-between;padding-top:10px}#video-editor-trim-root .button-group.secondary{justify-content:flex-end;width:auto}#video-editor-trim-root .button-group.preview-group{justify-content:flex-start}#video-editor-trim-root .button-group.center-play{position:absolute;left:50%;transform:translate(-50%);top:0}}@media (max-width: 640px){#video-editor-trim-root .flex-container{padding-top:0}#video-editor-trim-root .button-group.center-play{position:static;transform:none;margin-left:20px}}@media (max-width: 480px){#video-editor-trim-root .flex-container{flex-direction:column;align-items:center}#video-editor-trim-root .button-group.secondary{width:100%;justify-content:space-between;margin-left:0}#video-editor-trim-root .button-group.preview-group,#video-editor-trim-root .button-group.center-play{width:auto;justify-content:center;margin-left:0}#video-editor-trim-root .button-group.center-play{margin-top:10px;margin-bottom:10px}#video-editor-trim-root .divider{display:none}}#video-editor-trim-root .clip-segments-container{margin-top:1rem;background-color:#fff;border-radius:.5rem;padding:1rem;box-shadow:0 1px 2px #0000000d}#video-editor-trim-root .clip-segments-title{font-size:.875rem;font-weight:500;color:var(--foreground, #333);margin-bottom:.75rem}#video-editor-trim-root .segment-item{display:flex;align-items:center;justify-content:space-between;padding:.5rem;border:1px solid #e5e7eb;border-radius:.25rem;margin-bottom:.5rem;transition:box-shadow .2s ease}#video-editor-trim-root .segment-item:hover{box-shadow:0 4px 6px -1px #0000001a}#video-editor-trim-root .segment-content{display:flex;align-items:center}#video-editor-trim-root .segment-thumbnail{width:4rem;height:2.25rem;background-size:cover;background-position:center;border-radius:.25rem;margin-right:.75rem;box-shadow:0 0 0 1px #ffffff4d}#video-editor-trim-root .segment-info{display:flex;flex-direction:column}#video-editor-trim-root .segment-title{font-weight:500;font-size:.875rem;color:#000}#video-editor-trim-root .segment-time{font-size:.75rem;color:#000}#video-editor-trim-root .segment-duration{font-size:.75rem;margin-top:.25rem;display:inline-block;background-color:#f3f4f6;padding:0 .5rem;border-radius:.25rem;color:#000}#video-editor-trim-root .segment-actions{display:flex;align-items:center;gap:.5rem}#video-editor-trim-root .delete-button{padding:.375rem;color:#4b5563;background-color:#e5e7eb;border-radius:9999px;border:none;cursor:pointer;transition:background-color .2s,color .2s;min-width:auto}#video-editor-trim-root .delete-button:hover{color:#000;background-color:#d1d5db}#video-editor-trim-root .delete-button svg{height:1rem;width:1rem}#video-editor-trim-root .empty-message{padding:1rem;text-align:center;color:#333333b3}#video-editor-trim-root .segment-color-1{background-color:#3b82f626}#video-editor-trim-root .segment-color-2{background-color:#10b98126}#video-editor-trim-root .segment-color-3{background-color:#f59e0b26}#video-editor-trim-root .segment-color-4{background-color:#ef444426}#video-editor-trim-root .segment-color-5{background-color:#8b5cf626}#video-editor-trim-root .segment-color-6{background-color:#ec489926}#video-editor-trim-root .segment-color-7{background-color:#06b6d426}#video-editor-trim-root .segment-color-8{background-color:#facc1526}*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,sans-serif,"Apple Color Emoji","Segoe UI Emoji",Segoe UI Symbol,"Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}*{border-color:hsl(var(--border))}.container{width:100%}@media (min-width: 640px){.container{max-width:640px}}@media (min-width: 768px){.container{max-width:768px}}@media (min-width: 1024px){.container{max-width:1024px}}@media (min-width: 1280px){.container{max-width:1280px}}@media (min-width: 1536px){.container{max-width:1536px}}.visible{visibility:visible}.fixed{position:fixed}.absolute{position:absolute}.relative{position:relative}.mx-auto{margin-left:auto;margin-right:auto}.hidden{display:none}.min-h-screen{min-height:100vh}.max-w-6xl{max-width:72rem}.transform{transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skew(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.resize{resize:both}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.border{border-width:1px}.bg-background{background-color:hsl(var(--background))}.px-4{padding-left:1rem;padding-right:1rem}.py-6{padding-top:1.5rem;padding-bottom:1.5rem}.text-center{text-align:center}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}@keyframes enter{0%{opacity:var(--tw-enter-opacity, 1);transform:translate3d(var(--tw-enter-translate-x, 0),var(--tw-enter-translate-y, 0),0) scale3d(var(--tw-enter-scale, 1),var(--tw-enter-scale, 1),var(--tw-enter-scale, 1)) rotate(var(--tw-enter-rotate, 0))}}@keyframes exit{to{opacity:var(--tw-exit-opacity, 1);transform:translate3d(var(--tw-exit-translate-x, 0),var(--tw-exit-translate-y, 0),0) scale3d(var(--tw-exit-scale, 1),var(--tw-exit-scale, 1),var(--tw-exit-scale, 1)) rotate(var(--tw-exit-rotate, 0))}}:root{--foreground: 20 14.3% 4.1%;--muted: 60 4.8% 95.9%;--muted-foreground: 25 5.3% 44.7%;--popover: 0 0% 100%;--popover-foreground: 20 14.3% 4.1%;--card: 0 0% 100%;--card-foreground: 20 14.3% 4.1%;--border: 20 5.9% 90%;--input: 20 5.9% 90%;--primary: 207 90% 54%;--primary-foreground: 211 100% 99%;--secondary: 30 84% 54%;--secondary-foreground: 60 9.1% 97.8%;--accent: 60 4.8% 95.9%;--accent-foreground: 24 9.8% 10%;--destructive: 0 84.2% 60.2%;--destructive-foreground: 60 9.1% 97.8%;--ring: 20 14.3% 4.1%;--radius: .5rem}.video-player{position:relative;width:100%;background-color:#000;overflow:hidden;border-radius:.5rem}.video-controls{position:absolute;bottom:0;left:0;right:0;background:linear-gradient(to top,rgba(0,0,0,.8),transparent);padding:1rem;display:flex;flex-direction:column}.video-current-time{color:#fff;font-weight:500}.video-progress{position:relative;height:4px;background-color:#ffffff4d;border-radius:2px;margin-bottom:1rem}.video-progress-fill{position:absolute;left:0;top:0;height:100%;background-color:hsl(var(--primary));border-radius:2px}.video-scrubber{position:absolute;width:12px;height:12px;margin-left:-6px;background-color:#fff;border-radius:50%;top:-4px}.video-player-container{position:relative;overflow:hidden}.play-pause-indicator{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:70px;height:70px;border-radius:50%;background-color:#00000080;z-index:20;opacity:0;transition:opacity .2s ease;pointer-events:none;background-position:center;background-repeat:no-repeat}.play-icon{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E")}.pause-icon{background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' width='36' height='36' fill='white'%3E%3Cpath d='M6 19h4V5H6v14zm8-14v14h4V5h-4z'/%3E%3C/svg%3E")}.video-player-container:hover .play-pause-indicator{opacity:1}.timeline-scroll-container{height:6rem;border-radius:.375rem;overflow-x:auto;overflow-y:hidden;margin-bottom:.75rem;background-color:#eee;position:relative}.timeline-container{position:relative;background-color:#eee;height:6rem;width:100%;cursor:pointer;transition:width .3s ease}.timeline-marker{position:absolute;top:-10px;height:calc(100% + 10px);width:2px;background-color:red;z-index:100;pointer-events:none;box-shadow:0 0 4px #ff000080}.trim-line-marker{position:absolute;top:0;bottom:0;width:2px;background-color:#007bffe6;z-index:10}.trim-handle{width:8px;background-color:#6c757de6;position:absolute;top:0;bottom:0;cursor:ew-resize;z-index:15}.trim-handle.left{left:-4px}.trim-handle.right{right:-4px}.timeline-thumbnail{height:100%;border-right:1px solid rgba(0,0,0,.1);position:relative;display:inline-block;background-size:cover;background-position:center}.split-point{position:absolute;width:2px;background-color:#6c757de6;top:0;bottom:0;z-index:5}.clip-segment{position:absolute;height:95%;top:0;border-radius:4px;background-size:cover;background-position:center;background-blend-mode:soft-light;box-shadow:0 2px 8px #0003;overflow:hidden;cursor:grab;-webkit-user-select:none;-moz-user-select:none;user-select:none;transition:box-shadow .2s,transform .1s;z-index:15}.clip-segment:nth-child(odd),.segment-color-1,.segment-color-3,.segment-color-5,.segment-color-7{background-color:transparent;border:2px solid rgba(0,123,255,.9)}.clip-segment:nth-child(2n),.segment-color-2,.segment-color-4,.segment-color-6,.segment-color-8{background-color:transparent;border:2px solid rgba(108,117,125,.9)}.clip-segment:hover{box-shadow:0 4px 12px #0000004d;transform:translateY(-1px);filter:brightness(1.1)}.clip-segment:active{cursor:grabbing;box-shadow:0 2px 6px #0000004d;transform:translateY(0)}.clip-segment.selected{border-width:3px;box-shadow:0 4px 12px #0006;z-index:25;filter:brightness(1.2)}.clip-segment-info{background-color:#e2e6eae6;color:#000;padding:6px 8px;font-size:.7rem;position:absolute;top:0;left:0;width:100%;border-radius:4px 4px 0 0;z-index:2;display:flex;flex-direction:column;gap:2px}.clip-segment-name{font-weight:700;color:#000}.clip-segment-time{font-size:.65rem;color:#000}.clip-segment-duration{font-size:.65rem;color:#000;background:#b3d9ff66;padding:1px 4px;border-radius:2px;display:inline-block;margin-top:2px}.clip-segment-handle{position:absolute;width:8px;top:0;bottom:0;background-color:#6c757de6;cursor:ew-resize;z-index:20;display:flex;align-items:center;justify-content:center}.clip-segment-handle:after{content:"↔";color:#fff;font-size:12px;text-shadow:0 0 2px rgba(0,0,0,.8)}.clip-segment-handle.left{left:0}.clip-segment-handle.right{right:0}.clip-segment-handle:hover{background-color:#007bffe6;width:10px}input[type=range]{-webkit-appearance:none;height:6px;background:#e0e0e0;border-radius:3px}input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;height:16px;width:16px;border-radius:50%;background:#007bffe6;cursor:pointer}[data-tooltip]{position:relative;cursor:pointer}[data-tooltip]:before{content:attr(data-tooltip);position:absolute;bottom:100%;left:50%;transform:translate(-50%);margin-bottom:8px;background-color:#000c;color:#fff;padding:5px 10px;border-radius:4px;font-size:.8rem;white-space:nowrap;z-index:1000;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;pointer-events:none}[data-tooltip]:after{content:"";position:absolute;bottom:100%;left:50%;transform:translate(-50%);border-width:5px;border-style:solid;border-color:rgba(0,0,0,.8) transparent transparent transparent;margin-bottom:0;opacity:0;visibility:hidden;transition:opacity .2s,visibility .2s;pointer-events:none}[data-tooltip]:hover:before,[data-tooltip]:hover:after{opacity:1;visibility:visible}button[disabled][data-tooltip]:before,button[disabled][data-tooltip]:after{opacity:.5}.tooltip-action-btn{position:relative}.tooltip-action-btn[data-tooltip]:before,.tooltip-action-btn[data-tooltip]:after{opacity:0;visibility:hidden;position:absolute;pointer-events:none;transition:all .3s ease}.tooltip-action-btn[data-tooltip]:before{content:attr(data-tooltip);background-color:#000c;color:#fff;font-size:12px;padding:4px 8px;border-radius:3px;white-space:nowrap;bottom:-35px;left:50%;transform:translate(-50%);z-index:9999}.tooltip-action-btn[data-tooltip]:after{content:"";border-width:5px;border-style:solid;border-color:transparent transparent rgba(0,0,0,.8) transparent;bottom:-15px;left:50%;transform:translate(-50%);z-index:9999}.tooltip-action-btn:hover[data-tooltip]:before,.tooltip-action-btn:hover[data-tooltip]:after{opacity:1;visibility:visible}.segment-tooltip{background-color:#b3d9fff2;color:#000;border-radius:4px;padding:6px;min-width:140px;z-index:1000;box-shadow:0 3px 10px #0003}.segment-tooltip:after{content:"";position:absolute;bottom:-6px;left:50%;transform:translate(-50%);width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-top:6px solid rgba(179,217,255,.95)}.tooltip-time{font-size:.85rem;font-weight:700;text-align:center;margin-bottom:6px;color:#000}.tooltip-actions{display:flex;justify-content:space-between;gap:5px;position:relative}.tooltip-action-btn{background-color:#007bff33;border:none;border-radius:3px;width:30px;height:30px;display:flex;align-items:center;justify-content:center;cursor:pointer;padding:6px;transition:background-color .2s}.tooltip-action-btn:hover{background-color:#007bff66}.tooltip-action-btn svg{width:100%;height:100%;stroke:currentColor}.tooltip-action-btn.set-in svg,.tooltip-action-btn.set-out svg{width:100%;height:100%;margin:0 auto;fill:currentColor;stroke:none}.empty-space-tooltip{background-color:#fff;border-radius:6px;box-shadow:0 2px 8px #00000026;padding:8px;z-index:50;min-width:120px;text-align:center;position:relative}.empty-space-tooltip:after{content:"";position:absolute;bottom:-8px;left:50%;transform:translate(-50%);border-width:8px 8px 0;border-style:solid;border-color:white transparent transparent}.tooltip-action-btn.new-segment{width:auto;padding:6px 10px;display:flex;align-items:center;gap:5px}.tooltip-btn-text{font-size:.8rem;white-space:nowrap;color:#000}.icon-new-segment{width:20px;height:20px}.zoom-dropdown-container{position:relative}.zoom-button{display:flex;align-items:center;gap:6px;background-color:#6c757dcc;color:#fff;border:none;border-radius:4px;padding:8px 12px;font-weight:500;cursor:pointer;transition:background-color .2s}.zoom-button:hover{background-color:#6c757d}.zoom-dropdown{background-color:#fff;border-radius:4px;box-shadow:0 2px 10px #00000026;max-height:300px;overflow-y:auto}.zoom-option{padding:8px 12px;cursor:pointer;display:flex;align-items:center;gap:5px}.zoom-option:hover{background-color:#007bff1a}.zoom-option.selected{background-color:#007bff33;font-weight:500}.save-button,.save-copy-button{background-color:#007bffcc;color:#fff;border:none;border-radius:4px;padding:8px 12px;font-weight:500;cursor:pointer;transition:background-color .2s}.save-button:hover,.save-copy-button:hover{background-color:#007bff}.save-copy-button{background-color:#6c757dcc}.save-copy-button:hover{background-color:#6c757d}.time-nav-label{font-weight:500;font-size:.9rem}.time-input{padding:6px 10px;border-radius:4px;border:1px solid #ccc;width:150px;font-family:monospace}.time-button-group{display:flex;gap:5px}.time-button{background-color:#6c757dcc;color:#fff;border:none;border-radius:4px;padding:6px 8px;font-size:.8rem;cursor:pointer;transition:background-color .2s}.time-button:hover{background-color:#6c757d}.timeline-controls{display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;padding:12px;background-color:#f5f5f5;border-radius:6px;margin-top:15px}.time-navigation{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.controls-right{display:flex;align-items:center;gap:10px}@media (max-width: 768px){.timeline-controls{flex-direction:column;align-items:flex-start;gap:15px}.controls-right{margin-top:10px;width:100%;justify-content:flex-start}}.timeline-header{display:flex;align-items:center;gap:20px;margin-bottom:10px;flex-wrap:wrap}.timeline-title{font-weight:700;margin-right:20px}.timeline-title-text{font-size:1.1rem}.current-time,.duration-time{white-space:nowrap}.time-code{font-family:monospace;font-weight:500}@media (max-width: 480px){.timeline-header{flex-direction:column;align-items:flex-start;gap:8px}.time-navigation{width:100%;flex-direction:column;align-items:flex-start;gap:10px}.time-button-group{width:100%;display:flex;justify-content:space-between;margin-top:10px}.controls-right{flex-wrap:wrap;gap:8px}.save-button,.save-copy-button{margin-top:8px;width:100%}.zoom-dropdown-container{width:100%}.zoom-button{width:100%;justify-content:center}} diff --git a/frontend-tools/static/video_editor/video-editor.js b/frontend-tools/static/video_editor/video-editor.js new file mode 100644 index 00000000..b1d27d51 --- /dev/null +++ b/frontend-tools/static/video_editor/video-editor.js @@ -0,0 +1,203 @@ +(function(){"use strict";var ch={exports:{}},Ic={exports:{}},Wo={exports:{}};Wo.exports;var fh;function kS(){return fh||(fh=1,function(_,D){/** + * @license React + * react.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */(function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var se="18.3.1",ve=Symbol.for("react.element"),te=Symbol.for("react.portal"),pe=Symbol.for("react.fragment"),d=Symbol.for("react.strict_mode"),Ae=Symbol.for("react.profiler"),ne=Symbol.for("react.provider"),$=Symbol.for("react.context"),Ie=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),Te=Symbol.for("react.suspense_list"),G=Symbol.for("react.memo"),Le=Symbol.for("react.lazy"),Oe=Symbol.for("react.offscreen"),At=Symbol.iterator,Mt="@@iterator";function he(s){if(s===null||typeof s!="object")return null;var v=At&&s[At]||s[Mt];return typeof v=="function"?v:null}var oe={current:null},et={transition:null},ae={current:null,isBatchingLegacy:!1,didScheduleLegacyUpdate:!1},tt={current:null},ee={},Ke=null;function He(s){Ke=s}ee.setExtraStackFrame=function(s){Ke=s},ee.getCurrentStack=null,ee.getStackAddendum=function(){var s="";Ke&&(s+=Ke);var v=ee.getCurrentStack;return v&&(s+=v()||""),s};var Ct=!1,Je=!1,ge=!1,Re=!1,Et=!1,nt={ReactCurrentDispatcher:oe,ReactCurrentBatchConfig:et,ReactCurrentOwner:tt};nt.ReactDebugCurrentFrame=ee,nt.ReactCurrentActQueue=ae;function Lt(s){{for(var v=arguments.length,C=new Array(v>1?v-1:0),T=1;T1?v-1:0),T=1;T1){for(var Dt=Array(yt),_t=0;_t1){for(var Fe=Array(_t),Vt=0;Vt<_t;Vt++)Fe[Vt]=arguments[Vt+2];U.children=Fe}return K(s.type,ue,I,Ce,Ge,yt,U)}function ht(s){return typeof s=="object"&&s!==null&&s.$$typeof===ve}var qe=".",sn=":";function dt(s){var v=/[=:]/g,C={"=":"=0",":":"=2"},T=s.replace(v,function(U){return C[U]});return"$"+T}var mt=!1,wt=/\/+/g;function Tr(s){return s.replace(wt,"$&/")}function ga(s,v){return typeof s=="object"&&s!==null&&s.key!=null?(J(s.key),dt(""+s.key)):v.toString(36)}function ba(s,v,C,T,U){var ue=typeof s;(ue==="undefined"||ue==="boolean")&&(s=null);var I=!1;if(s===null)I=!0;else switch(ue){case"string":case"number":I=!0;break;case"object":switch(s.$$typeof){case ve:case te:I=!0}}if(I){var Ce=s,Ge=U(Ce),yt=T===""?qe+ga(Ce,0):T;if(ct(Ge)){var Dt="";yt!=null&&(Dt=Tr(yt)+"/"),ba(Ge,v,Dt,"",function(uf){return uf})}else Ge!=null&&(ht(Ge)&&(Ge.key&&(!Ce||Ce.key!==Ge.key)&&J(Ge.key),Ge=rt(Ge,C+(Ge.key&&(!Ce||Ce.key!==Ge.key)?Tr(""+Ge.key)+"/":"")+yt)),v.push(Ge));return 1}var _t,Fe,Vt=0,Xt=T===""?qe:T+sn;if(ct(s))for(var ui=0;ui is not supported and will be removed in a future major release. Did you mean to render instead?")),v.Provider},set:function(I){v.Provider=I}},_currentValue:{get:function(){return v._currentValue},set:function(I){v._currentValue=I}},_currentValue2:{get:function(){return v._currentValue2},set:function(I){v._currentValue2=I}},_threadCount:{get:function(){return v._threadCount},set:function(I){v._threadCount=I}},Consumer:{get:function(){return C||(C=!0,Me("Rendering is not supported and will be removed in a future major release. Did you mean to render instead?")),v.Consumer}},displayName:{get:function(){return v.displayName},set:function(I){U||(Lt("Setting `displayName` on Context.Consumer has no effect. You should set it directly on the context with Context.displayName = '%s'.",I),U=!0)}}}),v.Consumer=ue}return v._currentRenderer=null,v._currentRenderer2=null,v}var Sa=-1,Zn=0,ea=1,Fa=2;function ei(s){if(s._status===Sa){var v=s._result,C=v();if(C.then(function(ue){if(s._status===Zn||s._status===Sa){var I=s;I._status=ea,I._result=ue}},function(ue){if(s._status===Zn||s._status===Sa){var I=s;I._status=Fa,I._result=ue}}),s._status===Sa){var T=s;T._status=Zn,T._result=C}}if(s._status===ea){var U=s._result;return U===void 0&&Me(`lazy: Expected the result of a dynamic import() call. Instead received: %s + +Your code should look like: + const MyComponent = lazy(() => import('./MyComponent')) + +Did you accidentally put curly braces around the import?`,U),"default"in U||Me(`lazy: Expected the result of a dynamic import() call. Instead received: %s + +Your code should look like: + const MyComponent = lazy(() => import('./MyComponent'))`,U),U.default}else throw s._result}function ti(s){var v={_status:Sa,_result:s},C={$$typeof:Le,_payload:v,_init:ei};{var T,U;Object.defineProperties(C,{defaultProps:{configurable:!0,get:function(){return T},set:function(ue){Me("React.lazy(...): It is not supported to assign `defaultProps` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),T=ue,Object.defineProperty(C,"defaultProps",{enumerable:!0})}},propTypes:{configurable:!0,get:function(){return U},set:function(ue){Me("React.lazy(...): It is not supported to assign `propTypes` to a lazy component import. Either specify them where the component is defined, or create a wrapping component around it."),U=ue,Object.defineProperty(C,"propTypes",{enumerable:!0})}}})}return C}function ni(s){s!=null&&s.$$typeof===G?Me("forwardRef requires a render function but received a `memo` component. Instead of forwardRef(memo(...)), use memo(forwardRef(...))."):typeof s!="function"?Me("forwardRef requires a render function but was given %s.",s===null?"null":typeof s):s.length!==0&&s.length!==2&&Me("forwardRef render functions accept exactly two parameters: props and ref. %s",s.length===1?"Did you forget to use the ref parameter?":"Any additional parameter will be undefined."),s!=null&&(s.defaultProps!=null||s.propTypes!=null)&&Me("forwardRef render functions do not support propTypes or defaultProps. Did you accidentally pass a React component?");var v={$$typeof:Ie,render:s};{var C;Object.defineProperty(v,"displayName",{enumerable:!1,configurable:!0,get:function(){return C},set:function(T){C=T,!s.name&&!s.displayName&&(s.displayName=T)}})}return v}var p;p=Symbol.for("react.module.reference");function O(s){return!!(typeof s=="string"||typeof s=="function"||s===pe||s===Ae||Et||s===d||s===H||s===Te||Re||s===Oe||Ct||Je||ge||typeof s=="object"&&s!==null&&(s.$$typeof===Le||s.$$typeof===G||s.$$typeof===ne||s.$$typeof===$||s.$$typeof===Ie||s.$$typeof===p||s.getModuleId!==void 0))}function V(s,v){O(s)||Me("memo: The first argument must be a component. Instead received: %s",s===null?"null":typeof s);var C={$$typeof:G,type:s,compare:v===void 0?null:v};{var T;Object.defineProperty(C,"displayName",{enumerable:!1,configurable:!0,get:function(){return T},set:function(U){T=U,!s.name&&!s.displayName&&(s.displayName=U)}})}return C}function Q(){var s=oe.current;return s===null&&Me(`Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: +1. You might have mismatching versions of React and the renderer (such as React DOM) +2. You might be breaking the Rules of Hooks +3. You might have more than one copy of React in the same app +See https://reactjs.org/link/invalid-hook-call for tips about how to debug and fix this problem.`),s}function $e(s){var v=Q();if(s._context!==void 0){var C=s._context;C.Consumer===s?Me("Calling useContext(Context.Consumer) is not supported, may cause bugs, and will be removed in a future major release. Did you mean to call useContext(Context) instead?"):C.Provider===s&&Me("Calling useContext(Context.Provider) is not supported. Did you mean to call useContext(Context) instead?")}return v.useContext(s)}function _e(s){var v=Q();return v.useState(s)}function Pe(s,v,C){var T=Q();return T.useReducer(s,v,C)}function ze(s){var v=Q();return v.useRef(s)}function Qt(s,v){var C=Q();return C.useEffect(s,v)}function Nt(s,v){var C=Q();return C.useInsertionEffect(s,v)}function zt(s,v){var C=Q();return C.useLayoutEffect(s,v)}function wn(s,v){var C=Q();return C.useCallback(s,v)}function Ca(s,v){var C=Q();return C.useMemo(s,v)}function Ea(s,v,C){var T=Q();return T.useImperativeHandle(s,v,C)}function Ht(s,v){{var C=Q();return C.useDebugValue(s,v)}}function Ne(){var s=Q();return s.useTransition()}function er(s){var v=Q();return v.useDeferredValue(s)}function Yi(){var s=Q();return s.useId()}function Vu(s,v,C){var T=Q();return T.useSyncExternalStore(s,v,C)}var xr=0,Xo,Io,Ko,Jo,Zo,Bu,$u;function qi(){}qi.__reactDisabledLog=!0;function el(){{if(xr===0){Xo=console.log,Io=console.info,Ko=console.warn,Jo=console.error,Zo=console.group,Bu=console.groupCollapsed,$u=console.groupEnd;var s={configurable:!0,enumerable:!0,value:qi,writable:!0};Object.defineProperties(console,{info:s,log:s,warn:s,error:s,group:s,groupCollapsed:s,groupEnd:s})}xr++}}function Va(){{if(xr--,xr===0){var s={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:Pt({},s,{value:Xo}),info:Pt({},s,{value:Io}),warn:Pt({},s,{value:Ko}),error:Pt({},s,{value:Jo}),group:Pt({},s,{value:Zo}),groupCollapsed:Pt({},s,{value:Bu}),groupEnd:Pt({},s,{value:$u})})}xr<0&&Me("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}}var ai=nt.ReactCurrentDispatcher,wr;function Gi(s,v,C){{if(wr===void 0)try{throw Error()}catch(U){var T=U.stack.trim().match(/\n( *(at )?)/);wr=T&&T[1]||""}return` +`+wr+s}}var ri=!1,Wi;{var tl=typeof WeakMap=="function"?WeakMap:Map;Wi=new tl}function Pu(s,v){if(!s||ri)return"";{var C=Wi.get(s);if(C!==void 0)return C}var T;ri=!0;var U=Error.prepareStackTrace;Error.prepareStackTrace=void 0;var ue;ue=ai.current,ai.current=null,el();try{if(v){var I=function(){throw Error()};if(Object.defineProperty(I.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(I,[])}catch(Xt){T=Xt}Reflect.construct(s,[],I)}else{try{I.call()}catch(Xt){T=Xt}s.call(I.prototype)}}else{try{throw Error()}catch(Xt){T=Xt}s()}}catch(Xt){if(Xt&&T&&typeof Xt.stack=="string"){for(var Ce=Xt.stack.split(` +`),Ge=T.stack.split(` +`),yt=Ce.length-1,Dt=Ge.length-1;yt>=1&&Dt>=0&&Ce[yt]!==Ge[Dt];)Dt--;for(;yt>=1&&Dt>=0;yt--,Dt--)if(Ce[yt]!==Ge[Dt]){if(yt!==1||Dt!==1)do if(yt--,Dt--,Dt<0||Ce[yt]!==Ge[Dt]){var _t=` +`+Ce[yt].replace(" at new "," at ");return s.displayName&&_t.includes("")&&(_t=_t.replace("",s.displayName)),typeof s=="function"&&Wi.set(s,_t),_t}while(yt>=1&&Dt>=0);break}}}finally{ri=!1,ai.current=ue,Va(),Error.prepareStackTrace=U}var Fe=s?s.displayName||s.name:"",Vt=Fe?Gi(Fe):"";return typeof s=="function"&&Wi.set(s,Vt),Vt}function nl(s,v,C){return Pu(s,!1)}function tf(s){var v=s.prototype;return!!(v&&v.isReactComponent)}function ii(s,v,C){if(s==null)return"";if(typeof s=="function")return Pu(s,tf(s));if(typeof s=="string")return Gi(s);switch(s){case H:return Gi("Suspense");case Te:return Gi("SuspenseList")}if(typeof s=="object")switch(s.$$typeof){case Ie:return nl(s.render);case G:return ii(s.type,v,C);case Le:{var T=s,U=T._payload,ue=T._init;try{return ii(ue(U),v,C)}catch{}}}return""}var Yu={},al=nt.ReactDebugCurrentFrame;function ut(s){if(s){var v=s._owner,C=ii(s.type,s._source,v?v.type:null);al.setExtraStackFrame(C)}else al.setExtraStackFrame(null)}function nf(s,v,C,T,U){{var ue=Function.call.bind(fe);for(var I in s)if(ue(s,I)){var Ce=void 0;try{if(typeof s[I]!="function"){var Ge=Error((T||"React class")+": "+C+" type `"+I+"` is invalid; it must be a function, usually from the `prop-types` package, but received `"+typeof s[I]+"`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");throw Ge.name="Invariant Violation",Ge}Ce=s[I](v,I,T,C,null,"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED")}catch(yt){Ce=yt}Ce&&!(Ce instanceof Error)&&(ut(U),Me("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).",T||"React class",C,I,typeof Ce),ut(null)),Ce instanceof Error&&!(Ce.message in Yu)&&(Yu[Ce.message]=!0,ut(U),Me("Failed %s type: %s",C,Ce.message),ut(null))}}}function tr(s){if(s){var v=s._owner,C=ii(s.type,s._source,v?v.type:null);He(C)}else He(null)}var we;we=!1;function rl(){if(tt.current){var s=W(tt.current.type);if(s)return` + +Check the render method of \``+s+"`."}return""}function Un(s){if(s!==void 0){var v=s.fileName.replace(/^.*[\\\/]/,""),C=s.lineNumber;return` + +Check your code at `+v+":"+C+"."}return""}function oi(s){return s!=null?Un(s.__source):""}var Dr={};function af(s){var v=rl();if(!v){var C=typeof s=="string"?s:s.displayName||s.name;C&&(v=` + +Check the top-level render call using <`+C+">.")}return v}function cn(s,v){if(!(!s._store||s._store.validated||s.key!=null)){s._store.validated=!0;var C=af(v);if(!Dr[C]){Dr[C]=!0;var T="";s&&s._owner&&s._owner!==tt.current&&(T=" It was passed a child from "+W(s._owner.type)+"."),tr(s),Me('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.',C,T),tr(null)}}}function Ft(s,v){if(typeof s=="object"){if(ct(s))for(var C=0;C",U=" Did you accidentally export a JSX literal instead of a component?"):I=typeof s,Me("React.createElement: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s",I,U)}var Ce=ye.apply(this,arguments);if(Ce==null)return Ce;if(T)for(var Ge=2;Ge10&&Lt("Detected a large number of updates inside startTransition. If this is due to a subscription please re-write it to use React provided hooks. Otherwise concurrent mode guarantees are off the table."),T._updatedFibers.clear()}}}var ol=!1,Qi=null;function of(s){if(Qi===null)try{var v=("require"+Math.random()).slice(0,7),C=_&&_[v];Qi=C.call(_,"timers").setImmediate}catch{Qi=function(U){ol===!1&&(ol=!0,typeof MessageChannel>"u"&&Me("This browser does not have a MessageChannel implementation, so enqueuing tasks via await act(async () => ...) will fail. Please file an issue at https://github.com/facebook/react/issues if you encounter this warning."));var ue=new MessageChannel;ue.port1.onmessage=U,ue.port2.postMessage(void 0)}}return Qi(s)}var _r=0,li=!1;function ll(s){{var v=_r;_r++,ae.current===null&&(ae.current=[]);var C=ae.isBatchingLegacy,T;try{if(ae.isBatchingLegacy=!0,T=s(),!C&&ae.didScheduleLegacyUpdate){var U=ae.current;U!==null&&(ae.didScheduleLegacyUpdate=!1,Ki(U))}}catch(Fe){throw nr(v),Fe}finally{ae.isBatchingLegacy=C}if(T!==null&&typeof T=="object"&&typeof T.then=="function"){var ue=T,I=!1,Ce={then:function(Fe,Vt){I=!0,ue.then(function(Xt){nr(v),_r===0?Xi(Xt,Fe,Vt):Fe(Xt)},function(Xt){nr(v),Vt(Xt)})}};return!li&&typeof Promise<"u"&&Promise.resolve().then(function(){}).then(function(){I||(li=!0,Me("You called act(async () => ...) without await. This could lead to unexpected testing behaviour, interleaving multiple act calls and mixing their scopes. You should - await act(async () => ...);"))}),Ce}else{var Ge=T;if(nr(v),_r===0){var yt=ae.current;yt!==null&&(Ki(yt),ae.current=null);var Dt={then:function(Fe,Vt){ae.current===null?(ae.current=[],Xi(Ge,Fe,Vt)):Fe(Ge)}};return Dt}else{var _t={then:function(Fe,Vt){Fe(Ge)}};return _t}}}}function nr(s){s!==_r-1&&Me("You seem to have overlapping act() calls, this is not supported. Be sure to await previous act() calls before making a new one. "),_r=s}function Xi(s,v,C){{var T=ae.current;if(T!==null)try{Ki(T),of(function(){T.length===0?(ae.current=null,v(s)):Xi(s,v,C)})}catch(U){C(U)}else v(s)}}var Ii=!1;function Ki(s){if(!Ii){Ii=!0;var v=0;try{for(;v1?O-1:0),Q=1;Q=1&&zt>=0&&ze[Nt]!==Qt[zt];)zt--;for(;Nt>=1&&zt>=0;Nt--,zt--)if(ze[Nt]!==Qt[zt]){if(Nt!==1||zt!==1)do if(Nt--,zt--,zt<0||ze[Nt]!==Qt[zt]){var wn=` +`+ze[Nt].replace(" at new "," at ");return p.displayName&&wn.includes("")&&(wn=wn.replace("",p.displayName)),typeof p=="function"&&xe.set(p,wn),wn}while(Nt>=1&&zt>=0);break}}}finally{De=!1,Jn.current=_e,Yn(),Error.prepareStackTrace=$e}var Ca=p?p.displayName||p.name:"",Ea=Ca?N(Ca):"";return typeof p=="function"&&xe.set(p,Ea),Ea}function ct(p,O,V){return Ye(p,!1)}function kt(p){var O=p.prototype;return!!(O&&O.isReactComponent)}function at(p,O,V){if(p==null)return"";if(typeof p=="function")return Ye(p,kt(p));if(typeof p=="string")return N(p);switch(p){case $:return N("Suspense");case Ie:return N("SuspenseList")}if(typeof p=="object")switch(p.$$typeof){case ne:return ct(p.render);case H:return at(p.type,O,V);case Te:{var Q=p,$e=Q._payload,_e=Q._init;try{return at(_e($e),O,V)}catch{}}}return""}var L=Object.prototype.hasOwnProperty,J={},z=Mt.ReactDebugCurrentFrame;function ie(p){if(p){var O=p._owner,V=at(p.type,p._source,O?O.type:null);z.setExtraStackFrame(V)}else z.setExtraStackFrame(null)}function W(p,O,V,Q,$e){{var _e=Function.call.bind(L);for(var Pe in p)if(_e(p,Pe)){var ze=void 0;try{if(typeof p[Pe]!="function"){var Qt=Error((Q||"React class")+": "+V+" type `"+Pe+"` is invalid; it must be a function, usually from the `prop-types` package, but received `"+typeof p[Pe]+"`.This often happens because of typos such as `PropTypes.function` instead of `PropTypes.func`.");throw Qt.name="Invariant Violation",Qt}ze=p[Pe](O,Pe,Q,V,null,"SECRET_DO_NOT_PASS_THIS_OR_YOU_WILL_BE_FIRED")}catch(Nt){ze=Nt}ze&&!(ze instanceof Error)&&(ie($e),he("%s: type specification of %s `%s` is invalid; the type checker function must return `null` or an `Error` but returned a %s. You may have forgotten to pass an argument to the type checker creator (arrayOf, instanceOf, objectOf, oneOf, oneOfType, and shape all require an argument).",Q||"React class",V,Pe,typeof ze),ie(null)),ze instanceof Error&&!(ze.message in J)&&(J[ze.message]=!0,ie($e),he("Failed %s type: %s",V,ze.message),ie(null))}}}var fe=Array.isArray;function je(p){return fe(p)}function ke(p){{var O=typeof Symbol=="function"&&Symbol.toStringTag,V=O&&p[Symbol.toStringTag]||p.constructor.name||"Object";return V}}function Ze(p){try{return lt(p),!1}catch{return!0}}function lt(p){return""+p}function xt(p){if(Ze(p))return he("The provided key is an unsupported type %s. This value must be coerced to a string before before using it here.",ke(p)),lt(p)}var ft=Mt.ReactCurrentOwner,Bt={key:!0,ref:!0,__self:!0,__source:!0},mn,A;function K(p){if(L.call(p,"ref")){var O=Object.getOwnPropertyDescriptor(p,"ref").get;if(O&&O.isReactWarning)return!1}return p.ref!==void 0}function ye(p){if(L.call(p,"key")){var O=Object.getOwnPropertyDescriptor(p,"key").get;if(O&&O.isReactWarning)return!1}return p.key!==void 0}function rt(p,O){typeof p.ref=="string"&&ft.current}function Tt(p,O){{var V=function(){mn||(mn=!0,he("%s: `key` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)",O))};V.isReactWarning=!0,Object.defineProperty(p,"key",{get:V,configurable:!0})}}function ht(p,O){{var V=function(){A||(A=!0,he("%s: `ref` is not a prop. Trying to access it will result in `undefined` being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://reactjs.org/link/special-props)",O))};V.isReactWarning=!0,Object.defineProperty(p,"ref",{get:V,configurable:!0})}}var qe=function(p,O,V,Q,$e,_e,Pe){var ze={$$typeof:D,type:p,key:O,ref:V,props:Pe,_owner:_e};return ze._store={},Object.defineProperty(ze._store,"validated",{configurable:!1,enumerable:!1,writable:!0,value:!1}),Object.defineProperty(ze,"_self",{configurable:!1,enumerable:!1,writable:!1,value:Q}),Object.defineProperty(ze,"_source",{configurable:!1,enumerable:!1,writable:!1,value:$e}),Object.freeze&&(Object.freeze(ze.props),Object.freeze(ze)),ze};function sn(p,O,V,Q,$e){{var _e,Pe={},ze=null,Qt=null;V!==void 0&&(xt(V),ze=""+V),ye(O)&&(xt(O.key),ze=""+O.key),K(O)&&(Qt=O.ref,rt(O,$e));for(_e in O)L.call(O,_e)&&!Bt.hasOwnProperty(_e)&&(Pe[_e]=O[_e]);if(p&&p.defaultProps){var Nt=p.defaultProps;for(_e in Nt)Pe[_e]===void 0&&(Pe[_e]=Nt[_e])}if(ze||Qt){var zt=typeof p=="function"?p.displayName||p.name||"Unknown":p;ze&&Tt(Pe,zt),Qt&&ht(Pe,zt)}return qe(p,ze,Qt,$e,Q,ft.current,Pe)}}var dt=Mt.ReactCurrentOwner,mt=Mt.ReactDebugCurrentFrame;function wt(p){if(p){var O=p._owner,V=at(p.type,p._source,O?O.type:null);mt.setExtraStackFrame(V)}else mt.setExtraStackFrame(null)}var Tr;Tr=!1;function ga(p){return typeof p=="object"&&p!==null&&p.$$typeof===D}function ba(){{if(dt.current){var p=Re(dt.current.type);if(p)return` + +Check the render method of \``+p+"`."}return""}}function Rr(p){return""}var Vi={};function Bi(p){{var O=ba();if(!O){var V=typeof p=="string"?p:p.displayName||p.name;V&&(O=` + +Check the top-level render call using <`+V+">.")}return O}}function Zr(p,O){{if(!p._store||p._store.validated||p.key!=null)return;p._store.validated=!0;var V=Bi(O);if(Vi[V])return;Vi[V]=!0;var Q="";p&&p._owner&&p._owner!==dt.current&&(Q=" It was passed a child from "+Re(p._owner.type)+"."),wt(p),he('Each child in a list should have a unique "key" prop.%s%s See https://reactjs.org/link/warning-keys for more information.',V,Q),wt(null)}}function $i(p,O){{if(typeof p!="object")return;if(je(p))for(var V=0;V",ze=" Did you accidentally export a JSX literal instead of a component?"):Nt=typeof p,he("React.jsx: type is invalid -- expected a string (for built-in components) or a class/function (for composite components) but got: %s.%s",Nt,ze)}var zt=sn(p,O,V,$e,_e);if(zt==null)return zt;if(Pe){var wn=O.children;if(wn!==void 0)if(Q)if(je(wn)){for(var Ca=0;Ca0?"{key: someKey, "+Ht.join(": ..., ")+": ...}":"{key: someKey}";if(!Zn[Ea+Ne]){var er=Ht.length>0?"{"+Ht.join(": ..., ")+": ...}":"{}";he(`A props object containing a "key" prop is being spread into JSX: + let props = %s; + <%s {...props} /> +React keys must be passed directly to JSX without using spread: + let props = %s; + <%s key={someKey} {...props} />`,Ne,Ea,er,Ea),Zn[Ea+Ne]=!0}}return p===ve?Sa(zt):Pi(zt),zt}}function Fa(p,O,V){return ea(p,O,V,!0)}function ei(p,O,V){return ea(p,O,V,!1)}var ti=ei,ni=Fa;Qo.Fragment=ve,Qo.jsx=ti,Qo.jsxs=ni}(),Qo}ch.exports=NS();var h=ch.exports,ph={exports:{}},Jc={exports:{}},Zc={},hh;function US(){return hh||(hh=1,function(_){/** + * @license React + * scheduler.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */(function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var D=!1,se=5;function ve(A,K){var ye=A.length;A.push(K),d(A,K,ye)}function te(A){return A.length===0?null:A[0]}function pe(A){if(A.length===0)return null;var K=A[0],ye=A.pop();return ye!==K&&(A[0]=ye,Ae(A,ye,0)),K}function d(A,K,ye){for(var rt=ye;rt>0;){var Tt=rt-1>>>1,ht=A[Tt];if(ne(ht,K)>0)A[Tt]=K,A[rt]=ht,rt=Tt;else return}}function Ae(A,K,ye){for(var rt=ye,Tt=A.length,ht=Tt>>>1;rtye&&(!A||z()));){var rt=ge.callback;if(typeof rt=="function"){ge.callback=null,Re=ge.priorityLevel;var Tt=ge.expirationTime<=ye,ht=rt(Tt);ye=_.unstable_now(),typeof ht=="function"?ge.callback=ht:ge===te(He)&&pe(He),un(ye)}else pe(He);ge=te(He)}if(ge!==null)return!0;var qe=te(Ct);return qe!==null&&xt(Pn,qe.startTime-ye),!1}function Jt(A,K){switch(A){case $:case Ie:case H:case Te:case G:break;default:A=H}var ye=Re;Re=A;try{return K()}finally{Re=ye}}function Yn(A){var K;switch(Re){case $:case Ie:case H:K=H;break;default:K=Re;break}var ye=Re;Re=K;try{return A()}finally{Re=ye}}function Jn(A){var K=Re;return function(){var ye=Re;Re=K;try{return A.apply(this,arguments)}finally{Re=ye}}}function Wt(A,K,ye){var rt=_.unstable_now(),Tt;if(typeof ye=="object"&&ye!==null){var ht=ye.delay;typeof ht=="number"&&ht>0?Tt=rt+ht:Tt=rt}else Tt=rt;var qe;switch(A){case $:qe=et;break;case Ie:qe=ae;break;case G:qe=Ke;break;case Te:qe=ee;break;case H:default:qe=tt;break}var sn=Tt+qe,dt={id:Je++,callback:K,priorityLevel:A,startTime:Tt,expirationTime:sn,sortIndex:-1};return Tt>rt?(dt.sortIndex=Tt,ve(Ct,dt),te(He)===null&&dt===te(Ct)&&(Lt?ft():Lt=!0,xt(Pn,Tt-rt))):(dt.sortIndex=sn,ve(He,dt),!nt&&!Et&&(nt=!0,lt(Pt))),dt}function N(){}function De(){!nt&&!Et&&(nt=!0,lt(Pt))}function xe(){return te(He)}function me(A){A.callback=null}function Ye(){return Re}var ct=!1,kt=null,at=-1,L=se,J=-1;function z(){var A=_.unstable_now()-J;return!(A125){console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported");return}A>0?L=Math.floor(1e3/A):L=se}var fe=function(){if(kt!==null){var A=_.unstable_now();J=A;var K=!0,ye=!0;try{ye=kt(K,A)}finally{ye?je():(ct=!1,kt=null)}}else ct=!1},je;if(typeof Kn=="function")je=function(){Kn(fe)};else if(typeof MessageChannel<"u"){var ke=new MessageChannel,Ze=ke.port2;ke.port1.onmessage=fe,je=function(){Ze.postMessage(null)}}else je=function(){Me(fe,0)};function lt(A){kt=A,ct||(ct=!0,je())}function xt(A,K){at=Me(function(){A(_.unstable_now())},K)}function ft(){jt(at),at=-1}var Bt=ie,mn=null;_.unstable_IdlePriority=G,_.unstable_ImmediatePriority=$,_.unstable_LowPriority=Te,_.unstable_NormalPriority=H,_.unstable_Profiling=mn,_.unstable_UserBlockingPriority=Ie,_.unstable_cancelCallback=me,_.unstable_continueExecution=De,_.unstable_forceFrameRate=W,_.unstable_getCurrentPriorityLevel=Ye,_.unstable_getFirstCallbackNode=xe,_.unstable_next=Yn,_.unstable_pauseExecution=N,_.unstable_requestPaint=Bt,_.unstable_runWithPriority=Jt,_.unstable_scheduleCallback=Wt,_.unstable_shouldYield=z,_.unstable_wrapCallback=Jn,typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStop(new Error)})()}(Zc)),Zc}var mh;function AS(){return mh||(mh=1,Jc.exports=US()),Jc.exports}var $n={},yh;function jS(){if(yh)return $n;yh=1;/** + * @license React + * react-dom.development.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */return function(){typeof __REACT_DEVTOOLS_GLOBAL_HOOK__<"u"&&typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart=="function"&&__REACT_DEVTOOLS_GLOBAL_HOOK__.registerInternalModuleStart(new Error);var _=Kc(),D=AS(),se=_.__SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED,ve=!1;function te(e){ve=e}function pe(e){if(!ve){for(var t=arguments.length,n=new Array(t>1?t-1:0),a=1;a1?t-1:0),a=1;a2&&(e[0]==="o"||e[0]==="O")&&(e[1]==="n"||e[1]==="N")}function qe(e,t,n,a){if(n!==null&&n.type===je)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":{if(a)return!1;if(n!==null)return!n.acceptsBooleans;var r=e.toLowerCase().slice(0,5);return r!=="data-"&&r!=="aria-"}default:return!1}}function sn(e,t,n,a){if(t===null||typeof t>"u"||qe(e,t,n,a))return!0;if(a)return!1;if(n!==null)switch(n.type){case lt:return!t;case xt:return t===!1;case ft:return isNaN(t);case Bt:return isNaN(t)||t<1}return!1}function dt(e){return wt.hasOwnProperty(e)?wt[e]:null}function mt(e,t,n,a,r,i,o){this.acceptsBooleans=t===Ze||t===lt||t===xt,this.attributeName=a,this.attributeNamespace=r,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=o}var wt={},Tr=["children","dangerouslySetInnerHTML","defaultValue","defaultChecked","innerHTML","suppressContentEditableWarning","suppressHydrationWarning","style"];Tr.forEach(function(e){wt[e]=new mt(e,je,!1,e,null,!1,!1)}),[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0],n=e[1];wt[t]=new mt(t,ke,!1,n,null,!1,!1)}),["contentEditable","draggable","spellCheck","value"].forEach(function(e){wt[e]=new mt(e,Ze,!1,e.toLowerCase(),null,!1,!1)}),["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){wt[e]=new mt(e,Ze,!1,e,null,!1,!1)}),["allowFullScreen","async","autoFocus","autoPlay","controls","default","defer","disabled","disablePictureInPicture","disableRemotePlayback","formNoValidate","hidden","loop","noModule","noValidate","open","playsInline","readOnly","required","reversed","scoped","seamless","itemScope"].forEach(function(e){wt[e]=new mt(e,lt,!1,e.toLowerCase(),null,!1,!1)}),["checked","multiple","muted","selected"].forEach(function(e){wt[e]=new mt(e,lt,!0,e,null,!1,!1)}),["capture","download"].forEach(function(e){wt[e]=new mt(e,xt,!1,e,null,!1,!1)}),["cols","rows","size","span"].forEach(function(e){wt[e]=new mt(e,Bt,!1,e,null,!1,!1)}),["rowSpan","start"].forEach(function(e){wt[e]=new mt(e,ft,!1,e.toLowerCase(),null,!1,!1)});var ga=/[\-\:]([a-z])/g,ba=function(e){return e[1].toUpperCase()};["accent-height","alignment-baseline","arabic-form","baseline-shift","cap-height","clip-path","clip-rule","color-interpolation","color-interpolation-filters","color-profile","color-rendering","dominant-baseline","enable-background","fill-opacity","fill-rule","flood-color","flood-opacity","font-family","font-size","font-size-adjust","font-stretch","font-style","font-variant","font-weight","glyph-name","glyph-orientation-horizontal","glyph-orientation-vertical","horiz-adv-x","horiz-origin-x","image-rendering","letter-spacing","lighting-color","marker-end","marker-mid","marker-start","overline-position","overline-thickness","paint-order","panose-1","pointer-events","rendering-intent","shape-rendering","stop-color","stop-opacity","strikethrough-position","strikethrough-thickness","stroke-dasharray","stroke-dashoffset","stroke-linecap","stroke-linejoin","stroke-miterlimit","stroke-opacity","stroke-width","text-anchor","text-decoration","text-rendering","underline-position","underline-thickness","unicode-bidi","unicode-range","units-per-em","v-alphabetic","v-hanging","v-ideographic","v-mathematical","vector-effect","vert-adv-y","vert-origin-x","vert-origin-y","word-spacing","writing-mode","xmlns:xlink","x-height"].forEach(function(e){var t=e.replace(ga,ba);wt[t]=new mt(t,ke,!1,e,null,!1,!1)}),["xlink:actuate","xlink:arcrole","xlink:role","xlink:show","xlink:title","xlink:type"].forEach(function(e){var t=e.replace(ga,ba);wt[t]=new mt(t,ke,!1,e,"http://www.w3.org/1999/xlink",!1,!1)}),["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(ga,ba);wt[t]=new mt(t,ke,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)}),["tabIndex","crossOrigin"].forEach(function(e){wt[e]=new mt(e,ke,!1,e.toLowerCase(),null,!1,!1)});var Rr="xlinkHref";wt[Rr]=new mt("xlinkHref",ke,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1),["src","href","action","formAction"].forEach(function(e){wt[e]=new mt(e,ke,!1,e.toLowerCase(),null,!0,!0)});var Vi=/^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[\r\n\t]*s[\r\n\t]*c[\r\n\t]*r[\r\n\t]*i[\r\n\t]*p[\r\n\t]*t[\r\n\t]*\:/i,Bi=!1;function Zr(e){!Bi&&Vi.test(e)&&(Bi=!0,d("A future version of React will block javascript: URLs as a security precaution. Use event handlers instead if you can. If you need to generate unsafe HTML try using dangerouslySetInnerHTML instead. React was passed %s.",JSON.stringify(e)))}function $i(e,t,n,a){if(a.mustUseProperty){var r=a.propertyName;return e[r]}else{L(n,t),a.sanitizeURL&&Zr(""+n);var i=a.attributeName,o=null;if(a.type===xt){if(e.hasAttribute(i)){var l=e.getAttribute(i);return l===""?!0:sn(t,n,a,!1)?l:l===""+n?n:l}}else if(e.hasAttribute(i)){if(sn(t,n,a,!1))return e.getAttribute(i);if(a.type===lt)return n;o=e.getAttribute(i)}return sn(t,n,a,!1)?o===null?n:o:o===""+n?n:o}}function Pi(e,t,n,a){{if(!Tt(t))return;if(!e.hasAttribute(t))return n===void 0?void 0:null;var r=e.getAttribute(t);return L(n,t),r===""+n?n:r}}function Sa(e,t,n,a){var r=dt(t);if(!ht(t,r,a)){if(sn(t,n,r,a)&&(n=null),a||r===null){if(Tt(t)){var i=t;n===null?e.removeAttribute(i):(L(n,t),e.setAttribute(i,""+n))}return}var o=r.mustUseProperty;if(o){var l=r.propertyName;if(n===null){var u=r.type;e[l]=u===lt?!1:""}else e[l]=n;return}var c=r.attributeName,f=r.attributeNamespace;if(n===null)e.removeAttribute(c);else{var y=r.type,m;y===lt||y===xt&&n===!0?m="":(L(n,c),m=""+n,r.sanitizeURL&&Zr(m.toString())),f?e.setAttributeNS(f,c,m):e.setAttribute(c,m)}}}var Zn=Symbol.for("react.element"),ea=Symbol.for("react.portal"),Fa=Symbol.for("react.fragment"),ei=Symbol.for("react.strict_mode"),ti=Symbol.for("react.profiler"),ni=Symbol.for("react.provider"),p=Symbol.for("react.context"),O=Symbol.for("react.forward_ref"),V=Symbol.for("react.suspense"),Q=Symbol.for("react.suspense_list"),$e=Symbol.for("react.memo"),_e=Symbol.for("react.lazy"),Pe=Symbol.for("react.scope"),ze=Symbol.for("react.debug_trace_mode"),Qt=Symbol.for("react.offscreen"),Nt=Symbol.for("react.legacy_hidden"),zt=Symbol.for("react.cache"),wn=Symbol.for("react.tracing_marker"),Ca=Symbol.iterator,Ea="@@iterator";function Ht(e){if(e===null||typeof e!="object")return null;var t=Ca&&e[Ca]||e[Ea];return typeof t=="function"?t:null}var Ne=Object.assign,er=0,Yi,Vu,xr,Xo,Io,Ko,Jo;function Zo(){}Zo.__reactDisabledLog=!0;function Bu(){{if(er===0){Yi=console.log,Vu=console.info,xr=console.warn,Xo=console.error,Io=console.group,Ko=console.groupCollapsed,Jo=console.groupEnd;var e={configurable:!0,enumerable:!0,value:Zo,writable:!0};Object.defineProperties(console,{info:e,log:e,warn:e,error:e,group:e,groupCollapsed:e,groupEnd:e})}er++}}function $u(){{if(er--,er===0){var e={configurable:!0,enumerable:!0,writable:!0};Object.defineProperties(console,{log:Ne({},e,{value:Yi}),info:Ne({},e,{value:Vu}),warn:Ne({},e,{value:xr}),error:Ne({},e,{value:Xo}),group:Ne({},e,{value:Io}),groupCollapsed:Ne({},e,{value:Ko}),groupEnd:Ne({},e,{value:Jo})})}er<0&&d("disabledDepth fell below zero. This is a bug in React. Please file an issue.")}}var qi=se.ReactCurrentDispatcher,el;function Va(e,t,n){{if(el===void 0)try{throw Error()}catch(r){var a=r.stack.trim().match(/\n( *(at )?)/);el=a&&a[1]||""}return` +`+el+e}}var ai=!1,wr;{var Gi=typeof WeakMap=="function"?WeakMap:Map;wr=new Gi}function ri(e,t){if(!e||ai)return"";{var n=wr.get(e);if(n!==void 0)return n}var a;ai=!0;var r=Error.prepareStackTrace;Error.prepareStackTrace=void 0;var i;i=qi.current,qi.current=null,Bu();try{if(t){var o=function(){throw Error()};if(Object.defineProperty(o.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(o,[])}catch(E){a=E}Reflect.construct(e,[],o)}else{try{o.call()}catch(E){a=E}e.call(o.prototype)}}else{try{throw Error()}catch(E){a=E}e()}}catch(E){if(E&&a&&typeof E.stack=="string"){for(var l=E.stack.split(` +`),u=a.stack.split(` +`),c=l.length-1,f=u.length-1;c>=1&&f>=0&&l[c]!==u[f];)f--;for(;c>=1&&f>=0;c--,f--)if(l[c]!==u[f]){if(c!==1||f!==1)do if(c--,f--,f<0||l[c]!==u[f]){var y=` +`+l[c].replace(" at new "," at ");return e.displayName&&y.includes("")&&(y=y.replace("",e.displayName)),typeof e=="function"&&wr.set(e,y),y}while(c>=1&&f>=0);break}}}finally{ai=!1,qi.current=i,$u(),Error.prepareStackTrace=r}var m=e?e.displayName||e.name:"",S=m?Va(m):"";return typeof e=="function"&&wr.set(e,S),S}function Wi(e,t,n){return ri(e,!0)}function tl(e,t,n){return ri(e,!1)}function Pu(e){var t=e.prototype;return!!(t&&t.isReactComponent)}function nl(e,t,n){if(e==null)return"";if(typeof e=="function")return ri(e,Pu(e));if(typeof e=="string")return Va(e);switch(e){case V:return Va("Suspense");case Q:return Va("SuspenseList")}if(typeof e=="object")switch(e.$$typeof){case O:return tl(e.render);case $e:return nl(e.type,t,n);case _e:{var a=e,r=a._payload,i=a._init;try{return nl(i(r),t,n)}catch{}}}return""}function tf(e){switch(e._debugOwner&&e._debugOwner.type,e._debugSource,e.tag){case G:return Va(e.type);case Ke:return Va("Lazy");case ae:return Va("Suspense");case Je:return Va("SuspenseList");case ne:case Ie:case ee:return tl(e.type);case oe:return tl(e.type.render);case $:return Wi(e.type);default:return""}}function ii(e){try{var t="",n=e;do t+=tf(n),n=n.return;while(n);return t}catch(a){return` +Error generating stack: `+a.message+` +`+a.stack}}function Yu(e,t,n){var a=e.displayName;if(a)return a;var r=t.displayName||t.name||"";return r!==""?n+"("+r+")":n}function al(e){return e.displayName||"Context"}function ut(e){if(e==null)return null;if(typeof e.tag=="number"&&d("Received an unexpected object in getComponentNameFromType(). This is likely a bug in React. Please file an issue."),typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Fa:return"Fragment";case ea:return"Portal";case ti:return"Profiler";case ei:return"StrictMode";case V:return"Suspense";case Q:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case p:var t=e;return al(t)+".Consumer";case ni:var n=e;return al(n._context)+".Provider";case O:return Yu(e,e.render,"ForwardRef");case $e:var a=e.displayName||null;return a!==null?a:ut(e.type)||"Memo";case _e:{var r=e,i=r._payload,o=r._init;try{return ut(o(i))}catch{return null}}}return null}function nf(e,t,n){var a=t.displayName||t.name||"";return e.displayName||(a!==""?n+"("+a+")":n)}function tr(e){return e.displayName||"Context"}function we(e){var t=e.tag,n=e.type;switch(t){case nt:return"Cache";case Mt:var a=n;return tr(a)+".Consumer";case he:var r=n;return tr(r._context)+".Provider";case Ct:return"DehydratedFragment";case oe:return nf(n,n.render,"ForwardRef");case Oe:return"Fragment";case G:return n;case Te:return"Portal";case H:return"Root";case Le:return"Text";case Ke:return ut(n);case At:return n===ei?"StrictMode":"Mode";case Re:return"Offscreen";case et:return"Profiler";case ge:return"Scope";case ae:return"Suspense";case Je:return"SuspenseList";case Lt:return"TracingMarker";case $:case ne:case He:case Ie:case tt:case ee:if(typeof n=="function")return n.displayName||n.name||null;if(typeof n=="string")return n;break}return null}var rl=se.ReactDebugCurrentFrame,Un=null,oi=!1;function Dr(){{if(Un===null)return null;var e=Un._debugOwner;if(e!==null&&typeof e<"u")return we(e)}return null}function af(){return Un===null?"":ii(Un)}function cn(){rl.getCurrentStack=null,Un=null,oi=!1}function Ft(e){rl.getCurrentStack=e===null?null:af,Un=e,oi=!1}function qu(){return Un}function ua(e){oi=e}function An(e){return""+e}function Ta(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return fe(e),e;default:return""}}var rf={button:!0,checkbox:!0,image:!0,hidden:!0,radio:!0,reset:!0,submit:!0};function il(e,t){rf[t.type]||t.onChange||t.onInput||t.readOnly||t.disabled||t.value==null||d("You provided a `value` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultValue`. Otherwise, set either `onChange` or `readOnly`."),t.onChange||t.readOnly||t.disabled||t.checked==null||d("You provided a `checked` prop to a form field without an `onChange` handler. This will render a read-only field. If the field should be mutable use `defaultChecked`. Otherwise, set either `onChange` or `readOnly`.")}function Gu(e){var t=e.type,n=e.nodeName;return n&&n.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function ol(e){return e._valueTracker}function Qi(e){e._valueTracker=null}function of(e){var t="";return e&&(Gu(e)?t=e.checked?"true":"false":t=e.value),t}function _r(e){var t=Gu(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t);fe(e[t]);var a=""+e[t];if(!(e.hasOwnProperty(t)||typeof n>"u"||typeof n.get!="function"||typeof n.set!="function")){var r=n.get,i=n.set;Object.defineProperty(e,t,{configurable:!0,get:function(){return r.call(this)},set:function(l){fe(l),a=""+l,i.call(this,l)}}),Object.defineProperty(e,t,{enumerable:n.enumerable});var o={getValue:function(){return a},setValue:function(l){fe(l),a=""+l},stopTracking:function(){Qi(e),delete e[t]}};return o}}function li(e){ol(e)||(e._valueTracker=_r(e))}function ll(e){if(!e)return!1;var t=ol(e);if(!t)return!0;var n=t.getValue(),a=of(e);return a!==n?(t.setValue(a),!0):!1}function nr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}var Xi=!1,Ii=!1,Ki=!1,Wu=!1;function Qu(e){var t=e.type==="checkbox"||e.type==="radio";return t?e.checked!=null:e.value!=null}function ul(e,t){var n=e,a=t.checked,r=Ne({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:a??n._wrapperState.initialChecked});return r}function Xu(e,t){il("input",t),t.checked!==void 0&&t.defaultChecked!==void 0&&!Ii&&(d("%s contains an input of type %s with both checked and defaultChecked props. Input elements must be either controlled or uncontrolled (specify either the checked prop, or the defaultChecked prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components",Dr()||"A component",t.type),Ii=!0),t.value!==void 0&&t.defaultValue!==void 0&&!Xi&&(d("%s contains an input of type %s with both value and defaultValue props. Input elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled input element and remove one of these props. More info: https://reactjs.org/link/controlled-components",Dr()||"A component",t.type),Xi=!0);var n=e,a=t.defaultValue==null?"":t.defaultValue;n._wrapperState={initialChecked:t.checked!=null?t.checked:t.defaultChecked,initialValue:Ta(t.value!=null?t.value:a),controlled:Qu(t)}}function s(e,t){var n=e,a=t.checked;a!=null&&Sa(n,"checked",a,!1)}function v(e,t){var n=e;{var a=Qu(t);!n._wrapperState.controlled&&a&&!Wu&&(d("A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"),Wu=!0),n._wrapperState.controlled&&!a&&!Ki&&(d("A component is changing a controlled input to be uncontrolled. This is likely caused by the value changing from a defined to undefined, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components"),Ki=!0)}s(e,t);var r=Ta(t.value),i=t.type;if(r!=null)i==="number"?(r===0&&n.value===""||n.value!=r)&&(n.value=An(r)):n.value!==An(r)&&(n.value=An(r));else if(i==="submit"||i==="reset"){n.removeAttribute("value");return}t.hasOwnProperty("value")?ue(n,t.type,r):t.hasOwnProperty("defaultValue")&&ue(n,t.type,Ta(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(n.defaultChecked=!!t.defaultChecked)}function C(e,t,n){var a=e;if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type,i=r==="submit"||r==="reset";if(i&&(t.value===void 0||t.value===null))return;var o=An(a._wrapperState.initialValue);n||o!==a.value&&(a.value=o),a.defaultValue=o}var l=a.name;l!==""&&(a.name=""),a.defaultChecked=!a.defaultChecked,a.defaultChecked=!!a._wrapperState.initialChecked,l!==""&&(a.name=l)}function T(e,t){var n=e;v(n,t),U(n,t)}function U(e,t){var n=t.name;if(t.type==="radio"&&n!=null){for(var a=e;a.parentNode;)a=a.parentNode;L(n,"name");for(var r=a.querySelectorAll("input[name="+JSON.stringify(""+n)+'][type="radio"]'),i=0;i.")))}):t.dangerouslySetInnerHTML!=null&&(Ge||(Ge=!0,d("Pass a `value` prop if you set dangerouslyInnerHTML so React knows which value should be selected.")))),t.selected!=null&&!I&&(d("Use the `defaultValue` or `value` props on must be a scalar value if `multiple` is false.%s",n,Xt())}}}}function ar(e,t,n,a){var r=e.options;if(t){for(var i=n,o={},l=0;l.");var a=Ne({},t,{value:void 0,defaultValue:void 0,children:An(n._wrapperState.initialValue)});return a}function xh(e,t){var n=e;il("textarea",t),t.value!==void 0&&t.defaultValue!==void 0&&!Rh&&(d("%s contains a textarea with both value and defaultValue props. Textarea elements must be either controlled or uncontrolled (specify either the value prop, or the defaultValue prop, but not both). Decide between using a controlled or uncontrolled textarea and remove one of these props. More info: https://reactjs.org/link/controlled-components",Dr()||"A component"),Rh=!0);var a=t.value;if(a==null){var r=t.children,i=t.defaultValue;if(r!=null){d("Use the `defaultValue` or `value` props instead of setting children on