Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
610716533b | ||
|
|
4f1c4a2b4c | ||
|
|
83f3eec940 | ||
|
|
a5acce4ab1 | ||
|
|
a4e9309350 | ||
|
|
6beaf0bbe2 | ||
|
|
70168299ba | ||
|
|
b28c2d8271 | ||
|
|
d34fc328bf | ||
|
|
ab4d9d67df | ||
|
|
f7a2f049bd | ||
|
|
05414f66c7 | ||
|
|
8fecccce1c | ||
|
|
2a7123ca0b | ||
|
|
20f305e69e | ||
|
|
d1fda05fdc | ||
|
|
a02e0a8a66 | ||
|
|
21f76dbb6e | ||
|
|
50e9f3103f | ||
|
|
0b9a203123 | ||
|
|
5cbd815496 | ||
|
|
3a8cacc847 | ||
|
|
5402ee7bc5 | ||
|
|
a6a2b50c8d | ||
|
|
23e48a8bb7 | ||
|
|
313cd9cbc6 | ||
|
|
0392dbe1ed | ||
|
|
a7562c244e | ||
|
|
d2ee12087c | ||
|
|
6db01932e1 | ||
|
|
53d8215346 | ||
|
|
1b960b28f8 | ||
|
|
02d9188aa1 | ||
|
|
8d9a4618f0 | ||
|
|
cf93a77802 | ||
|
|
5a1e4f25ed | ||
|
|
9fc7597e73 | ||
|
|
9b3e0250d4 | ||
|
|
1384471745 | ||
|
|
29b362c8ce | ||
|
|
b8ee2e9fb8 | ||
|
|
99be0f07dd | ||
|
|
27d1660192 | ||
|
|
98adb22205 | ||
|
|
673ddeb5bd | ||
|
|
aa8a2d92dc | ||
|
|
6bbd4c2809 | ||
|
|
c4148bd504 | ||
|
|
ea8b2af26f | ||
|
|
5aa899cef0 |
8
.github/workflows/python.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
||||
uses: actions/checkout@v1
|
||||
|
||||
- name: Build the Stack
|
||||
run: docker-compose -f docker-compose-dev.yaml build
|
||||
run: docker compose -f docker-compose-dev.yaml build
|
||||
|
||||
- name: Start containers
|
||||
run: docker-compose -f docker-compose-dev.yaml up -d
|
||||
run: docker compose -f docker-compose-dev.yaml up -d
|
||||
|
||||
- name: List containers
|
||||
run: docker ps
|
||||
@@ -26,10 +26,10 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
- name: Run Django Tests
|
||||
run: docker-compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest
|
||||
run: docker compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest
|
||||
|
||||
# Run with coverage, saves report on htmlcov dir
|
||||
# run: docker-compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest --cov --cov-report=html --cov-config=.coveragerc
|
||||
|
||||
- name: Tear down the Stack
|
||||
run: docker-compose -f docker-compose-dev.yaml down
|
||||
run: docker compose -f docker-compose-dev.yaml down
|
||||
|
||||
14
.gitignore
vendored
@@ -16,4 +16,16 @@ static/mptt/
|
||||
static/rest_framework/
|
||||
static/drf-yasg
|
||||
cms/local_settings.py
|
||||
deploy/docker/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
|
||||
/frontend-tools/video-editor-v2
|
||||
.DS_Store
|
||||
static/video_editor/videos/sample-video-10m.mp4
|
||||
static/video_editor/videos/sample-video-10s.mp4
|
||||
|
||||
1
.prettierignore
Normal file
@@ -0,0 +1 @@
|
||||
*
|
||||
120
Dockerfile
@@ -1,70 +1,88 @@
|
||||
FROM python:3.11.4-bookworm AS compile-image
|
||||
FROM python:3.13.5-bookworm AS build-image
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
# Set up virtualenv
|
||||
ENV VIRTUAL_ENV=/home/mediacms.io
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && cd /home/mediacms.io && python3 -m venv $VIRTUAL_ENV
|
||||
|
||||
# Install dependencies:
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . /home/mediacms.io/mediacms
|
||||
WORKDIR /home/mediacms.io/mediacms
|
||||
|
||||
RUN wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
|
||||
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d ../bento4 && \
|
||||
mv ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/* ../bento4/ && \
|
||||
rm -rf ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux && \
|
||||
rm -rf ../bento4/docs && \
|
||||
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
|
||||
############ RUNTIME IMAGE ############
|
||||
FROM python:3.11.4-bookworm as runtime-image
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# See: https://github.com/celery/celery/issues/6285#issuecomment-715316219
|
||||
ENV CELERY_APP='cms'
|
||||
|
||||
# Use these to toggle which processes supervisord should run
|
||||
ENV ENABLE_UWSGI='yes'
|
||||
ENV ENABLE_NGINX='yes'
|
||||
ENV ENABLE_CELERY_BEAT='yes'
|
||||
ENV ENABLE_CELERY_SHORT='yes'
|
||||
ENV ENABLE_CELERY_LONG='yes'
|
||||
ENV ENABLE_MIGRATIONS='yes'
|
||||
|
||||
# Set up virtualenv
|
||||
ENV VIRTUAL_ENV=/home/mediacms.io
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
COPY --chown=www-data:www-data --from=compile-image /home/mediacms.io /home/mediacms.io
|
||||
|
||||
RUN apt-get update -y && apt-get -y upgrade && apt-get install --no-install-recommends \
|
||||
supervisor nginx imagemagick procps wget xz-utils -y && \
|
||||
# Install system dependencies needed for downloading and extracting
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y --no-install-recommends wget xz-utils unzip && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
apt-get purge --auto-remove && \
|
||||
apt-get clean
|
||||
|
||||
# Install ffmpeg
|
||||
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz && \
|
||||
mkdir -p ffmpeg-tmp && \
|
||||
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C ffmpeg-tmp && \
|
||||
cp -v ffmpeg-tmp/ffmpeg ffmpeg-tmp/ffprobe ffmpeg-tmp/qt-faststart /usr/local/bin && \
|
||||
rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz
|
||||
|
||||
# Install Bento4 in the specified location
|
||||
RUN mkdir -p /home/mediacms.io/bento4 && \
|
||||
wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
|
||||
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d /home/mediacms.io/bento4 && \
|
||||
mv /home/mediacms.io/bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/* /home/mediacms.io/bento4/ && \
|
||||
rm -rf /home/mediacms.io/bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux && \
|
||||
rm -rf /home/mediacms.io/bento4/docs && \
|
||||
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
|
||||
############ RUNTIME IMAGE ############
|
||||
FROM python:3.13.5-bookworm AS runtime_image
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV CELERY_APP='cms'
|
||||
ENV VIRTUAL_ENV=/home/mediacms.io
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
# Install runtime system dependencies
|
||||
RUN apt-get update -y && \
|
||||
apt-get -y upgrade && \
|
||||
apt-get install --no-install-recommends supervisor nginx imagemagick procps pkg-config libxml2-dev libxmlsec1-dev libxmlsec1-openssl -y && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
apt-get purge --auto-remove && \
|
||||
apt-get clean
|
||||
|
||||
# Copy ffmpeg and Bento4 from build image
|
||||
COPY --from=build-image /usr/local/bin/ffmpeg /usr/local/bin/ffmpeg
|
||||
COPY --from=build-image /usr/local/bin/ffprobe /usr/local/bin/ffprobe
|
||||
COPY --from=build-image /usr/local/bin/qt-faststart /usr/local/bin/qt-faststart
|
||||
COPY --from=build-image /home/mediacms.io/bento4 /home/mediacms.io/bento4
|
||||
|
||||
# Set up virtualenv
|
||||
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && \
|
||||
cd /home/mediacms.io && \
|
||||
python3 -m venv $VIRTUAL_ENV
|
||||
|
||||
# Install Python dependencies
|
||||
COPY requirements.txt requirements-dev.txt ./
|
||||
|
||||
ARG DEVELOPMENT_MODE=False
|
||||
|
||||
RUN pip install --no-cache-dir --no-binary lxml,xmlsec -r requirements.txt && \
|
||||
if [ "$DEVELOPMENT_MODE" = "True" ]; then \
|
||||
echo "Installing development dependencies..." && \
|
||||
pip install --no-cache-dir -r requirements-dev.txt; \
|
||||
fi
|
||||
|
||||
# Copy application files
|
||||
COPY . /home/mediacms.io/mediacms
|
||||
WORKDIR /home/mediacms.io/mediacms
|
||||
|
||||
# required for sprite thumbnail generation for large video files
|
||||
|
||||
COPY deploy/docker/policy.xml /etc/ImageMagick-6/policy.xml
|
||||
|
||||
# Set process control environment variables
|
||||
ENV ENABLE_UWSGI='yes' \
|
||||
ENABLE_NGINX='yes' \
|
||||
ENABLE_CELERY_BEAT='yes' \
|
||||
ENABLE_CELERY_SHORT='yes' \
|
||||
ENABLE_CELERY_LONG='yes' \
|
||||
ENABLE_MIGRATIONS='yes'
|
||||
|
||||
EXPOSE 9000 80
|
||||
|
||||
RUN chmod +x ./deploy/docker/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
|
||||
|
||||
CMD ["./deploy/docker/start.sh"]
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
FROM python:3.11.4-bookworm AS compile-image
|
||||
|
||||
SHELL ["/bin/bash", "-c"]
|
||||
|
||||
# Set up virtualenv
|
||||
ENV VIRTUAL_ENV=/home/mediacms.io
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
ENV PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN mkdir -p /home/mediacms.io/mediacms/{logs} && cd /home/mediacms.io && python3 -m venv $VIRTUAL_ENV
|
||||
|
||||
# Install dependencies:
|
||||
COPY requirements.txt .
|
||||
COPY requirements-dev.txt .
|
||||
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
RUN pip install -r requirements-dev.txt
|
||||
|
||||
COPY . /home/mediacms.io/mediacms
|
||||
WORKDIR /home/mediacms.io/mediacms
|
||||
|
||||
RUN wget -q http://zebulon.bok.net/Bento4/binaries/Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip && \
|
||||
unzip Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip -d ../bento4 && \
|
||||
mv ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/* ../bento4/ && \
|
||||
rm -rf ../bento4/Bento4-SDK-1-6-0-637.x86_64-unknown-linux && \
|
||||
rm -rf ../bento4/docs && \
|
||||
rm Bento4-SDK-1-6-0-637.x86_64-unknown-linux.zip
|
||||
|
||||
############ RUNTIME IMAGE ############
|
||||
FROM python:3.11.4-bookworm as runtime-image
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
|
||||
# See: https://github.com/celery/celery/issues/6285#issuecomment-715316219
|
||||
ENV CELERY_APP='cms'
|
||||
|
||||
# Use these to toggle which processes supervisord should run
|
||||
ENV ENABLE_UWSGI='yes'
|
||||
ENV ENABLE_NGINX='yes'
|
||||
ENV ENABLE_CELERY_BEAT='yes'
|
||||
ENV ENABLE_CELERY_SHORT='yes'
|
||||
ENV ENABLE_CELERY_LONG='yes'
|
||||
ENV ENABLE_MIGRATIONS='yes'
|
||||
|
||||
# Set up virtualenv
|
||||
ENV VIRTUAL_ENV=/home/mediacms.io
|
||||
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
|
||||
|
||||
COPY --chown=www-data:www-data --from=compile-image /home/mediacms.io /home/mediacms.io
|
||||
|
||||
RUN apt-get update -y && apt-get -y upgrade && apt-get install --no-install-recommends \
|
||||
supervisor nginx imagemagick procps wget xz-utils -y && \
|
||||
rm -rf /var/lib/apt/lists/* && \
|
||||
apt-get purge --auto-remove && \
|
||||
apt-get clean
|
||||
|
||||
RUN wget -q https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz && \
|
||||
mkdir -p ffmpeg-tmp && \
|
||||
tar -xf ffmpeg-release-amd64-static.tar.xz --strip-components 1 -C ffmpeg-tmp && \
|
||||
cp -v ffmpeg-tmp/ffmpeg ffmpeg-tmp/ffprobe ffmpeg-tmp/qt-faststart /usr/local/bin && \
|
||||
rm -rf ffmpeg-tmp ffmpeg-release-amd64-static.tar.xz
|
||||
|
||||
WORKDIR /home/mediacms.io/mediacms
|
||||
|
||||
EXPOSE 9000 80
|
||||
|
||||
RUN chmod +x ./deploy/docker/entrypoint.sh
|
||||
|
||||
ENTRYPOINT ["./deploy/docker/entrypoint.sh"]
|
||||
|
||||
CMD ["./deploy/docker/start.sh"]
|
||||
8
Makefile
@@ -10,6 +10,10 @@ admin-shell:
|
||||
fi
|
||||
|
||||
build-frontend:
|
||||
docker-compose -f docker-compose-dev.yaml exec frontend npm run dist
|
||||
docker compose -f docker-compose-dev.yaml exec frontend npm run dist
|
||||
cp -r frontend/dist/static/* static/
|
||||
docker-compose -f docker-compose-dev.yaml restart web
|
||||
docker compose -f docker-compose-dev.yaml restart web
|
||||
|
||||
test:
|
||||
docker compose -f docker-compose-dev.yaml exec --env TESTING=True -T web pytest
|
||||
|
||||
|
||||
11
README.md
@@ -23,11 +23,14 @@ A demo is available at https://demo.mediacms.io
|
||||
|
||||
## Features
|
||||
- **Complete control over your data**: host it yourself!
|
||||
- **Support for multiple publishing workflows**: public, private, unlisted and custom
|
||||
- **Modern technologies**: Django/Python/Celery, React.
|
||||
- **Support for multiple publishing workflows**: public, private, unlisted and custom
|
||||
- **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
|
||||
- **Playlists for audio and video content**: create playlists, add and reorder content
|
||||
- **Responsive design**: including light and dark themes
|
||||
@@ -43,7 +46,6 @@ A demo is available at https://demo.mediacms.io
|
||||
- **REST API**: Documented through Swagger
|
||||
- **Translation**: Most of the CMS is translated to a number of languages
|
||||
|
||||
|
||||
## Example cases
|
||||
|
||||
- **Schools, education.** Administrators and editors keep what content will be published, students are not distracted with advertisements and irrelevant content, plus they have the ability to select either to stream or download content.
|
||||
@@ -68,7 +70,12 @@ Copyright Markos Gogoulos.
|
||||
|
||||
We provide custom installations, development of extra functionality, migration from existing systems, integrations with legacy systems, training and support. Contact us at info@mediacms.io for more information.
|
||||
|
||||
### Commercial Hostings
|
||||
**Elestio**
|
||||
|
||||
You can deploy MediaCMS on Elestio using one-click deployment. Elestio supports MediaCMS by providing revenue share so go ahead and click below to deploy and use MediaCMS.
|
||||
|
||||
[](https://elest.io/open-source/mediacms)
|
||||
|
||||
## Hardware considerations
|
||||
|
||||
|
||||
0
admin_customizations/__init__.py
Normal file
0
admin_customizations/admin.py
Normal file
86
admin_customizations/apps.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
|
||||
|
||||
class AdminCustomizationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'admin_customizations'
|
||||
|
||||
def ready(self):
|
||||
original_get_app_list = admin.AdminSite.get_app_list
|
||||
|
||||
def get_app_list(self, request, app_label=None):
|
||||
"""Custom get_app_list"""
|
||||
app_list = original_get_app_list(self, request, app_label)
|
||||
# To see the list:
|
||||
# print([a.get('app_label') for a in app_list])
|
||||
|
||||
email_model = None
|
||||
rbac_group_model = None
|
||||
identity_providers_user_log_model = None
|
||||
identity_providers_login_option = None
|
||||
auth_app = None
|
||||
rbac_app = None
|
||||
socialaccount_app = None
|
||||
|
||||
for app in app_list:
|
||||
if app['app_label'] == 'users':
|
||||
auth_app = app
|
||||
|
||||
elif app['app_label'] == 'account':
|
||||
for model in app['models']:
|
||||
if model['object_name'] == 'EmailAddress':
|
||||
email_model = model
|
||||
elif app['app_label'] == 'rbac':
|
||||
if not getattr(settings, 'USE_RBAC', False):
|
||||
continue
|
||||
rbac_app = app
|
||||
for model in app['models']:
|
||||
if model['object_name'] == 'RBACGroup':
|
||||
rbac_group_model = model
|
||||
elif app['app_label'] == 'identity_providers':
|
||||
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
continue
|
||||
|
||||
models_to_check = list(app['models'])
|
||||
|
||||
for model in models_to_check:
|
||||
if model['object_name'] == 'IdentityProviderUserLog':
|
||||
identity_providers_user_log_model = model
|
||||
if model['object_name'] == 'LoginOption':
|
||||
identity_providers_login_option = model
|
||||
elif app['app_label'] == 'socialaccount':
|
||||
socialaccount_app = app
|
||||
|
||||
if email_model and auth_app:
|
||||
auth_app['models'].append(email_model)
|
||||
if rbac_group_model and rbac_app and auth_app:
|
||||
auth_app['models'].append(rbac_group_model)
|
||||
if identity_providers_login_option and socialaccount_app:
|
||||
socialaccount_app['models'].append(identity_providers_login_option)
|
||||
if identity_providers_user_log_model and socialaccount_app:
|
||||
socialaccount_app['models'].append(identity_providers_user_log_model)
|
||||
|
||||
# 2. don't include the following apps
|
||||
apps_to_hide = ['authtoken', 'auth', 'account', 'saml_auth', 'rbac']
|
||||
if not getattr(settings, 'USE_RBAC', False):
|
||||
apps_to_hide.append('rbac')
|
||||
if not getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
apps_to_hide.append('socialaccount')
|
||||
|
||||
app_list = [app for app in app_list if app['app_label'] not in apps_to_hide]
|
||||
|
||||
# 3. change the ordering
|
||||
app_order = {
|
||||
'files': 1,
|
||||
'users': 2,
|
||||
'socialaccount': 3,
|
||||
'rbac': 5,
|
||||
}
|
||||
|
||||
app_list.sort(key=lambda x: app_order.get(x['app_label'], 999))
|
||||
|
||||
return app_list
|
||||
|
||||
admin.AdminSite.get_app_list = get_app_list
|
||||
0
admin_customizations/migrations/__init__.py
Normal file
0
admin_customizations/models.py
Normal file
0
admin_customizations/tests.py
Normal file
0
admin_customizations/views.py
Normal file
@@ -4,30 +4,36 @@ import os
|
||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'allauth',
|
||||
'allauth.account',
|
||||
'allauth.socialaccount',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'django.contrib.sites',
|
||||
'rest_framework',
|
||||
'rest_framework.authtoken',
|
||||
'imagekit',
|
||||
'files.apps.FilesConfig',
|
||||
'users.apps.UsersConfig',
|
||||
'actions.apps.ActionsConfig',
|
||||
'debug_toolbar',
|
||||
'mptt',
|
||||
'crispy_forms',
|
||||
'uploader.apps.UploaderConfig',
|
||||
'djcelery_email',
|
||||
'ckeditor',
|
||||
'drf_yasg',
|
||||
'corsheaders',
|
||||
"admin_customizations",
|
||||
"django.contrib.auth",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
"django.contrib.contenttypes",
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"jazzmin",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.sites",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
"imagekit",
|
||||
"files.apps.FilesConfig",
|
||||
"users.apps.UsersConfig",
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
"crispy_bootstrap5",
|
||||
"uploader.apps.UploaderConfig",
|
||||
"djcelery_email",
|
||||
"drf_yasg",
|
||||
"allauth.socialaccount.providers.saml",
|
||||
"saml_auth.apps.SamlAuthConfig",
|
||||
"corsheaders",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -41,9 +47,10 @@ MIDDLEWARE = [
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
'debug_toolbar.middleware.DebugToolbarMiddleware',
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
]
|
||||
|
||||
DEBUG = True
|
||||
CORS_ORIGIN_ALLOW_ALL = True
|
||||
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static/'),)
|
||||
STATIC_ROOT = None
|
||||
STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)
|
||||
STATIC_ROOT = os.path.join(BASE_DIR, 'static_collected')
|
||||
|
||||
169
cms/settings.py
@@ -9,7 +9,6 @@ DEBUG = False
|
||||
# is also shown on several places as emails
|
||||
PORTAL_NAME = "MediaCMS"
|
||||
PORTAL_DESCRIPTION = ""
|
||||
LANGUAGE_CODE = "en-us"
|
||||
TIME_ZONE = "Europe/London"
|
||||
|
||||
# who can add media
|
||||
@@ -112,11 +111,11 @@ TIME_TO_ACTION_ANONYMOUS = 10 * 60
|
||||
|
||||
# django-allauth settings
|
||||
ACCOUNT_SESSION_REMEMBER = True
|
||||
ACCOUNT_AUTHENTICATION_METHOD = "username_email"
|
||||
ACCOUNT_LOGIN_METHODS = {"username", "email"}
|
||||
ACCOUNT_EMAIL_REQUIRED = True # new users need to specify email
|
||||
ACCOUNT_EMAIL_VERIFICATION = "optional" # 'mandatory' 'none'
|
||||
ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION = True
|
||||
ACCOUNT_USERNAME_MIN_LENGTH = "4"
|
||||
ACCOUNT_USERNAME_MIN_LENGTH = 4
|
||||
ACCOUNT_ADAPTER = "users.adapter.MyAccountAdapter"
|
||||
ACCOUNT_SIGNUP_FORM_CLASS = "users.forms.SignupForm"
|
||||
ACCOUNT_USERNAME_VALIDATORS = "users.validators.custom_username_validators"
|
||||
@@ -124,13 +123,15 @@ ACCOUNT_SIGNUP_PASSWORD_ENTER_TWICE = False
|
||||
ACCOUNT_USERNAME_REQUIRED = True
|
||||
ACCOUNT_LOGIN_ON_PASSWORD_RESET = True
|
||||
ACCOUNT_EMAIL_CONFIRMATION_EXPIRE_DAYS = 1
|
||||
ACCOUNT_LOGIN_ATTEMPTS_LIMIT = 20
|
||||
ACCOUNT_LOGIN_ATTEMPTS_TIMEOUT = 5
|
||||
# registration won't be open, might also consider to remove links for register
|
||||
USERS_CAN_SELF_REGISTER = True
|
||||
|
||||
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = ["xxx.com", "emaildomainwhatever.com"]
|
||||
|
||||
# Comma separated list of domains: ["organization.com", "private.organization.com", "org2.com"]
|
||||
# Empty list disables.
|
||||
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = []
|
||||
|
||||
# django rest settings
|
||||
REST_FRAMEWORK = {
|
||||
"DEFAULT_AUTHENTICATION_CLASSES": (
|
||||
@@ -227,11 +228,11 @@ POST_UPLOAD_AUTHOR_MESSAGE_UNLISTED_NO_COMMENTARY = ""
|
||||
|
||||
CANNOT_ADD_MEDIA_MESSAGE = ""
|
||||
|
||||
# mp4hls command, part of Bendo4
|
||||
# mp4hls command, part of Bento4
|
||||
MP4HLS_COMMAND = "/home/mediacms.io/mediacms/Bento4-SDK-1-6-0-637.x86_64-unknown-linux/bin/mp4hls"
|
||||
|
||||
# highly experimental, related with remote workers
|
||||
ADMIN_TOKEN = "c2b8e1838b6128asd333ddc5e24"
|
||||
ADMIN_TOKEN = ""
|
||||
# this is used by remote workers to push
|
||||
# encodings once they are done
|
||||
# USE_BASIC_HTTP = True
|
||||
@@ -246,35 +247,6 @@ ADMIN_TOKEN = "c2b8e1838b6128asd333ddc5e24"
|
||||
# uncomment the two lines related to htpasswd
|
||||
|
||||
|
||||
CKEDITOR_CONFIGS = {
|
||||
"default": {
|
||||
"toolbar": "Custom",
|
||||
"width": "100%",
|
||||
"toolbar_Custom": [
|
||||
["Styles"],
|
||||
["Format"],
|
||||
["Bold", "Italic", "Underline"],
|
||||
["HorizontalRule"],
|
||||
[
|
||||
"NumberedList",
|
||||
"BulletedList",
|
||||
"-",
|
||||
"Outdent",
|
||||
"Indent",
|
||||
"-",
|
||||
"JustifyLeft",
|
||||
"JustifyCenter",
|
||||
"JustifyRight",
|
||||
"JustifyBlock",
|
||||
],
|
||||
["Link", "Unlink"],
|
||||
["Image"],
|
||||
["RemoveFormat", "Source"],
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
AUTH_USER_MODEL = "users.User"
|
||||
LOGIN_REDIRECT_URL = "/"
|
||||
|
||||
@@ -284,7 +256,7 @@ AUTHENTICATION_BACKENDS = (
|
||||
)
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"django.contrib.admin",
|
||||
"admin_customizations",
|
||||
"django.contrib.auth",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
@@ -293,6 +265,8 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"jazzmin",
|
||||
"django.contrib.admin",
|
||||
"django.contrib.sites",
|
||||
"rest_framework",
|
||||
"rest_framework.authtoken",
|
||||
@@ -300,13 +274,17 @@ INSTALLED_APPS = [
|
||||
"files.apps.FilesConfig",
|
||||
"users.apps.UsersConfig",
|
||||
"actions.apps.ActionsConfig",
|
||||
"rbac.apps.RbacConfig",
|
||||
"identity_providers.apps.IdentityProvidersConfig",
|
||||
"debug_toolbar",
|
||||
"mptt",
|
||||
"crispy_forms",
|
||||
"crispy_bootstrap5",
|
||||
"uploader.apps.UploaderConfig",
|
||||
"djcelery_email",
|
||||
"ckeditor",
|
||||
"drf_yasg",
|
||||
"allauth.socialaccount.providers.saml",
|
||||
"saml_auth.apps.SamlAuthConfig",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
@@ -319,6 +297,7 @@ MIDDLEWARE = [
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
"debug_toolbar.middleware.DebugToolbarMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
]
|
||||
|
||||
ROOT_URLCONF = "cms.urls"
|
||||
@@ -346,11 +325,15 @@ WSGI_APPLICATION = "cms.wsgi.application"
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||
"OPTIONS": {
|
||||
"user_attributes": ("username", "email", "first_name", "last_name"),
|
||||
"max_similarity": 0.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||
"OPTIONS": {
|
||||
"min_length": 5,
|
||||
"min_length": 7,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -462,7 +445,66 @@ CELERY_TASK_ALWAYS_EAGER = False
|
||||
if os.environ.get("TESTING"):
|
||||
CELERY_TASK_ALWAYS_EAGER = True
|
||||
|
||||
# if True, only show original, don't perform any action on videos
|
||||
DO_NOT_TRANSCODE_VIDEO = False
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
|
||||
LANGUAGES = [
|
||||
('ar', _('Arabic')),
|
||||
('bn', _('Bengali')),
|
||||
('da', _('Danish')),
|
||||
('nl', _('Dutch')),
|
||||
('en', _('English')),
|
||||
('fr', _('French')),
|
||||
('de', _('German')),
|
||||
('hi', _('Hindi')),
|
||||
('id', _('Indonesian')),
|
||||
('it', _('Italian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
('zh-hans', _('Simplified Chinese')),
|
||||
('zh-hant', _('Traditional Chinese')),
|
||||
('es', _('Spanish')),
|
||||
('tr', _('Turkish')),
|
||||
('el', _('Greek')),
|
||||
('ur', _('Urdu')),
|
||||
('he', _('Hebrew')),
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en' # default language
|
||||
|
||||
SPRITE_NUM_SECS = 10
|
||||
# number of seconds for sprite image.
|
||||
# If you plan to change this, you must also follow the instructions on admin_docs.md
|
||||
# to change the equivalent value in ./frontend/src/static/js/components/media-viewer/VideoViewer/index.js and then re-build frontend
|
||||
|
||||
# how many images will be shown on the slideshow
|
||||
SLIDESHOW_ITEMS = 30
|
||||
# this calculation is redundant most probably, setting as an option
|
||||
CALCULATE_MD5SUM = False
|
||||
|
||||
CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5"
|
||||
CRISPY_TEMPLATE_PACK = "bootstrap5"
|
||||
|
||||
# allow option to override the default admin url
|
||||
# keep the trailing slash
|
||||
DJANGO_ADMIN_URL = "admin/"
|
||||
|
||||
# this are used around a number of places and will need to be well documented!!!
|
||||
|
||||
USE_SAML = False
|
||||
USE_RBAC = False
|
||||
USE_IDENTITY_PROVIDERS = False
|
||||
JAZZMIN_UI_TWEAKS = {"theme": "flatly"}
|
||||
|
||||
USE_ROUNDED_CORNERS = True
|
||||
|
||||
ALLOW_VIDEO_TRIMMER = True
|
||||
|
||||
ALLOW_CUSTOM_MEDIA_URLS = False
|
||||
try:
|
||||
# keep a local_settings.py file for local overrides
|
||||
from .local_settings import * # noqa
|
||||
@@ -473,6 +515,7 @@ 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
|
||||
@@ -483,22 +526,11 @@ if LOCAL_INSTALL:
|
||||
else:
|
||||
SSL_FRONTEND_HOST = FRONTEND_HOST
|
||||
|
||||
if GLOBAL_LOGIN_REQUIRED:
|
||||
# this should go after the AuthenticationMiddleware middleware
|
||||
MIDDLEWARE.insert(5, "login_required.middleware.LoginRequiredMiddleware")
|
||||
LOGIN_REQUIRED_IGNORE_PATHS = [
|
||||
r'/accounts/login/$',
|
||||
r'/accounts/logout/$',
|
||||
r'/accounts/signup/$',
|
||||
r'/accounts/password/.*/$',
|
||||
r'/accounts/confirm-email/.*/$',
|
||||
r'/api/v[0-9]+/',
|
||||
]
|
||||
|
||||
# if True, only show original, don't perform any action on videos
|
||||
DO_NOT_TRANSCODE_VIDEO = False
|
||||
# CSRF_COOKIE_SECURE = True
|
||||
# SESSION_COOKIE_SECURE = True
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
|
||||
PYSUBS_COMMAND = "pysubs2"
|
||||
|
||||
# the following is related to local development using docker
|
||||
# and docker-compose-dev.yaml
|
||||
@@ -510,24 +542,15 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
LANGUAGES = [
|
||||
('ar', _('Arabic')),
|
||||
('bn', _('Bengali')),
|
||||
('nl', _('Dutch')),
|
||||
('en', _('English')),
|
||||
('fr', _('French')),
|
||||
('de', _('German')),
|
||||
('hi', _('Hindi')),
|
||||
('id', _('Indonesian')),
|
||||
('ja', _('Japanese')),
|
||||
('ko', _('Korean')),
|
||||
('pt', _('Portuguese')),
|
||||
('ru', _('Russian')),
|
||||
('zh-hans', _('Simplified Chinese')),
|
||||
('es', _('Spanish')),
|
||||
('tr', _('Turkish')),
|
||||
('el', _('Greek')),
|
||||
('ur', _('Urdu')),
|
||||
]
|
||||
|
||||
LANGUAGE_CODE = 'en' # default language
|
||||
if GLOBAL_LOGIN_REQUIRED:
|
||||
# this should go after the AuthenticationMiddleware middleware
|
||||
MIDDLEWARE.insert(6, "login_required.middleware.LoginRequiredMiddleware")
|
||||
LOGIN_REQUIRED_IGNORE_PATHS = [
|
||||
r'/accounts/login/$',
|
||||
r'/accounts/logout/$',
|
||||
r'/accounts/signup/$',
|
||||
r'/accounts/password/.*/$',
|
||||
r'/accounts/confirm-email/.*/$',
|
||||
# r'/api/v[0-9]+/',
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import debug_toolbar
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include
|
||||
from django.contrib import admin
|
||||
from django.urls import path, re_path
|
||||
@@ -25,8 +26,12 @@ urlpatterns = [
|
||||
re_path(r"^", include("users.urls")),
|
||||
re_path(r"^accounts/", include("allauth.urls")),
|
||||
re_path(r"^api-auth/", include("rest_framework.urls")),
|
||||
path("admin/", admin.site.urls),
|
||||
path(settings.DJANGO_ADMIN_URL, admin.site.urls),
|
||||
re_path(r'^swagger(?P<format>\.json|\.yaml)$', schema_view.without_ui(cache_timeout=0), name='schema-json'),
|
||||
re_path(r'^swagger/$', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
|
||||
path('docs/api/', schema_view.with_ui('redoc', cache_timeout=0), name='schema-redoc'),
|
||||
]
|
||||
|
||||
admin.site.site_header = "MediaCMS Admin"
|
||||
admin.site.site_title = "MediaCMS"
|
||||
admin.site.index_title = "Admin"
|
||||
|
||||
1
cms/version.py
Normal file
@@ -0,0 +1 @@
|
||||
VERSION = "6.2.0"
|
||||
@@ -1,5 +0,0 @@
|
||||
from pytest_factoryboy import register
|
||||
|
||||
from tests.users.factories import UserFactory
|
||||
|
||||
register(UserFactory)
|
||||
75
deic_setup_notes.md
Normal file
@@ -0,0 +1,75 @@
|
||||
# MediaCMS: Document Changes for DEIC
|
||||
|
||||
## Configuration Changes
|
||||
The following changes are required in `deploy/docker/local_settings.py`:
|
||||
|
||||
```python
|
||||
|
||||
# default workflow
|
||||
PORTAL_WORKFLOW = 'private'
|
||||
|
||||
# Authentication Settings
|
||||
# these two are necessary so that users cannot register through system accounts. They can only register through identity providers
|
||||
REGISTER_ALLOWED = False
|
||||
USERS_CAN_SELF_REGISTER = False
|
||||
|
||||
USE_RBAC = True
|
||||
USE_SAML = True
|
||||
USE_IDENTITY_PROVIDERS = True
|
||||
|
||||
# Proxy and SSL Settings
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
SECURE_SSL_REDIRECT = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
# SAML Configuration
|
||||
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
|
||||
ACCOUNT_USERNAME_VALIDATORS = "users.validators.less_restrictive_username_validators"
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"saml": {
|
||||
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
|
||||
}
|
||||
}
|
||||
SOCIALACCOUNT_AUTO_SIGNUP = True
|
||||
SOCIALACCOUNT_EMAIL_REQUIRED = False
|
||||
|
||||
# if set to strict, user is created with the email from the saml provider without
|
||||
# checking if the email is already on the system
|
||||
# however if this is ommited, and user tries to login with an email that already exists on
|
||||
# the system, then they get to the ugly form where it suggests they add a username/email/name
|
||||
|
||||
ACCOUNT_PREVENT_ENUMERATION = 'strict'
|
||||
|
||||
```
|
||||
|
||||
## SAML Configuration Steps
|
||||
|
||||
### Step 1: Add SAML Identity Provider
|
||||
1. Navigate to Admin panel
|
||||
2. Select "Identity Provider"
|
||||
3. Configure as follows:
|
||||
- **Provider**: saml # ensure this is set with lower case!
|
||||
- **Provider ID**: `wayf.wayf.dk`
|
||||
- **IDP Config Name**: `Deic` (or preferred name)
|
||||
- **Client ID**: `wayf_dk` (important: defines the URL, e.g., `https://deic.mediacms.io/accounts/saml/wayf_dk`)
|
||||
- **Site**: Set the default one
|
||||
|
||||
### Step 2: Add SAML Configuration
|
||||
Can be set through the SAML Configurations tab:
|
||||
|
||||
1. **IDP ID**: Must be a URL, e.g., `https://wayf.wayf.dk`
|
||||
2. **IDP Certificate**: x509cert from your SAML provider
|
||||
3. **SSO URL**: `https://wayf.wayf.dk/saml2/idp/SSOService2.php`
|
||||
4. **SLO URL**: `https://wayf.wayf.dk/saml2/idp/SingleLogoutService.php`
|
||||
5. **SP Metadata URL**: The metadata URL set for the SP, e.g., `https://deic.mediacms.io/saml/metadata`. This should point to the URL of the SP and is autogenerated
|
||||
|
||||
### Step 3: Set the other Options
|
||||
1. **Email Settings**:
|
||||
- `verified_email`: When enabled, emails from SAML responses will be marked as verified
|
||||
- `Remove from groups`: When enabled, user is removed from a group after login, if they have been removed from the group on the IDP
|
||||
2. **Global Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in MediaCMS
|
||||
3. **Group Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in groups that user will be added
|
||||
4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups
|
||||
5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS
|
||||
@@ -7,6 +7,7 @@ ln -sf /dev/stdout /var/log/nginx/mediacms.io.access.log && ln -sf /dev/stderr /
|
||||
|
||||
cp /home/mediacms.io/mediacms/deploy/docker/local_settings.py /home/mediacms.io/mediacms/cms/local_settings.py
|
||||
|
||||
|
||||
mkdir -p /home/mediacms.io/mediacms/{logs,media_files/hls}
|
||||
touch /home/mediacms.io/mediacms/logs/debug.log
|
||||
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
FRONTEND_HOST = 'http://localhost'
|
||||
PORTAL_NAME = 'MediaCMS'
|
||||
SECRET_KEY = 'ma!s3^b-cw!f#7s6s0m3*jx77a@riw(7701**(r=ww%w!2+yk2'
|
||||
POSTGRES_HOST = 'db'
|
||||
REDIS_LOCATION = "redis://redis:6379/1"
|
||||
import os
|
||||
|
||||
FRONTEND_HOST = os.getenv('FRONTEND_HOST', 'http://localhost')
|
||||
PORTAL_NAME = os.getenv('PORTAL_NAME', 'MediaCMS')
|
||||
SECRET_KEY = os.getenv('SECRET_KEY', 'ma!s3^b-cw!f#7s6s0m3*jx77a@riw(7701**(r=ww%w!2+yk2')
|
||||
REDIS_LOCATION = os.getenv('REDIS_LOCATION', 'redis://redis:6379/1')
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": "mediacms",
|
||||
"HOST": POSTGRES_HOST,
|
||||
"PORT": "5432",
|
||||
"USER": "mediacms",
|
||||
"PASSWORD": "mediacms",
|
||||
"NAME": os.getenv('POSTGRES_NAME', 'mediacms'),
|
||||
"HOST": os.getenv('POSTGRES_HOST', 'db'),
|
||||
"PORT": os.getenv('POSTGRES_PORT', '5432'),
|
||||
"USER": os.getenv('POSTGRES_USER', 'mediacms'),
|
||||
"PASSWORD": os.getenv('POSTGRES_PASSWORD', 'mediacms'),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,4 +32,4 @@ CELERY_RESULT_BACKEND = BROKER_URL
|
||||
|
||||
MP4HLS_COMMAND = "/home/mediacms.io/bento4/bin/mp4hls"
|
||||
|
||||
DEBUG = False
|
||||
DEBUG = os.getenv('DEBUG', 'False') == 'True'
|
||||
|
||||
99
deploy/docker/policy.xml
Normal file
@@ -0,0 +1,99 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE policymap [
|
||||
<!ELEMENT policymap (policy)*>
|
||||
<!ATTLIST policymap xmlns CDATA #FIXED ''>
|
||||
<!ELEMENT policy EMPTY>
|
||||
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
|
||||
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
|
||||
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
|
||||
]>
|
||||
<!--
|
||||
Configure ImageMagick policies.
|
||||
|
||||
Domains include system, delegate, coder, filter, path, or resource.
|
||||
|
||||
Rights include none, read, write, execute and all. Use | to combine them,
|
||||
for example: "read | write" to permit read from, or write to, a path.
|
||||
|
||||
Use a glob expression as a pattern.
|
||||
|
||||
Suppose we do not want users to process MPEG video images:
|
||||
|
||||
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
|
||||
|
||||
Here we do not want users reading images from HTTP:
|
||||
|
||||
<policy domain="coder" rights="none" pattern="HTTP" />
|
||||
|
||||
The /repository file system is restricted to read only. We use a glob
|
||||
expression to match all paths that start with /repository:
|
||||
|
||||
<policy domain="path" rights="read" pattern="/repository/*" />
|
||||
|
||||
Lets prevent users from executing any image filters:
|
||||
|
||||
<policy domain="filter" rights="none" pattern="*" />
|
||||
|
||||
Any large image is cached to disk rather than memory:
|
||||
|
||||
<policy domain="resource" name="area" value="1GP"/>
|
||||
|
||||
Use the default system font unless overwridden by the application:
|
||||
|
||||
<policy domain="system" name="font" value="/usr/share/fonts/favorite.ttf"/>
|
||||
|
||||
Define arguments for the memory, map, area, width, height and disk resources
|
||||
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
|
||||
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
|
||||
exceeds policy maximum so memory limit is 1GB).
|
||||
|
||||
Rules are processed in order. Here we want to restrict ImageMagick to only
|
||||
read or write a small subset of proven web-safe image types:
|
||||
|
||||
<policy domain="delegate" rights="none" pattern="*" />
|
||||
<policy domain="filter" rights="none" pattern="*" />
|
||||
<policy domain="coder" rights="none" pattern="*" />
|
||||
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
|
||||
-->
|
||||
<policymap>
|
||||
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
|
||||
<policy domain="resource" name="memory" value="1GiB"/>
|
||||
<policy domain="resource" name="map" value="30GiB"/>
|
||||
<policy domain="resource" name="width" value="16MP"/>
|
||||
<policy domain="resource" name="height" value="16MP"/>
|
||||
<!-- <policy domain="resource" name="list-length" value="128"/> -->
|
||||
<policy domain="resource" name="area" value="40GP"/>
|
||||
<policy domain="resource" name="disk" value="100GiB"/>
|
||||
<!-- <policy domain="resource" name="file" value="768"/> -->
|
||||
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
||||
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
||||
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
||||
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
|
||||
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
||||
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
||||
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
|
||||
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
|
||||
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/>
|
||||
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
|
||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||
<!-- <policy domain="system" name="font" value="/path/to/font.ttf"/> -->
|
||||
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||
<!-- not needed due to the need to use explicitly by mvg: -->
|
||||
<!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
|
||||
<!-- use curl -->
|
||||
<policy domain="delegate" rights="none" pattern="URL" />
|
||||
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
||||
<policy domain="delegate" rights="none" pattern="HTTP" />
|
||||
<!-- in order to avoid to get image with password text -->
|
||||
<policy domain="path" rights="none" pattern="@*"/>
|
||||
<!-- disable ghostscript format types -->
|
||||
<policy domain="coder" rights="none" pattern="PS" />
|
||||
<policy domain="coder" rights="none" pattern="PS2" />
|
||||
<policy domain="coder" rights="none" pattern="PS3" />
|
||||
<policy domain="coder" rights="none" pattern="EPS" />
|
||||
<policy domain="coder" rights="none" pattern="PDF" />
|
||||
<policy domain="coder" rights="none" pattern="XPS" />
|
||||
</policymap>
|
||||
@@ -49,7 +49,7 @@ server {
|
||||
ssl_dhparam /etc/nginx/dhparams/dhparams.pem;
|
||||
|
||||
ssl_protocols TLSv1.2 TLSv1.3; # Dropping SSLv3, ref: POODLE
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
|
||||
ssl_ecdh_curve secp521r1:secp384r1;
|
||||
ssl_prefer_server_ciphers on;
|
||||
|
||||
|
||||
27
deploy/scripts/build_and_deploy.sh
Normal file
@@ -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!"
|
||||
@@ -4,13 +4,23 @@ services:
|
||||
migrations:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile-dev
|
||||
dockerfile: ./Dockerfile
|
||||
args:
|
||||
- DEVELOPMENT_MODE=True
|
||||
image: mediacms/mediacms-dev:latest
|
||||
volumes:
|
||||
- ./:/home/mediacms.io/mediacms/
|
||||
command: "python manage.py migrate"
|
||||
command: "./deploy/docker/prestart.sh"
|
||||
environment:
|
||||
DEVELOPMENT_MODE: "True"
|
||||
DEVELOPMENT_MODE: True
|
||||
ENABLE_UWSGI: 'no'
|
||||
ENABLE_NGINX: 'no'
|
||||
ENABLE_CELERY_SHORT: 'no'
|
||||
ENABLE_CELERY_LONG: 'no'
|
||||
ENABLE_CELERY_BEAT: 'no'
|
||||
ADMIN_USER: 'admin'
|
||||
ADMIN_EMAIL: 'admin@localhost'
|
||||
ADMIN_PASSWORD: 'admin'
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
redis:
|
||||
@@ -18,7 +28,7 @@ services:
|
||||
db:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
image: node:14
|
||||
image: node:20
|
||||
volumes:
|
||||
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
|
||||
working_dir: /home/mediacms.io/mediacms/frontend/
|
||||
@@ -30,16 +40,10 @@ services:
|
||||
depends_on:
|
||||
- web
|
||||
web:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./Dockerfile-dev
|
||||
image: mediacms/mediacms-dev:latest
|
||||
command: "python manage.py runserver 0.0.0.0:80"
|
||||
environment:
|
||||
DEVELOPMENT_MODE: "True"
|
||||
ADMIN_USER: 'admin'
|
||||
ADMIN_PASSWORD: 'admin'
|
||||
ADMIN_EMAIL: 'admin@localhost'
|
||||
DEVELOPMENT_MODE: True
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
@@ -47,7 +51,7 @@ services:
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:15.2-alpine
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ../postgres_data:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
@@ -80,5 +84,6 @@ services:
|
||||
ENABLE_NGINX: 'no'
|
||||
ENABLE_CELERY_BEAT: 'no'
|
||||
ENABLE_MIGRATIONS: 'no'
|
||||
DEVELOPMENT_MODE: True
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
@@ -13,7 +13,7 @@ services:
|
||||
ENABLE_CELERY_BEAT: 'no'
|
||||
ADMIN_USER: 'admin'
|
||||
ADMIN_EMAIL: 'admin@localhost'
|
||||
#ADMIN_PASSWORD: 'uncomment_and_set_password_here'
|
||||
# ADMIN_PASSWORD: 'uncomment_and_set_password_here'
|
||||
command: "./deploy/docker/prestart.sh"
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
@@ -62,7 +62,7 @@ services:
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:15.2-alpine
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ../postgres_data:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
|
||||
145
docker-compose/docker-compose-dev-updated.yaml
Normal file
@@ -0,0 +1,145 @@
|
||||
name: mediacms-dev
|
||||
services:
|
||||
migrations:
|
||||
platform: linux/amd64
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- DEVELOPMENT_MODE=True
|
||||
image: mediacms/mediacms:latest
|
||||
volumes:
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
command: "/home/mediacms.io/mediacms/deploy/docker/prestart.sh"
|
||||
environment:
|
||||
DEVELOPMENT_MODE: True
|
||||
ENABLE_UWSGI: 'no'
|
||||
ENABLE_NGINX: 'no'
|
||||
ENABLE_CELERY_SHORT: 'no'
|
||||
ENABLE_CELERY_LONG: 'no'
|
||||
ENABLE_CELERY_BEAT: 'no'
|
||||
ADMIN_USER: 'admin'
|
||||
ADMIN_EMAIL: 'admin@localhost'
|
||||
ADMIN_PASSWORD: 'admin'
|
||||
restart: on-failure
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
db:
|
||||
condition: service_healthy
|
||||
frontend:
|
||||
image: node:20
|
||||
user: "root"
|
||||
volumes:
|
||||
- ${PWD}/frontend:/home/mediacms.io/mediacms/frontend/
|
||||
- frontend_node_modules:/home/mediacms.io/mediacms/frontend/node_modules
|
||||
- player_node_modules:/home/mediacms.io/mediacms/frontend/packages/player/node_modules
|
||||
- scripts_node_modules:/home/mediacms.io/mediacms/frontend/packages/scripts/node_modules
|
||||
- npm_global:/home/node/.npm-global
|
||||
working_dir: /home/mediacms.io/mediacms/frontend/
|
||||
command: >
|
||||
bash -c "
|
||||
echo 'Setting up npm global directory...' &&
|
||||
mkdir -p /home/node/.npm-global &&
|
||||
chown -R node:node /home/node/.npm-global &&
|
||||
echo 'Setting up permissions...' &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend &&
|
||||
echo 'Cleaning up node_modules...' &&
|
||||
find /home/mediacms.io/mediacms/frontend/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
||||
find /home/mediacms.io/mediacms/frontend/packages/player/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
||||
find /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules -mindepth 1 -delete 2>/dev/null || true &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend/node_modules &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/player/node_modules &&
|
||||
chown -R node:node /home/mediacms.io/mediacms/frontend/packages/scripts/node_modules &&
|
||||
echo 'Switching to node user...' &&
|
||||
su node -c '
|
||||
export NPM_CONFIG_PREFIX=/home/node/.npm-global &&
|
||||
echo \"Setting up frontend...\" &&
|
||||
rm -f package-lock.json &&
|
||||
rm -f packages/player/package-lock.json &&
|
||||
rm -f packages/scripts/package-lock.json &&
|
||||
echo \"Installing dependencies...\" &&
|
||||
npm install --legacy-peer-deps &&
|
||||
echo \"Setting up workspaces...\" &&
|
||||
npm install -g npm@latest &&
|
||||
cd packages/scripts &&
|
||||
npm install --legacy-peer-deps &&
|
||||
npm install rollup@2.79.1 --save-dev --legacy-peer-deps &&
|
||||
npm install typescript@4.9.5 --save-dev --legacy-peer-deps &&
|
||||
npm install tslib@2.6.2 --save --legacy-peer-deps &&
|
||||
npm install rollup-plugin-typescript2@0.34.1 --save-dev --legacy-peer-deps &&
|
||||
npm install --legacy-peer-deps &&
|
||||
npm run build &&
|
||||
cd ../.. &&
|
||||
cd packages/player &&
|
||||
npm install --legacy-peer-deps &&
|
||||
npm run build &&
|
||||
cd ../.. &&
|
||||
echo \"Starting development server...\" &&
|
||||
npm run start
|
||||
'"
|
||||
env_file:
|
||||
- ${PWD}/frontend/.env
|
||||
environment:
|
||||
- NPM_CONFIG_PREFIX=/home/node/.npm-global
|
||||
ports:
|
||||
- "8088:8088"
|
||||
depends_on:
|
||||
- web
|
||||
restart: unless-stopped
|
||||
web:
|
||||
platform: linux/amd64
|
||||
image: mediacms/mediacms:latest
|
||||
command: "python manage.py runserver 0.0.0.0:80"
|
||||
environment:
|
||||
DEVELOPMENT_MODE: True
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ../postgres_data:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: mediacms
|
||||
POSTGRES_PASSWORD: mediacms
|
||||
POSTGRES_DB: mediacms
|
||||
TZ: Europe/London
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}", "--host=db", "--dbname=$POSTGRES_DB", "--username=$POSTGRES_USER"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
redis:
|
||||
image: "redis:alpine"
|
||||
restart: always
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
celery_worker:
|
||||
platform: linux/amd64
|
||||
image: mediacms/mediacms:latest
|
||||
deploy:
|
||||
replicas: 1
|
||||
volumes:
|
||||
- ../:/home/mediacms.io/mediacms/
|
||||
environment:
|
||||
ENABLE_UWSGI: 'no'
|
||||
ENABLE_NGINX: 'no'
|
||||
ENABLE_CELERY_BEAT: 'no'
|
||||
ENABLE_MIGRATIONS: 'no'
|
||||
DEVELOPMENT_MODE: True
|
||||
depends_on:
|
||||
- web
|
||||
|
||||
volumes:
|
||||
frontend_node_modules:
|
||||
player_node_modules:
|
||||
scripts_node_modules:
|
||||
npm_global:
|
||||
@@ -68,7 +68,7 @@ services:
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:15.2-alpine
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ../postgres_data/:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
@@ -70,7 +70,7 @@ services:
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:15.2-alpine
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ../postgres_data/:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
@@ -90,7 +90,7 @@ services:
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:15.2-alpine
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- ../postgres_data:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
depends_on:
|
||||
- migrations
|
||||
db:
|
||||
image: postgres:15.2-alpine
|
||||
image: postgres:17.2-alpine
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data/
|
||||
restart: always
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## Table of contents
|
||||
- [1. Welcome](#1-welcome)
|
||||
- [2. Server Installaton](#2-server-installation)
|
||||
- [2. Single Server Installaton](#2-single-server-installation)
|
||||
- [3. Docker Installation](#3-docker-installation)
|
||||
- [4. Docker Deployment options](#4-docker-deployment-options)
|
||||
- [5. Configuration](#5-configuration)
|
||||
@@ -21,18 +21,24 @@
|
||||
- [18. Disable encoding and show only original file](#18-disable-encoding-and-show-only-original-file)
|
||||
- [19. Rounded corners on videos](#19-rounded-corners)
|
||||
- [20. Translations](#20-translations)
|
||||
- [21. How to change the video frames on videos](#21-how-to-change-the-video-frames-on-videos)
|
||||
- [22. Role-Based Access Control](#22-role-based-access-control)
|
||||
- [23. SAML setup](#23-saml-setup)
|
||||
- [24. Identity Providers setup](#24-identity-providers-setup)
|
||||
- [25. Custom urls](#25-custom-urls)
|
||||
|
||||
|
||||
## 1. Welcome
|
||||
This page is created for MediaCMS administrators that are responsible for setting up the software, maintaining it and making modifications.
|
||||
|
||||
## 2. Server Installation
|
||||
## 2. Single Server Installation
|
||||
|
||||
The core dependencies are Python3, Django3, Celery, PostgreSQL, Redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But we strongly suggest installing on Linux Ubuntu (tested on versions 20, 22).
|
||||
The core dependencies are python3, Django, celery, PostgreSQL, redis, ffmpeg. Any system that can have these dependencies installed, can run MediaCMS. But the install.sh is only tested in Linux Ubuntu 24 and 22 versions.
|
||||
|
||||
Installation on an Ubuntu system with git utility installed should be completed in a few minutes with the following steps.
|
||||
Installation on an Ubuntu 22/24 system with git utility installed should be completed in a few minutes with the following steps.
|
||||
Make sure you run it as user root, on a clear system, since the automatic script will install and configure the following services: Celery/PostgreSQL/Redis/Nginx and will override any existing settings.
|
||||
|
||||
Automated script - tested on Ubuntu 20, Ubuntu 22 and Debian Buster
|
||||
|
||||
|
||||
```bash
|
||||
mkdir /home/mediacms.io && cd /home/mediacms.io/
|
||||
@@ -83,13 +89,11 @@ Database can be backed up with pg_dump and media_files on /home/mediacms.io/medi
|
||||
## Installation
|
||||
Install a recent version of [Docker](https://docs.docker.com/get-docker/), and [Docker Compose](https://docs.docker.com/compose/install/).
|
||||
|
||||
For Ubuntu 20/22 systems this is:
|
||||
For Ubuntu systems this is:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.docker.com -o get-docker.sh
|
||||
sudo sh get-docker.sh
|
||||
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
```
|
||||
|
||||
Then run as root
|
||||
@@ -105,7 +109,7 @@ If you want to explore more options (including setup of https with letsencrypt c
|
||||
Run
|
||||
|
||||
```bash
|
||||
docker-compose up
|
||||
docker compose up
|
||||
```
|
||||
|
||||
This will download all MediaCMS related Docker images and start all containers. Once it finishes, MediaCMS will be installed and available on http://localhost or http://ip
|
||||
@@ -125,8 +129,8 @@ Get latest MediaCMS image and stop/start containers
|
||||
```bash
|
||||
cd /path/to/mediacms/installation
|
||||
docker pull mediacms/mediacms
|
||||
docker-compose down
|
||||
docker-compose up
|
||||
docker compose down
|
||||
docker compose up
|
||||
```
|
||||
|
||||
### Update from version 2 to version 3
|
||||
@@ -166,7 +170,7 @@ Also see the `Dockerfile` for other environment variables which you may wish to
|
||||
|
||||
See example deployments in the sections below. These example deployments have been tested on `docker-compose version 1.27.4` running on `Docker version 19.03.13`
|
||||
|
||||
To run, update the configs above if necessary, build the image by running `docker-compose build`, then run `docker-compose run`
|
||||
To run, update the configs above if necessary, build the image by running `docker compose build`, then run `docker compose run`
|
||||
|
||||
### Simple Deployment, accessed as http://localhost
|
||||
|
||||
@@ -183,7 +187,7 @@ Edit this file and set `VIRTUAL_HOST` as my_domain.com, `LETSENCRYPT_HOST` as my
|
||||
|
||||
Edit `deploy/docker/local_settings.py` and set https://my_domain.com as `FRONTEND_HOST`
|
||||
|
||||
Now run docker-compose -f docker-compose-letsencrypt.yaml up, when installation finishes you will be able to access https://my_domain.com using a valid Letsencrypt certificate!
|
||||
Now run `docker compose -f docker-compose-letsencrypt.yaml up`, when installation finishes you will be able to access https://my_domain.com using a valid Letsencrypt certificate!
|
||||
|
||||
### Advanced Deployment, accessed as http://localhost:8000
|
||||
|
||||
@@ -224,7 +228,7 @@ Single server installation: edit `cms/local_settings.py`, make a change and rest
|
||||
Docker Compose installation: edit `deploy/docker/local_settings.py`, make a change and restart MediaCMS containers
|
||||
|
||||
```bash
|
||||
#docker-compose restart web celery_worker celery_beat
|
||||
#docker compose restart web celery_worker celery_beat
|
||||
```
|
||||
|
||||
### 5.1 Change portal logo
|
||||
@@ -354,13 +358,22 @@ ADMIN_EMAIL_LIST = ['info@mediacms.io']
|
||||
|
||||
### 5.13 Disallow user registrations from specific domains
|
||||
|
||||
set domains that are not valid for registration via this variable:
|
||||
Set domains that are not valid for registration via this variable:
|
||||
|
||||
```
|
||||
RESTRICTED_DOMAINS_FOR_USER_REGISTRATION = [
|
||||
'xxx.com', 'emaildomainwhatever.com']
|
||||
```
|
||||
|
||||
Alternatively, allow only permitted domains to register. This can be useful if you're using mediacms as a private service within an organization, and want to give free registration for those in the org, but deny registration from all other domains. Setting this option bans all domains NOT in the list from registering. Default is a blank list, which is ignored. To disable, set to a blank list.
|
||||
```
|
||||
ALLOWED_DOMAINS_FOR_USER_REGISTRATION = [
|
||||
"private.com",
|
||||
"vod.private.com",
|
||||
"my.favorite.domain",
|
||||
"test.private.com"]
|
||||
```
|
||||
|
||||
### 5.14 Require a review by MediaCMS editors/managers/admins
|
||||
|
||||
set value
|
||||
@@ -794,14 +807,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
|
||||
@@ -833,10 +840,131 @@ After the string is marked as translatable, add the string to `files/frontend-tr
|
||||
python manage.py process_translations
|
||||
```
|
||||
|
||||
In order to populate the string in all the languages. NO PR will be accepted if this procedure is not followed. You don't have to translate the string to all supported languages, but the command has to run and populate the existing dictionaries with the new strings for all languages. This ensures that there is no missing string to be translated in any language.
|
||||
In order to populate the string in all the languages. NO PR will be accepted if this procedure is not followed. You don't have to translate the string to all supported languages, but the command has to run and populate the existing dictionaries with the new strings for all languages. This ensures that there is no missing string to be translated in any language.
|
||||
|
||||
After this command is run, translate the string to the language you want. If the string to be translated lives in Django templates, you don't have to re-build the frontend. If the change lives in the frontend, you will have to re-build in order to see the changes. The Makefile command `make build-frontend` can help with this.
|
||||
|
||||
|
||||
### 20.5 Add a new language and translate
|
||||
To add a new language: add the language in settings.py, then add the file in `files/frontend-translations/`. Make sure you copy the initial strings by copying `files/frontend-translations/en.py` to it.
|
||||
To add a new language: add the language in settings.py, then add the file in `files/frontend-translations/`. Make sure you copy the initial strings by copying `files/frontend-translations/en.py` to it.
|
||||
|
||||
## 21. How to change the video frames on videos
|
||||
|
||||
By default while watching a video you can hover and see the small images named sprites that are extracted every 10 seconds of a video. You can change this number to something smaller by performing the following:
|
||||
|
||||
* edit ./frontend/src/static/js/components/media-viewer/VideoViewer/index.js and change `seconds: 10 ` to the value you prefer, eg 2.
|
||||
* edit settings.py and set the same number for value SPRITE_NUM_SECS
|
||||
* now you have to re-build the frontend: the easiest way is to run `make build-frontend`, which requires Docker
|
||||
|
||||
After that, newly uploaded videos will have sprites generated with the new number of seconds.
|
||||
|
||||
|
||||
|
||||
## 22. Role-Based Access Control
|
||||
|
||||
By default there are 3 statuses for any Media that lives on the system, public, unlisted, private. When RBAC support is added, a user that is part of a group has access to media that are published to one or more categories that the group is associated with. The workflow is this:
|
||||
|
||||
|
||||
1. A Group is created
|
||||
2. A Category is associated with the Group
|
||||
3. A User is added to the Group
|
||||
|
||||
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.
|
||||
|
||||
- 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
|
||||
- Manager: same as Contributor for now
|
||||
|
||||
Use cases facilitated with RBAC:
|
||||
- viewing a Media in private state: if RBAC is enabled, if user is Member on a Group that is associated with a Category, and the media is published to this Category, then user can view the media
|
||||
- editing a Media: if RBAC is enabled, and user is Contributor to one or more Categories, they can publish media to these Categories as long as they are associated with one Group
|
||||
- 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:
|
||||
|
||||
```
|
||||
USE_RBAC = True
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
To enable SAML support, edit local_settings.py and set the following options:
|
||||
|
||||
```
|
||||
USE_RBAC = True
|
||||
USE_SAML = True
|
||||
USE_IDENTITY_PROVIDERS = True
|
||||
|
||||
USE_X_FORWARDED_HOST = True
|
||||
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
|
||||
SECURE_SSL_REDIRECT = True
|
||||
CSRF_COOKIE_SECURE = True
|
||||
SESSION_COOKIE_SECURE = True
|
||||
|
||||
SOCIALACCOUNT_ADAPTER = 'saml_auth.adapter.SAMLAccountAdapter'
|
||||
SOCIALACCOUNT_PROVIDERS = {
|
||||
"saml": {
|
||||
"provider_class": "saml_auth.custom.provider.CustomSAMLProvider",
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
To set a SAML provider:
|
||||
|
||||
- Step 1: Add SAML Identity Provider
|
||||
1. Navigate to Admin panel
|
||||
2. Select "Identity Provider"
|
||||
3. Configure as follows:
|
||||
- **Provider**: saml
|
||||
- **Provider ID**: an ID for the provider
|
||||
- **IDP Config Name**: a name for the provider
|
||||
- **Client ID**: the identifier that is part of the login, and that is shared with the IDP.
|
||||
- **Site**: Set the default one
|
||||
|
||||
- Step 2: Add SAML Configuration
|
||||
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**:
|
||||
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
|
||||
1. **Email Settings**:
|
||||
- `verified_email`: When enabled, emails from SAML responses will be marked as verified
|
||||
- `Remove from groups`: When enabled, user is removed from a group after login, if they have been removed from the group on the IDP
|
||||
2. **Global Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in MediaCMS
|
||||
3. **Group Role Mapping**: Maps the role returned by SAML (as set in the SAML Configuration tab) with the role in groups that user will be added
|
||||
4. **Group mapping**: This creates groups associated with this IDP. Group ids as they come from SAML, associated with MediaCMS groups
|
||||
5. **Category Mapping**: This maps a group id (from SAML response) with a category in MediaCMS
|
||||
|
||||
## 24. Identity Providers setup
|
||||
|
||||
A separate Django app identity_providers has been added in order to facilitate a number of configurations related to different identity providers. If this is enabled, it gives the following options:
|
||||
|
||||
- allows to add an Identity Provider through Django admin, and set a number of mappings, as Group Mapping, Global Role mapping and more. While SAML is the only provider that can be added out of the box, any identity provider supported by django allauth can be added with minimal effort. If the response of the identity provider contains attributes as role, or groups, then these can be mapped to MediaCMS specific roles (advanced user, editor, manager, admin) and groups (rbac groups)
|
||||
- saves SAML response logs after user is authenticated (can be utilized for other providers too)
|
||||
- allows to specify a list of login options through the admin (eg system login, identity provider login)
|
||||
|
||||
|
||||
to enable the identity providers, set the following setting on `local_settings.py`:
|
||||
|
||||
|
||||
```
|
||||
USE_IDENTITY_PROVIDERS = True
|
||||
```
|
||||
|
||||
Visiting the admin, you will see the Identity Providers tab and you can add one.
|
||||
|
||||
## 25. Custom urls
|
||||
To enable custom urls, set `ALLOW_CUSTOM_MEDIA_URLS = True` on settings.py or local_settings.py
|
||||
This will enable editing the URL of the media, while editing a media. If the URL is already taken you get a message you cannot update this.
|
||||
|
||||
@@ -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
|
||||
<img src="./images/CC-display.png"/>
|
||||
</p>
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
<p align="left">
|
||||
<img src="./images/TimebarComments_Hover.png" height="180" alt="Comment preview on hover"/>
|
||||
<img src="./images/TimebarComments_Hit.png" height="180" alt="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.
|
||||
|
||||
|
||||
145
files/admin.py
@@ -1,6 +1,22 @@
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
from .models import Category, Comment, EncodeProfile, Encoding, Language, Media, Subtitle, Tag
|
||||
from rbac.models import RBACGroup
|
||||
|
||||
from .models import (
|
||||
Category,
|
||||
Comment,
|
||||
EncodeProfile,
|
||||
Encoding,
|
||||
Language,
|
||||
Media,
|
||||
Subtitle,
|
||||
Tag,
|
||||
VideoTrimRequest,
|
||||
)
|
||||
|
||||
|
||||
class CommentAdmin(admin.ModelAdmin):
|
||||
@@ -40,12 +56,126 @@ class MediaAdmin(admin.ModelAdmin):
|
||||
get_comments_count.short_description = "Comments count"
|
||||
|
||||
|
||||
class CategoryAdminForm(forms.ModelForm):
|
||||
rbac_groups = forms.ModelMultipleChoiceField(queryset=RBACGroup.objects.all(), required=False, widget=admin.widgets.FilteredSelectMultiple('Groups', False))
|
||||
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = '__all__'
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
is_rbac_category = cleaned_data.get('is_rbac_category')
|
||||
identity_provider = cleaned_data.get('identity_provider')
|
||||
# Check if this category has any RBAC groups
|
||||
if self.instance.pk:
|
||||
has_rbac_groups = cleaned_data.get('rbac_groups')
|
||||
else:
|
||||
has_rbac_groups = False
|
||||
|
||||
if not is_rbac_category:
|
||||
if has_rbac_groups:
|
||||
cleaned_data['is_rbac_category'] = True
|
||||
# self.add_error('is_rbac_category', ValidationError('This category has RBAC groups assigned. "Is RBAC Category" must be enabled.'))
|
||||
|
||||
for rbac_group in cleaned_data.get('rbac_groups'):
|
||||
if rbac_group.identity_provider != identity_provider:
|
||||
self.add_error('rbac_groups', ValidationError('Chosen Groups are associated with a different Identity Provider than the one selected here.'))
|
||||
|
||||
return cleaned_data
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance.pk:
|
||||
self.fields['rbac_groups'].initial = self.instance.rbac_groups.all()
|
||||
|
||||
def save(self, commit=True):
|
||||
category = super().save(commit=True)
|
||||
|
||||
if commit:
|
||||
self.save_m2m()
|
||||
|
||||
if self.instance.rbac_groups.exists() or self.cleaned_data.get('rbac_groups'):
|
||||
if not self.cleaned_data['is_rbac_category']:
|
||||
category.is_rbac_category = True
|
||||
category.save(update_fields=['is_rbac_category'])
|
||||
return category
|
||||
|
||||
@transaction.atomic
|
||||
def save_m2m(self):
|
||||
if self.instance.pk:
|
||||
rbac_groups = self.cleaned_data['rbac_groups']
|
||||
self._update_rbac_groups(rbac_groups)
|
||||
|
||||
def _update_rbac_groups(self, rbac_groups):
|
||||
new_rbac_group_ids = RBACGroup.objects.filter(pk__in=rbac_groups).values_list('pk', flat=True)
|
||||
|
||||
existing_rbac_groups = RBACGroup.objects.filter(categories=self.instance)
|
||||
existing_rbac_groups_ids = existing_rbac_groups.values_list('pk', flat=True)
|
||||
|
||||
rbac_groups_to_add = RBACGroup.objects.filter(pk__in=new_rbac_group_ids).exclude(pk__in=existing_rbac_groups_ids)
|
||||
rbac_groups_to_remove = existing_rbac_groups.exclude(pk__in=new_rbac_group_ids)
|
||||
|
||||
for rbac_group in rbac_groups_to_add:
|
||||
rbac_group.categories.add(self.instance)
|
||||
|
||||
for rbac_group in rbac_groups_to_remove:
|
||||
rbac_group.categories.remove(self.instance)
|
||||
|
||||
|
||||
class CategoryAdmin(admin.ModelAdmin):
|
||||
search_fields = ["title"]
|
||||
list_display = ["title", "user", "add_date", "is_global", "media_count"]
|
||||
list_filter = ["is_global"]
|
||||
form = CategoryAdminForm
|
||||
|
||||
search_fields = ["title", "uid"]
|
||||
list_display = ["title", "user", "add_date", "media_count"]
|
||||
list_filter = []
|
||||
ordering = ("-add_date",)
|
||||
readonly_fields = ("user", "media_count")
|
||||
change_form_template = 'admin/files/category/change_form.html'
|
||||
|
||||
def get_list_filter(self, request):
|
||||
list_filter = list(self.list_filter)
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
list_filter.insert(0, "is_rbac_category")
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_filter.insert(-1, "identity_provider")
|
||||
|
||||
return list_filter
|
||||
|
||||
def get_list_display(self, request):
|
||||
list_display = list(self.list_display)
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
list_display.insert(-1, "is_rbac_category")
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
list_display.insert(-1, "identity_provider")
|
||||
|
||||
return list_display
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
basic_fieldset = [
|
||||
(
|
||||
'Category Information',
|
||||
{
|
||||
'fields': ['uid', 'title', 'description', 'user', 'media_count', 'thumbnail', 'listings_thumbnail'],
|
||||
},
|
||||
),
|
||||
]
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
rbac_fieldset = [
|
||||
('RBAC Settings', {'fields': ['is_rbac_category'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
|
||||
]
|
||||
if getattr(settings, 'USE_IDENTITY_PROVIDERS', False):
|
||||
rbac_fieldset = [
|
||||
('RBAC Settings', {'fields': ['is_rbac_category', 'identity_provider'], 'classes': ['tab'], 'description': 'Role-Based Access Control settings'}),
|
||||
('Group Access', {'fields': ['rbac_groups'], 'description': 'Select the Groups that have access to category'}),
|
||||
]
|
||||
return basic_fieldset + rbac_fieldset
|
||||
else:
|
||||
return basic_fieldset
|
||||
|
||||
|
||||
class TagAdmin(admin.ModelAdmin):
|
||||
@@ -70,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"]
|
||||
@@ -93,3 +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"
|
||||
|
||||
@@ -15,7 +15,7 @@ class VideoEncodingError(Exception):
|
||||
|
||||
|
||||
RE_TIMECODE = re.compile(r"time=(\d+:\d+:\d+.\d+)")
|
||||
console_encoding = locale.getdefaultlocale()[1] or "UTF-8"
|
||||
console_encoding = locale.getlocale()[1] or "UTF-8"
|
||||
|
||||
|
||||
class FFmpegBackend(object):
|
||||
|
||||
@@ -34,5 +34,11 @@ def stuff(request):
|
||||
ret["RSS_URL"] = "/rss"
|
||||
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
|
||||
|
||||
return ret
|
||||
|
||||
198
files/forms.py
@@ -1,48 +1,100 @@
|
||||
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 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 = (
|
||||
"friendly_token",
|
||||
"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 = {
|
||||
"friendly_token": "Slug",
|
||||
"uploaded_poster": "Poster Image",
|
||||
"thumbnail_time": "Thumbnail Time (seconds)",
|
||||
}
|
||||
help_texts = {
|
||||
"title": "",
|
||||
"friendly_token": "Media URL slug",
|
||||
"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 not getattr(settings, 'ALLOW_CUSTOM_MEDIA_URLS', False):
|
||||
self.fields.pop("friendly_token")
|
||||
if self.instance.media_type != "video":
|
||||
self.fields.pop("thumbnail_time")
|
||||
if not is_mediacms_editor(user):
|
||||
self.fields.pop("featured")
|
||||
self.fields.pop("reported_times")
|
||||
self.fields.pop("is_reviewed")
|
||||
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'))
|
||||
if getattr(settings, 'ALLOW_CUSTOM_MEDIA_URLS', False):
|
||||
self.helper.layout.insert(0, CustomField('friendly_token'))
|
||||
|
||||
self.helper.layout.append(FormActions(Submit('submit', 'Update Media', css_class='primaryAction')))
|
||||
|
||||
def clean_friendly_token(self):
|
||||
token = self.cleaned_data.get("friendly_token", "").strip()
|
||||
|
||||
if token:
|
||||
if not all(c.isalnum() or c in "-_" for c in token):
|
||||
raise forms.ValidationError("Slug can only contain alphanumeric characters, underscores, or hyphens.")
|
||||
|
||||
if Media.objects.filter(friendly_token=token).exclude(pk=self.instance.pk).exists():
|
||||
raise forms.ValidationError("This slug is already in use. Please choose a different one.")
|
||||
return token
|
||||
|
||||
def clean_uploaded_poster(self):
|
||||
image = self.cleaned_data.get("uploaded_poster", False)
|
||||
if image:
|
||||
@@ -50,13 +102,117 @@ class MediaForm(forms.ModelForm):
|
||||
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):
|
||||
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):
|
||||
pass
|
||||
else:
|
||||
self.fields['category'].initial = self.instance.category.all()
|
||||
|
||||
non_rbac_categories = Category.objects.filter(is_rbac_category=False)
|
||||
rbac_categories = user.get_rbac_categories_as_contributor()
|
||||
combined_category_ids = list(non_rbac_categories.values_list('id', flat=True)) + list(rbac_categories.values_list('id', flat=True))
|
||||
|
||||
if self.instance.pk:
|
||||
instance_category_ids = list(self.instance.category.all().values_list('id', flat=True))
|
||||
combined_category_ids = list(set(combined_category_ids + instance_category_ids))
|
||||
|
||||
self.fields['category'].queryset = Category.objects.filter(id__in=combined_category_ids).order_by('title')
|
||||
|
||||
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'),
|
||||
)
|
||||
|
||||
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
|
||||
state = data.get("state")
|
||||
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
|
||||
|
||||
|
||||
@@ -68,6 +224,8 @@ class SubtitleForm(forms.ModelForm):
|
||||
def __init__(self, media_item, *args, **kwargs):
|
||||
super(SubtitleForm, self).__init__(*args, **kwargs)
|
||||
self.instance.media = media_item
|
||||
self.fields["subtitle_file"].help_text = "SubRip (.srt) and WebVTT (.vtt) are supported file formats."
|
||||
self.fields["subtitle_file"].label = "Subtitle or Closed Caption File"
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.instance.user = self.instance.media.user
|
||||
@@ -75,6 +233,14 @@ class SubtitleForm(forms.ModelForm):
|
||||
return media
|
||||
|
||||
|
||||
class EditSubtitleForm(forms.Form):
|
||||
subtitle = forms.CharField(widget=forms.Textarea, required=True)
|
||||
|
||||
def __init__(self, subtitle, *args, **kwargs):
|
||||
super(EditSubtitleForm, self).__init__(*args, **kwargs)
|
||||
self.fields["subtitle"].initial = subtitle.subtitle_file.read().decode("utf-8")
|
||||
|
||||
|
||||
class ContactForm(forms.Form):
|
||||
from_email = forms.EmailField(required=True)
|
||||
name = forms.CharField(required=False)
|
||||
|
||||
@@ -32,7 +32,6 @@ for translation_file in files:
|
||||
replacement_strings[language_code] = tr_module.replacement_strings
|
||||
|
||||
|
||||
|
||||
def get_translation(language_code):
|
||||
# get list of translations per language
|
||||
if not check_language_code(language_code):
|
||||
|
||||
104
files/frontend_translations/da.py
Normal file
@@ -0,0 +1,104 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "OM",
|
||||
"AUTOPLAY": "Automatisk afspilning",
|
||||
"About": "Om",
|
||||
"Add a ": "Tilføj en ",
|
||||
"COMMENT": "KOMMENTAR",
|
||||
"Categories": "Kategorier",
|
||||
"Category": "Kategori",
|
||||
"Change Language": "Skift sprog",
|
||||
"Change password": "Skift adgangskode",
|
||||
"Comment": "Kommentar",
|
||||
"Comments": "Kommentarer",
|
||||
"Comments are disabled": "Kommentarer er slået fra",
|
||||
"Contact": "Kontakt",
|
||||
"DELETE MEDIA": "SLET MEDIE",
|
||||
"DOWNLOAD": "HENT",
|
||||
"EDIT MEDIA": "REDIGER MEDIE",
|
||||
"EDIT PROFILE": "REDIGER PROFIL",
|
||||
"EDIT SUBTITLE": "REDIGER UNDERTEKSTER",
|
||||
"Edit media": "Rediger medie",
|
||||
"Edit profile": "Rediger profil",
|
||||
"Edit subtitle": "Rediger undertekster",
|
||||
"Featured": "Fremhævede",
|
||||
"Go": "Vælg",
|
||||
"History": "Historik",
|
||||
"Home": "Hjem",
|
||||
"Language": "Sprog",
|
||||
"Latest": "Nyeste",
|
||||
"Liked media": "Medier du har liket",
|
||||
"Manage comments": "Administrer kommentarer",
|
||||
"Manage media": "Administrer medier",
|
||||
"Manage users": "Administrer brugere",
|
||||
"Media": "Medier",
|
||||
"Media was edited": "Mediet er blevet redigeret",
|
||||
"Members": "Medlemmer",
|
||||
"My media": "Mine medier",
|
||||
"My playlists": "Mine playlister",
|
||||
"No": "Nej",
|
||||
"No comment yet": "Ingen kommentar endnu",
|
||||
"No comments yet": "Ingen komentarer endnu",
|
||||
"No results for": "Ingen resultater for",
|
||||
"PLAYLISTS": "PLAYLISTER",
|
||||
"Playlists": "Playlister",
|
||||
"Powered by": "Drevet af",
|
||||
"Published on": "Udgivet på",
|
||||
"Recommended": "Anbefalet",
|
||||
"Register": "Registrer",
|
||||
"SAVE": "GEM",
|
||||
"SEARCH": "SØG",
|
||||
"SHARE": "DEL",
|
||||
"SHOW MORE": "VIS MERE",
|
||||
"SUBMIT": "INDSEND",
|
||||
"Search": "Søg",
|
||||
"Select": "Vælg",
|
||||
"Sign in": "Log ind",
|
||||
"Sign out": "Log ud",
|
||||
"Subtitle was added": "Undertekster tilføjet",
|
||||
"Tags": "Tags",
|
||||
"Terms": "Vilkår",
|
||||
"UPLOAD": "UPLOAD",
|
||||
"Up next": "Næste",
|
||||
"Upload": "Upload",
|
||||
"Upload media": "Upload medie",
|
||||
"Uploads": "Uploads",
|
||||
"VIEW ALL": "SE ALLE",
|
||||
"View all": "Se alle",
|
||||
"comment": "kommentar",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "er et moderne, fuldt udstyret open source video og medie CMS. Det er udviklet til at imødekomme behovene for moderne webplatforme til visning og deling af medier.",
|
||||
"media in category": "medier i kategori",
|
||||
"media in tag": "medier i tag",
|
||||
"view": "visning",
|
||||
"views": "visninger",
|
||||
"yet": "endnu",
|
||||
}
|
||||
|
||||
replacement_strings = {
|
||||
"Apr": "Apr",
|
||||
"Aug": "Aug",
|
||||
"Dec": "Dec",
|
||||
"Feb": "Feb",
|
||||
"Jan": "Jan",
|
||||
"Jul": "Jul",
|
||||
"Jun": "Jun",
|
||||
"Mar": "Mar",
|
||||
"May": "Maj",
|
||||
"Nov": "Nov",
|
||||
"Oct": "Okt",
|
||||
"Sep": "Sep",
|
||||
"day ago": "dag siden",
|
||||
"days ago": "dage siden",
|
||||
"hour ago": "time siden",
|
||||
"hours ago": "timer siden",
|
||||
"just now": "lige nu",
|
||||
"minute ago": "minut siden",
|
||||
"minutes ago": "minutter siden",
|
||||
"month ago": "måned siden",
|
||||
"months ago": "måneder siden",
|
||||
"second ago": "sekund siden",
|
||||
"seconds ago": "sekunder siden",
|
||||
"week ago": "uge siden",
|
||||
"weeks ago": "uger siden",
|
||||
"year ago": "år siden",
|
||||
"years ago": "år siden",
|
||||
}
|
||||
104
files/frontend_translations/he.py
Normal file
@@ -0,0 +1,104 @@
|
||||
translation_strings = {
|
||||
'ABOUT': 'על אודות',
|
||||
'AUTOPLAY': 'ניגון אוטומטי',
|
||||
'About': 'על אודות',
|
||||
'Add a ': 'הוסף',
|
||||
'COMMENT': 'תגובה',
|
||||
'Categories': 'קטגוריות',
|
||||
'Category': 'קטגוריה',
|
||||
'Change Language': 'שנה שפה',
|
||||
'Change password': 'שנה סיסמה',
|
||||
'Comment': 'תגובה',
|
||||
'Comments': 'תגובות',
|
||||
'Comments are disabled': 'התגובות מושבתות',
|
||||
'Contact': 'צור קשר',
|
||||
'DELETE MEDIA': 'מחק מדיה',
|
||||
'DOWNLOAD': 'הורד',
|
||||
'EDIT MEDIA': 'ערוך מדיה',
|
||||
'EDIT PROFILE': 'ערוך פרופיל',
|
||||
'EDIT SUBTITLE': 'ערוך כתוביות',
|
||||
'Edit media': 'ערוך מדיה',
|
||||
'Edit profile': 'ערוך פרופיל',
|
||||
'Edit subtitle': 'ערוך כתוביות',
|
||||
'Featured': 'מומלצים',
|
||||
'Go': 'בצע', # in context of "execution"
|
||||
'History': 'היסטוריה',
|
||||
'Home': 'דף הבית',
|
||||
'Language': 'שפה',
|
||||
'Latest': 'העדכונים האחרונים',
|
||||
'Liked media': 'מדיה שאהבתי',
|
||||
'Manage comments': 'ניהול תגובות',
|
||||
'Manage media': 'ניהול מדיה',
|
||||
'Manage users': 'ניהול משתמשים',
|
||||
'Media': 'מדיה',
|
||||
'Media was edited': 'המדיה נערכה',
|
||||
'Members': 'משתמשים',
|
||||
'My media': 'המדיה שלי',
|
||||
'My playlists': 'הפלייליסטים שלי',
|
||||
'No': 'לא', # in context of "no comments", etc.
|
||||
'No comment yet': 'עדיין אין תגובות',
|
||||
'No comments yet': 'עדיין אין תגובות',
|
||||
'No results for': 'אין תוצאות עבור',
|
||||
'PLAYLISTS': 'פלייליסטים',
|
||||
'Playlists': 'פלייליסטים',
|
||||
'Powered by': 'מופעל על ידי',
|
||||
'Published on': 'פורסם בתאריך',
|
||||
'Recommended': 'מומלץ',
|
||||
'Register': 'הרשמה',
|
||||
'SAVE': 'שמור',
|
||||
'SEARCH': 'חפש',
|
||||
'SHARE': 'שתף',
|
||||
'SHOW MORE': 'הצג עוד',
|
||||
'SUBMIT': 'שלח',
|
||||
'Search': 'חפש',
|
||||
'Select': 'בחר',
|
||||
'Sign in': 'התחבר',
|
||||
'Sign out': 'התנתק',
|
||||
'Subtitle was added': 'הכתובית נוספה',
|
||||
'Tags': 'תגיות',
|
||||
'Terms': 'תנאים',
|
||||
'UPLOAD': 'העלה',
|
||||
'Up next': 'הבא בתור',
|
||||
'Upload': 'העלה',
|
||||
'Upload media': 'העלה מדיה',
|
||||
'Uploads': 'העלאות',
|
||||
'VIEW ALL': 'הצג הכל',
|
||||
'View all': 'הצג הכל',
|
||||
'comment': 'תגובה',
|
||||
'is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media': 'מערכת ניהול מדיה ווידאו מודרנית, פתוחה ומלאה בפיצ׳רים. פותחה כדי לענות על הצרכים של פלטפורמות אינטרנט מודרניות לצפייה ושיתוף מדיה.',
|
||||
'media in category': 'מדיה בקטגוריה',
|
||||
'media in tag': 'מדיה בתגית',
|
||||
'view': 'צפיות',
|
||||
'views': 'צפיות',
|
||||
'yet': 'עדיין',
|
||||
}
|
||||
|
||||
replacement_strings = {
|
||||
'Apr': 'אפריל',
|
||||
'Aug': 'אוגוסט',
|
||||
'Dec': 'דצמבר',
|
||||
'Feb': 'פברואר',
|
||||
'Jan': 'ינואר',
|
||||
'Jul': 'יולי',
|
||||
'Jun': 'יוני',
|
||||
'Mar': 'מרץ',
|
||||
'May': 'מאי',
|
||||
'Nov': 'נובמבר',
|
||||
'Oct': 'אוקטובר',
|
||||
'Sep': 'ספטמבר',
|
||||
'day ago': 'לפני יום',
|
||||
'days ago': 'לפני ימים',
|
||||
'hour ago': 'לפני שעה',
|
||||
'hours ago': 'לפני שעות',
|
||||
'just now': 'הרגע',
|
||||
'minute ago': 'לפני דקה',
|
||||
'minutes ago': 'לפני דקות',
|
||||
'month ago': 'לפני חודש',
|
||||
'months ago': 'לפני חודשים',
|
||||
'second ago': 'לפני שנייה',
|
||||
'seconds ago': 'לפני שניות',
|
||||
'week ago': 'לפני שבוע',
|
||||
'weeks ago': 'לפני שבועות',
|
||||
'year ago': 'לפני שנה',
|
||||
'years ago': 'לפני שנים',
|
||||
}
|
||||
105
files/frontend_translations/it.py
Normal file
@@ -0,0 +1,105 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "SU DI NOI",
|
||||
"AUTOPLAY": "RIPRODUZIONE AUTOMATICA",
|
||||
"About": "Su di noi",
|
||||
"Add a": "Aggiungi un",
|
||||
"Add a ": "Aggiungi un ",
|
||||
"COMMENT": "COMMENTA",
|
||||
"Categories": "Categorie",
|
||||
"Category": "Categoria",
|
||||
"Change Language": "Cambia lingua",
|
||||
"Change password": "Cambia password",
|
||||
"Comment": "Commento",
|
||||
"Comments": "Commenti",
|
||||
"Comments are disabled": "I commenti sono disabilitati",
|
||||
"Contact": "Contatti",
|
||||
"DELETE MEDIA": "ELIMINA MEDIA",
|
||||
"DOWNLOAD": "SCARICA",
|
||||
"EDIT MEDIA": "MODIFICA IL MEDIA",
|
||||
"EDIT PROFILE": "MODIFICA IL PROFILO",
|
||||
"EDIT SUBTITLE": "MODIFICA I SOTTOTITOLI",
|
||||
"Edit media": "Modifica il media",
|
||||
"Edit profile": "Modifica il profilo",
|
||||
"Edit subtitle": "Modifica i sottotitoli",
|
||||
"Featured": "In evidenza",
|
||||
"Go": "Vai",
|
||||
"History": "Cronologia",
|
||||
"Home": "Home",
|
||||
"Language": "Lingua",
|
||||
"Latest": "Ultimi",
|
||||
"Liked media": "Piaciuti",
|
||||
"Manage comments": "Gestisci i commenti",
|
||||
"Manage media": "Gestisci i media",
|
||||
"Manage users": "Gestisci gli utenti",
|
||||
"Media": "Media",
|
||||
"Media was edited": "Il media è stato modificato",
|
||||
"Members": "Membri",
|
||||
"My media": "I miei media",
|
||||
"My playlists": "Le mie playlist",
|
||||
"No": "No",
|
||||
"No comment yet": "Ancora nessun commento",
|
||||
"No comments yet": "Ancora nessun commento",
|
||||
"No results for": "Nessun risultato per",
|
||||
"PLAYLISTS": "PLAYLIST",
|
||||
"Playlists": "Playlist",
|
||||
"Powered by": "Powered by",
|
||||
"Published on": "Pubblicato il",
|
||||
"Recommended": "Raccomandati",
|
||||
"Register": "Registrati",
|
||||
"SAVE": "SALVA",
|
||||
"SEARCH": "CERCA",
|
||||
"SHARE": "CONDIVIDI",
|
||||
"SHOW MORE": "MOSTRA DI PIÙ",
|
||||
"SUBMIT": "INVIA",
|
||||
"Search": "Cerca",
|
||||
"Select": "Seleziona",
|
||||
"Sign in": "Login",
|
||||
"Sign out": "Logout",
|
||||
"Subtitle was added": "I sottotitoli sono stati aggiunti",
|
||||
"Tags": "Tag",
|
||||
"Terms": "Termini e condizioni",
|
||||
"UPLOAD": "CARICA",
|
||||
"Up next": "A seguire",
|
||||
"Upload": "Carica",
|
||||
"Upload media": "Carica i media",
|
||||
"Uploads": "Caricamenti",
|
||||
"VIEW ALL": "MOSTRA TUTTI",
|
||||
"View all": "Mostra tutti",
|
||||
"comment": "commento",
|
||||
"is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media": "è un CMS per media open source moderno e completo. È stato sviluppato per rispondere per venire incontro alle esigenze delle moderne piattaforme web di visualizzazione e condivisione media",
|
||||
"media in category": "media nella categoria",
|
||||
"media in tag": "media con tag",
|
||||
"view": "visualizzazione",
|
||||
"views": "visualizzazioni",
|
||||
"yet": "ancora",
|
||||
}
|
||||
|
||||
replacement_strings = {
|
||||
"Apr": "Apr",
|
||||
"Aug": "Ago",
|
||||
"Dec": "Dic",
|
||||
"Feb": "Feb",
|
||||
"Jan": "Gen",
|
||||
"Jul": "Lug",
|
||||
"Jun": "Giu",
|
||||
"Mar": "Mar",
|
||||
"May": "Mag",
|
||||
"Nov": "Nov",
|
||||
"Oct": "Ott",
|
||||
"Sep": "Set",
|
||||
"day ago": "giorno fa",
|
||||
"days ago": "giorni fa",
|
||||
"hour ago": "ora fa",
|
||||
"hours ago": "ore fa",
|
||||
"just now": "adesso",
|
||||
"minute ago": "minuto fa",
|
||||
"minutes ago": "minuti fa",
|
||||
"month ago": "mese fa",
|
||||
"months ago": "mesi fa",
|
||||
"second ago": "secondo fa",
|
||||
"seconds ago": "secondi fa",
|
||||
"week ago": "settimana fa",
|
||||
"weeks ago": "settimane fa",
|
||||
"year ago": "anno fa",
|
||||
"years ago": "anni fa",
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "約",
|
||||
"AUTOPLAY": "自動再生",
|
||||
"About": "",
|
||||
"About": "約",
|
||||
"Add a ": "追加",
|
||||
"COMMENT": "コメント",
|
||||
"Categories": "カテゴリー",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "정보",
|
||||
"AUTOPLAY": "자동 재생",
|
||||
"About": "",
|
||||
"About": "정보",
|
||||
"Add a ": "추가",
|
||||
"COMMENT": "댓글",
|
||||
"Categories": "카테고리",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "OVER",
|
||||
"AUTOPLAY": "AUTOMATISCH AFSPELEN",
|
||||
"About": "",
|
||||
"About": "Over",
|
||||
"Add a ": "Voeg een ",
|
||||
"COMMENT": "REACTIE",
|
||||
"Categories": "Categorieën",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "SOBRE",
|
||||
"AUTOPLAY": "REPRODUÇÃO AUTOMÁTICA",
|
||||
"About": "",
|
||||
"About": "Sobre",
|
||||
"Add a ": "Adicionar um ",
|
||||
"COMMENT": "COMENTÁRIO",
|
||||
"Categories": "Categorias",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "О",
|
||||
"AUTOPLAY": "Автовоспроизведение",
|
||||
"About": "",
|
||||
"About": "О",
|
||||
"Add a ": "Добавить ",
|
||||
"COMMENT": "КОММЕНТАРИЙ",
|
||||
"Categories": "Категории",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "HAKKINDA",
|
||||
"AUTOPLAY": "OTOMATİK OYNATMA",
|
||||
"About": "",
|
||||
"About": "Hakkında",
|
||||
"Add a ": "Ekle ",
|
||||
"COMMENT": "YORUM",
|
||||
"Categories": "Kategoriler",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "کے بارے میں",
|
||||
"AUTOPLAY": "خودکار پلے",
|
||||
"About": "",
|
||||
"About": "کے بارے میں",
|
||||
"Add a ": "شامل کریں",
|
||||
"COMMENT": "تبصرہ",
|
||||
"Categories": "اقسام",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
translation_strings = {
|
||||
"ABOUT": "关于",
|
||||
"AUTOPLAY": "自动播放",
|
||||
"About": "",
|
||||
"About": "关于",
|
||||
"Add a ": "添加一个",
|
||||
"COMMENT": "评论",
|
||||
"Categories": "分类",
|
||||
|
||||
104
files/frontend_translations/zh_hant.py
Normal file
@@ -0,0 +1,104 @@
|
||||
translation_strings = {
|
||||
'ABOUT': '關於',
|
||||
'AUTOPLAY': '自動播放',
|
||||
'About': '關於',
|
||||
'Add a ': '新增',
|
||||
'COMMENT': '留言',
|
||||
'Categories': '分類',
|
||||
'Category': '分類',
|
||||
'Change Language': '切換語言',
|
||||
'Change password': '變更密碼',
|
||||
'Comment': '留言',
|
||||
'Comments': '留言',
|
||||
'Comments are disabled': '留言功能已關閉',
|
||||
'Contact': '聯絡資訊',
|
||||
'DELETE MEDIA': '刪除影片',
|
||||
'DOWNLOAD': '下載',
|
||||
'EDIT MEDIA': '編輯影片',
|
||||
'EDIT PROFILE': '編輯個人資料',
|
||||
'EDIT SUBTITLE': '編輯字幕',
|
||||
'Edit media': '編輯影片',
|
||||
'Edit profile': '編輯個人資料',
|
||||
'Edit subtitle': '編輯字幕',
|
||||
'Featured': '精選內容',
|
||||
'Go': '執行', # in context of "execution"
|
||||
'History': '觀看紀錄',
|
||||
'Home': '首頁',
|
||||
'Language': '語言',
|
||||
'Latest': '最新內容',
|
||||
'Liked media': '我喜歡的影片',
|
||||
'Manage comments': '留言管理',
|
||||
'Manage media': '媒體管理',
|
||||
'Manage users': '使用者管理',
|
||||
'Media': '媒體',
|
||||
'Media was edited': '媒體已更新',
|
||||
'Members': '會員',
|
||||
'My media': '我的媒體',
|
||||
'My playlists': '我的播放清單',
|
||||
'No': '無', # in context of "no comments", etc.
|
||||
'No comment yet': '尚無留言',
|
||||
'No comments yet': '尚未有留言',
|
||||
'No results for': '查無相關結果:',
|
||||
'PLAYLISTS': '播放清單',
|
||||
'Playlists': '播放清單',
|
||||
'Powered by': '技術提供為',
|
||||
'Published on': '發布日期為',
|
||||
'Recommended': '推薦內容',
|
||||
'Register': '註冊',
|
||||
'SAVE': '儲存',
|
||||
'SEARCH': '搜尋',
|
||||
'SHARE': '分享',
|
||||
'SHOW MORE': '顯示更多',
|
||||
'SUBMIT': '送出',
|
||||
'Search': '搜尋',
|
||||
'Select': '選擇',
|
||||
'Sign in': '登入',
|
||||
'Sign out': '登出',
|
||||
'Subtitle was added': '字幕已新增',
|
||||
'Tags': '標籤',
|
||||
'Terms': '使用條款',
|
||||
'UPLOAD': '上傳',
|
||||
'Up next': '即將播放',
|
||||
'Upload': '上傳',
|
||||
'Upload media': '上傳媒體',
|
||||
'Uploads': '上傳內容',
|
||||
'VIEW ALL': '查看全部',
|
||||
'View all': '瀏覽全部',
|
||||
'comment': '留言',
|
||||
'is a modern, fully featured open source video and media CMS. It is developed to meet the needs of modern web platforms for viewing and sharing media': '這是一個現代化且功能完整的開源影音內容管理系統,專為現代網路平台的觀賞與分享需求所打造。',
|
||||
'media in category': '此分類下的媒體',
|
||||
'media in tag': '此標籤下的媒體',
|
||||
'view': '次觀看',
|
||||
'views': '次觀看',
|
||||
'yet': ' ', # no such usage in this language,
|
||||
}
|
||||
|
||||
replacement_strings = {
|
||||
'Apr': '四月',
|
||||
'Aug': '八月',
|
||||
'Dec': '十二月',
|
||||
'Feb': '二月',
|
||||
'Jan': '一月',
|
||||
'Jul': '七月',
|
||||
'Jun': '六月',
|
||||
'Mar': '三月',
|
||||
'May': '五月',
|
||||
'Nov': '十一月',
|
||||
'Oct': '十月',
|
||||
'Sep': '九月',
|
||||
'day ago': '天前',
|
||||
'days ago': '天前',
|
||||
'hour ago': '小時前',
|
||||
'hours ago': '小時前',
|
||||
'just now': '剛剛',
|
||||
'minute ago': '分鐘前',
|
||||
'minutes ago': '分鐘前',
|
||||
'month ago': '個月前',
|
||||
'months ago': '個月前',
|
||||
'second ago': '秒前',
|
||||
'seconds ago': '秒前',
|
||||
'week ago': '週前',
|
||||
'weeks ago': '週前',
|
||||
'year ago': '年前',
|
||||
'years ago': '年前',
|
||||
}
|
||||
177
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
|
||||
|
||||
@@ -18,7 +18,10 @@ class Command(BaseCommand):
|
||||
files = os.listdir(translations_dir)
|
||||
files = [f for f in files if f.endswith('.py') and f not in ('__init__.py', 'en.py')]
|
||||
# Import the original English translations
|
||||
from files.frontend_translations.en import replacement_strings, translation_strings
|
||||
from files.frontend_translations.en import (
|
||||
replacement_strings,
|
||||
translation_strings,
|
||||
)
|
||||
|
||||
for file in files:
|
||||
file_path = os.path.join(translations_dir, file)
|
||||
@@ -44,12 +47,12 @@ class Command(BaseCommand):
|
||||
with open(file_path, 'w') as f:
|
||||
f.write("translation_strings = {\n")
|
||||
for key, value in translation_strings_wip.items():
|
||||
f.write(f' "{key}": "{value}",\n')
|
||||
f.write(f' "{key}": "{value}",\n') # noqa
|
||||
f.write("}\n\n")
|
||||
|
||||
f.write("replacement_strings = {\n")
|
||||
for key, value in replacement_strings_wip.items():
|
||||
f.write(f' "{key}": "{value}",\n')
|
||||
f.write(f' "{key}": "{value}",\n') # noqa
|
||||
f.write("}\n")
|
||||
|
||||
self.stdout.write(self.style.SUCCESS(f'Processed {file}'))
|
||||
|
||||
@@ -46,6 +46,7 @@ class MediaList(APIView):
|
||||
|
||||
featured = params.get("featured", "").strip()
|
||||
is_reviewed = params.get("is_reviewed", "").strip()
|
||||
category = params.get("category", "").strip()
|
||||
|
||||
sort_by_options = [
|
||||
"title",
|
||||
@@ -98,6 +99,9 @@ class MediaList(APIView):
|
||||
if is_reviewed != "all":
|
||||
qs = qs.filter(is_reviewed=is_reviewed)
|
||||
|
||||
if category:
|
||||
qs = qs.filter(category__title__contains=category)
|
||||
|
||||
media = qs.order_by(f"{ordering}{sort_by}")
|
||||
|
||||
paginator = pagination_class()
|
||||
|
||||
129
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__)
|
||||
@@ -119,12 +122,16 @@ def get_next_state(user, current_state, next_state):
|
||||
|
||||
if next_state not in ["public", "private", "unlisted"]:
|
||||
next_state = settings.PORTAL_WORKFLOW # get default state
|
||||
|
||||
if is_mediacms_editor(user):
|
||||
# allow any transition
|
||||
return next_state
|
||||
|
||||
if settings.PORTAL_WORKFLOW == "private":
|
||||
next_state = "private"
|
||||
if next_state in ["private", "unlisted"]:
|
||||
next_state = next_state
|
||||
else:
|
||||
next_state = current_state
|
||||
|
||||
if settings.PORTAL_WORKFLOW == "unlisted":
|
||||
# don't allow to make media public in this case
|
||||
@@ -258,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()
|
||||
@@ -394,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
|
||||
@@ -444,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
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-18 17:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0003_auto_20210927_1245'),
|
||||
('socialaccount', '0006_alter_socialaccount_extra_data'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='subtitle',
|
||||
options={'ordering': ['language__title']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='identity_provider',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
help_text='If category is related with a specific Identity Provider',
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='categories',
|
||||
to='socialaccount.socialapp',
|
||||
verbose_name='IDP Config Name',
|
||||
),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='category',
|
||||
name='is_rbac_category',
|
||||
field=models.BooleanField(db_index=True, default=False, help_text='If access to Category is controlled by role based membership of Groups'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='media',
|
||||
name='state',
|
||||
field=models.CharField(choices=[('private', 'Private'), ('public', 'Public'), ('unlisted', 'Unlisted')], db_index=True, default='private', help_text='state of Media', max_length=20),
|
||||
),
|
||||
]
|
||||
19
files/migrations/0005_alter_category_uid.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-25 14:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
import files.models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0004_alter_subtitle_options_category_identity_provider_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='category',
|
||||
name='uid',
|
||||
field=models.CharField(default=files.models.generate_uid, max_length=36, unique=True),
|
||||
),
|
||||
]
|
||||
17
files/migrations/0006_alter_category_title.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.6 on 2025-03-27 09:11
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0005_alter_category_uid'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='category',
|
||||
name='title',
|
||||
field=models.CharField(db_index=True, max_length=100),
|
||||
),
|
||||
]
|
||||
24
files/migrations/0007_alter_media_state_videochapterdata.py
Normal file
@@ -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',)},
|
||||
},
|
||||
),
|
||||
]
|
||||
30
files/migrations/0008_alter_media_state_videotrimrequest.py
Normal file
@@ -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')),
|
||||
],
|
||||
),
|
||||
]
|
||||
17
files/migrations/0009_alter_media_friendly_token.py
Normal file
@@ -0,0 +1,17 @@
|
||||
# Generated by Django 5.1.6 on 2025-06-20 08:13
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('files', '0008_alter_media_state_videotrimrequest'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='media',
|
||||
name='friendly_token',
|
||||
field=models.CharField(blank=True, db_index=True, help_text='Identifier for the Media', max_length=150, unique=True),
|
||||
),
|
||||
]
|
||||
270
files/models.py
@@ -18,6 +18,7 @@ from django.db.models.signals import m2m_changed, post_delete, post_save, pre_de
|
||||
from django.dispatch import receiver
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.crypto import get_random_string
|
||||
from django.utils.html import strip_tags
|
||||
from imagekit.models import ProcessedImageField
|
||||
from imagekit.processors import ResizeToFit
|
||||
@@ -83,6 +84,10 @@ ENCODE_EXTENSIONS_KEYS = [extension for extension, name in ENCODE_EXTENSIONS]
|
||||
ENCODE_RESOLUTIONS_KEYS = [resolution for resolution, name in ENCODE_RESOLUTIONS]
|
||||
|
||||
|
||||
def generate_uid():
|
||||
return get_random_string(length=16)
|
||||
|
||||
|
||||
def original_media_file_path(instance, filename):
|
||||
"""Helper function to place original media file"""
|
||||
file_name = "{0}.{1}".format(instance.uid.hex, helpers.get_file_name(filename))
|
||||
@@ -150,7 +155,7 @@ class Media(models.Model):
|
||||
help_text="Whether media is globally featured by a MediaCMS editor",
|
||||
)
|
||||
|
||||
friendly_token = models.CharField(blank=True, max_length=12, db_index=True, help_text="Identifier for the Media")
|
||||
friendly_token = models.CharField(blank=True, max_length=150, db_index=True, unique=True, help_text="Identifier for the Media")
|
||||
|
||||
hls_file = models.CharField(max_length=1000, blank=True, help_text="Path to HLS file for videos")
|
||||
|
||||
@@ -382,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
|
||||
@@ -519,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):
|
||||
@@ -554,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
|
||||
|
||||
@@ -632,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):
|
||||
@@ -662,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"""
|
||||
@@ -673,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
|
||||
@@ -780,6 +826,36 @@ class Media(models.Model):
|
||||
return helpers.url_from_path(self.poster.path)
|
||||
return None
|
||||
|
||||
@property
|
||||
def slideshow_items(self):
|
||||
slideshow_items = getattr(settings, "SLIDESHOW_ITEMS", 30)
|
||||
if self.media_type != "image":
|
||||
items = []
|
||||
else:
|
||||
qs = Media.objects.filter(listable=True, user=self.user, media_type="image").exclude(id=self.id).order_by('id')[:slideshow_items]
|
||||
|
||||
items = [
|
||||
{
|
||||
"poster_url": item.poster_url,
|
||||
"url": item.get_absolute_url(),
|
||||
"thumbnail_url": item.thumbnail_url,
|
||||
"title": item.title,
|
||||
"original_media_url": item.original_media_url,
|
||||
}
|
||||
for item in qs
|
||||
]
|
||||
items.insert(
|
||||
0,
|
||||
{
|
||||
"poster_url": self.poster_url,
|
||||
"url": self.get_absolute_url(),
|
||||
"thumbnail_url": self.thumbnail_url,
|
||||
"title": self.title,
|
||||
"original_media_url": self.original_media_url,
|
||||
},
|
||||
)
|
||||
return items
|
||||
|
||||
@property
|
||||
def subtitles_info(self):
|
||||
"""Property used on serializers
|
||||
@@ -787,7 +863,9 @@ class Media(models.Model):
|
||||
"""
|
||||
|
||||
ret = []
|
||||
for subtitle in self.subtitles.all():
|
||||
# Retrieve all subtitles and sort by the first letter of their associated language's title
|
||||
sorted_subtitles = sorted(self.subtitles.all(), key=lambda s: s.language.title[0])
|
||||
for subtitle in sorted_subtitles:
|
||||
ret.append(
|
||||
{
|
||||
"src": helpers.url_from_path(subtitle.subtitle_file.path),
|
||||
@@ -911,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"""
|
||||
@@ -925,11 +1016,11 @@ class License(models.Model):
|
||||
class Category(models.Model):
|
||||
"""A Category base model"""
|
||||
|
||||
uid = models.UUIDField(unique=True, default=uuid.uuid4)
|
||||
uid = models.CharField(unique=True, max_length=36, default=generate_uid)
|
||||
|
||||
add_date = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
title = models.CharField(max_length=100, unique=True, db_index=True)
|
||||
title = models.CharField(max_length=100, db_index=True)
|
||||
|
||||
description = models.TextField(blank=True)
|
||||
|
||||
@@ -949,6 +1040,18 @@ class Category(models.Model):
|
||||
|
||||
listings_thumbnail = models.CharField(max_length=400, blank=True, null=True, help_text="Thumbnail to show on listings")
|
||||
|
||||
is_rbac_category = models.BooleanField(default=False, db_index=True, help_text='If access to Category is controlled by role based membership of Groups')
|
||||
|
||||
identity_provider = models.ForeignKey(
|
||||
'socialaccount.SocialApp',
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='categories',
|
||||
help_text='If category is related with a specific Identity Provider',
|
||||
verbose_name='IDP Config Name',
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@@ -962,7 +1065,11 @@ class Category(models.Model):
|
||||
def update_category_media(self):
|
||||
"""Set media_count"""
|
||||
|
||||
self.media_count = Media.objects.filter(listable=True, category=self).count()
|
||||
if getattr(settings, 'USE_RBAC', False) and self.is_rbac_category:
|
||||
self.media_count = Media.objects.filter(category=self).count()
|
||||
else:
|
||||
self.media_count = Media.objects.filter(listable=True, category=self).count()
|
||||
|
||||
self.save(update_fields=["media_count"])
|
||||
return True
|
||||
|
||||
@@ -1131,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
|
||||
|
||||
@@ -1178,9 +1299,36 @@ class Subtitle(models.Model):
|
||||
|
||||
user = models.ForeignKey("users.User", on_delete=models.CASCADE)
|
||||
|
||||
class Meta:
|
||||
ordering = ["language__title"]
|
||||
|
||||
def __str__(self):
|
||||
return "{0}-{1}".format(self.media.title, self.language.title)
|
||||
|
||||
def get_absolute_url(self):
|
||||
return f"{reverse('edit_subtitle')}?id={self.id}"
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self.get_absolute_url()
|
||||
|
||||
def convert_to_srt(self):
|
||||
input_path = self.subtitle_file.path
|
||||
with tempfile.TemporaryDirectory(dir=settings.TEMP_DIRECTORY) as tmpdirname:
|
||||
pysub = settings.PYSUBS_COMMAND
|
||||
|
||||
cmd = [pysub, input_path, "--to", "vtt", "-o", tmpdirname]
|
||||
stdout = helpers.run_command(cmd)
|
||||
|
||||
list_of_files = os.listdir(tmpdirname)
|
||||
if list_of_files:
|
||||
subtitles_file = os.path.join(tmpdirname, list_of_files[0])
|
||||
cmd = ["cp", subtitles_file, input_path]
|
||||
stdout = helpers.run_command(cmd) # noqa
|
||||
else:
|
||||
raise Exception("Could not convert to srt")
|
||||
return True
|
||||
|
||||
|
||||
class RatingCategory(models.Model):
|
||||
"""Rating Category
|
||||
@@ -1253,7 +1401,7 @@ class Playlist(models.Model):
|
||||
|
||||
@property
|
||||
def media_count(self):
|
||||
return self.media.count()
|
||||
return self.media.filter(listable=True).count()
|
||||
|
||||
def get_absolute_url(self, api=False):
|
||||
if api:
|
||||
@@ -1300,7 +1448,7 @@ class Playlist(models.Model):
|
||||
|
||||
@property
|
||||
def thumbnail_url(self):
|
||||
pm = self.playlistmedia_set.first()
|
||||
pm = self.playlistmedia_set.filter(media__listable=True).first()
|
||||
if pm and pm.media.thumbnail:
|
||||
return helpers.url_from_path(pm.media.thumbnail.path)
|
||||
return None
|
||||
@@ -1360,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
|
||||
@@ -1367,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
|
||||
|
||||
@@ -1399,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:
|
||||
@@ -1421,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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.conf import settings
|
||||
from rest_framework import serializers
|
||||
|
||||
from .methods import is_mediacms_editor
|
||||
from .models import Category, Comment, EncodeProfile, Media, Playlist, Tag
|
||||
|
||||
# TODO: put them in a more DRY way
|
||||
@@ -76,8 +78,25 @@ class MediaSerializer(serializers.ModelSerializer):
|
||||
"featured",
|
||||
"user_featured",
|
||||
"size",
|
||||
# "category",
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
request = self.context.get('request')
|
||||
|
||||
if False and request and 'category' in self.fields:
|
||||
# this is not working
|
||||
user = request.user
|
||||
if is_mediacms_editor(user):
|
||||
pass
|
||||
else:
|
||||
if getattr(settings, 'USE_RBAC', False):
|
||||
# Filter category queryset based on user permissions
|
||||
non_rbac_categories = Category.objects.filter(is_rbac_category=False)
|
||||
rbac_categories = user.get_rbac_categories_as_contributor()
|
||||
self.fields['category'].queryset = non_rbac_categories.union(rbac_categories)
|
||||
|
||||
|
||||
class SingleMediaSerializer(serializers.ModelSerializer):
|
||||
user = serializers.ReadOnlyField(source="user.username")
|
||||
@@ -142,9 +161,11 @@ class SingleMediaSerializer(serializers.ModelSerializer):
|
||||
"hls_info",
|
||||
"license",
|
||||
"subtitles_info",
|
||||
"chapter_data",
|
||||
"ratings_info",
|
||||
"add_subtitle_url",
|
||||
"allow_download",
|
||||
"slideshow_items",
|
||||
)
|
||||
|
||||
|
||||
|
||||
376
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
|
||||
@@ -23,9 +22,36 @@ from users.models import User
|
||||
|
||||
from .backends import FFmpegBackend
|
||||
from .exceptions import VideoEncodingError
|
||||
from .helpers import calculate_seconds, create_temp_file, get_file_name, get_file_type, media_file_info, produce_ffmpeg_commands, produce_friendly_token, rm_file, run_command
|
||||
from .methods import list_tasks, notify_users, pre_save_action
|
||||
from .models import Category, EncodeProfile, Encoding, Media, Rating, Tag
|
||||
from .helpers import (
|
||||
calculate_seconds,
|
||||
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,
|
||||
)
|
||||
|
||||
logger = get_task_logger(__name__)
|
||||
|
||||
@@ -38,7 +64,70 @@ ERRORS_LIST = [
|
||||
]
|
||||
|
||||
|
||||
@task(name="chunkize_media", bind=True, queue="short_tasks", soft_time_limit=60 * 30)
|
||||
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"""
|
||||
|
||||
@@ -135,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:
|
||||
@@ -161,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
|
||||
@@ -301,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
|
||||
@@ -374,23 +479,25 @@ def produce_sprite_from_video(friendly_token):
|
||||
try:
|
||||
tmpdir_image_files = tmpdirname + "/img%03d.jpg"
|
||||
output_name = tmpdirname + "/sprites.jpg"
|
||||
cmd = "{0} -i {1} -f image2 -vf 'fps=1/10, scale=160:90' {2}&&files=$(ls {3}/img*.jpg | sort -t '-' -n -k 2 | tr '\n' ' ')&&convert $files -append {4}".format(
|
||||
settings.FFMPEG_COMMAND,
|
||||
media.media_file.path,
|
||||
tmpdir_image_files,
|
||||
tmpdirname,
|
||||
output_name,
|
||||
)
|
||||
subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
||||
|
||||
fps = getattr(settings, 'SPRITE_NUM_SECS', 10)
|
||||
ffmpeg_cmd = [settings.FFMPEG_COMMAND, "-i", media.media_file.path, "-f", "image2", "-vf", f"fps=1/{fps}, scale=160:90", tmpdir_image_files] # noqa
|
||||
run_command(ffmpeg_cmd)
|
||||
image_files = [f for f in os.listdir(tmpdirname) if f.startswith("img") and f.endswith(".jpg")]
|
||||
image_files = sorted(image_files, key=lambda x: int(re.search(r'\d+', x).group()))
|
||||
image_files = [os.path.join(tmpdirname, f) for f in image_files]
|
||||
cmd_convert = ["convert", *image_files, "-append", output_name] # image files, unpacked into the list
|
||||
ret = run_command(cmd_convert) # noqa
|
||||
|
||||
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",
|
||||
)
|
||||
except BaseException:
|
||||
pass
|
||||
# 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
|
||||
|
||||
|
||||
@@ -415,25 +522,32 @@ def create_hls(friendly_token):
|
||||
p = media.uid.hex
|
||||
output_dir = os.path.join(settings.HLS_DIR, p)
|
||||
encodings = media.encodings.filter(profile__extension="mp4", status="success", chunk=False, profile__codec="h264")
|
||||
|
||||
if encodings:
|
||||
existing_output_dir = None
|
||||
if os.path.exists(output_dir):
|
||||
existing_output_dir = output_dir
|
||||
output_dir = os.path.join(settings.HLS_DIR, p + produce_friendly_token())
|
||||
files = " ".join([f.media_file.path for f in encodings if f.media_file])
|
||||
cmd = "{0} --segment-duration=4 --output-dir={1} {2}".format(settings.MP4HLS_COMMAND, output_dir, files)
|
||||
subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
||||
files = [f.media_file.path for f in encodings if f.media_file]
|
||||
cmd = [settings.MP4HLS_COMMAND, '--segment-duration=4', f'--output-dir={output_dir}', *files]
|
||||
run_command(cmd)
|
||||
|
||||
if existing_output_dir:
|
||||
# override content with -T !
|
||||
cmd = "cp -rT {0} {1}".format(output_dir, existing_output_dir)
|
||||
subprocess.run(cmd, stdout=subprocess.PIPE, shell=True)
|
||||
shutil.rmtree(output_dir)
|
||||
cmd = ["cp", "-rT", output_dir, existing_output_dir]
|
||||
run_command(cmd)
|
||||
|
||||
try:
|
||||
shutil.rmtree(output_dir)
|
||||
except: # noqa
|
||||
# this was breaking in some cases where it was already deleted
|
||||
# because create_hls was running multiple times
|
||||
pass
|
||||
output_dir = existing_output_dir
|
||||
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
|
||||
|
||||
|
||||
@@ -756,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
|
||||
|
||||
@@ -7,5 +7,4 @@ register = template.Library()
|
||||
|
||||
@register.filter
|
||||
def custom_translate(string, lang_code):
|
||||
|
||||
return translate_string(lang_code, string)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from allauth.account.views import LoginView
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include
|
||||
from django.conf.urls.static import static
|
||||
@@ -12,8 +13,12 @@ urlpatterns = [
|
||||
re_path(r"^about", views.about, name="about"),
|
||||
re_path(r"^setlanguage", views.setlanguage, name="setlanguage"),
|
||||
re_path(r"^add_subtitle", views.add_subtitle, name="add_subtitle"),
|
||||
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),
|
||||
@@ -46,7 +51,7 @@ urlpatterns = [
|
||||
re_path(r"^api/v1/media$", views.MediaList.as_view()),
|
||||
re_path(r"^api/v1/media/$", views.MediaList.as_view()),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)$",
|
||||
r"^api/v1/media/(?P<friendly_token>[\w\-_]*)$",
|
||||
views.MediaDetail.as_view(),
|
||||
name="api_get_media",
|
||||
),
|
||||
@@ -60,6 +65,14 @@ urlpatterns = [
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/actions$",
|
||||
views.MediaActions.as_view(),
|
||||
),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\w]*)/chapters$",
|
||||
views.video_chapters,
|
||||
),
|
||||
re_path(
|
||||
r"^api/v1/media/(?P<friendly_token>[\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()),
|
||||
@@ -92,5 +105,14 @@ urlpatterns = [
|
||||
re_path(r"^manage/users$", views.manage_users, name="manage_users"),
|
||||
] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
if hasattr(settings, "USE_SAML") and settings.USE_SAML:
|
||||
urlpatterns.append(re_path(r"^saml/metadata", views.saml_metadata, name="saml-metadata"))
|
||||
|
||||
if hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS:
|
||||
urlpatterns.append(path('accounts/login_system', LoginView.as_view(), name='login_system'))
|
||||
urlpatterns.append(re_path(r"^accounts/login", views.custom_login_view, name='login'))
|
||||
else:
|
||||
urlpatterns.append(path('accounts/login', LoginView.as_view(), name='login_system'))
|
||||
|
||||
if hasattr(settings, "GENERATE_SITEMAP") and settings.GENERATE_SITEMAP:
|
||||
urlpatterns.append(path("sitemap.xml", views.sitemap, name="sitemap"))
|
||||
|
||||
390
files/views.py
@@ -1,42 +1,77 @@
|
||||
import json
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from allauth.socialaccount.models import SocialApp
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
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 HttpResponseRedirect
|
||||
from django.shortcuts import get_object_or_404, render
|
||||
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
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.parsers import FileUploadParser, FormParser, JSONParser, MultiPartParser
|
||||
from rest_framework.parsers import (
|
||||
FileUploadParser,
|
||||
FormParser,
|
||||
JSONParser,
|
||||
MultiPartParser,
|
||||
)
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.settings import api_settings
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from actions.models import USER_MEDIA_ACTIONS, MediaAction
|
||||
from cms.custom_pagination import FastPaginationWithoutCount
|
||||
from cms.permissions import IsAuthorizedToAdd, IsAuthorizedToAddComment, IsUserOrEditor, user_allowed_to_upload
|
||||
from cms.permissions import (
|
||||
IsAuthorizedToAdd,
|
||||
IsAuthorizedToAddComment,
|
||||
IsUserOrEditor,
|
||||
user_allowed_to_upload,
|
||||
)
|
||||
from cms.version import VERSION
|
||||
from identity_providers.models import LoginOption
|
||||
from users.models import User
|
||||
|
||||
from .forms import ContactForm, 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,
|
||||
show_related_media,
|
||||
update_user_ratings,
|
||||
)
|
||||
from .models import Category, Comment, EncodeProfile, Encoding, Media, Playlist, PlaylistMedia, Tag
|
||||
from .models import (
|
||||
Category,
|
||||
Comment,
|
||||
EncodeProfile,
|
||||
Encoding,
|
||||
Media,
|
||||
Playlist,
|
||||
PlaylistMedia,
|
||||
Subtitle,
|
||||
Tag,
|
||||
VideoTrimRequest,
|
||||
)
|
||||
from .serializers import (
|
||||
CategorySerializer,
|
||||
CommentSerializer,
|
||||
@@ -49,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]
|
||||
|
||||
@@ -57,7 +92,7 @@ VALID_USER_ACTIONS = [action for action, name in USER_MEDIA_ACTIONS]
|
||||
def about(request):
|
||||
"""About view"""
|
||||
|
||||
context = {}
|
||||
context = {"VERSION": VERSION}
|
||||
return render(request, "cms/about.html", context)
|
||||
|
||||
|
||||
@@ -79,19 +114,75 @@ 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":
|
||||
form = SubtitleForm(media, request.POST, request.FILES)
|
||||
if form.is_valid():
|
||||
subtitle = form.save()
|
||||
messages.add_message(request, messages.INFO, translate_string(request.LANGUAGE_CODE, "Subtitle was added"))
|
||||
new_subtitle = Subtitle.objects.filter(id=subtitle.id).first()
|
||||
try:
|
||||
new_subtitle.convert_to_srt()
|
||||
messages.add_message(request, messages.INFO, "Subtitle was added!")
|
||||
return HttpResponseRedirect(subtitle.media.get_absolute_url())
|
||||
except: # noqa: E722
|
||||
new_subtitle.delete()
|
||||
error_msg = "Invalid subtitle format. Use SubRip (.srt) or WebVTT (.vtt) files."
|
||||
form.add_error("subtitle_file", error_msg)
|
||||
|
||||
return HttpResponseRedirect(subtitle.media.get_absolute_url())
|
||||
else:
|
||||
form = SubtitleForm(media_item=media)
|
||||
return render(request, "cms/add_subtitle.html", {"form": form})
|
||||
subtitles = media.subtitles.all()
|
||||
context = {"media": media, "form": form, "subtitles": subtitles}
|
||||
return render(request, "cms/add_subtitle.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def edit_subtitle(request):
|
||||
subtitle_id = request.GET.get("id", "").strip()
|
||||
action = request.GET.get("action", "").strip()
|
||||
if not subtitle_id:
|
||||
return HttpResponseRedirect("/")
|
||||
subtitle = Subtitle.objects.filter(id=subtitle_id).first()
|
||||
|
||||
if not subtitle:
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
if not (request.user == subtitle.user or is_mediacms_editor(request.user)):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
context = {"subtitle": subtitle, "action": action}
|
||||
|
||||
if action == "download":
|
||||
response = HttpResponse(subtitle.subtitle_file.read(), content_type="text/vtt")
|
||||
filename = subtitle.subtitle_file.name.split("/")[-1]
|
||||
|
||||
if not filename.endswith(".vtt"):
|
||||
filename = f"{filename}.vtt"
|
||||
|
||||
response["Content-Disposition"] = f"attachment; filename={filename}" # noqa
|
||||
|
||||
return response
|
||||
|
||||
if request.method == "GET":
|
||||
form = EditSubtitleForm(subtitle)
|
||||
context["form"] = form
|
||||
elif request.method == "POST":
|
||||
confirm = request.GET.get("confirm", "").strip()
|
||||
if confirm == "true":
|
||||
messages.add_message(request, messages.INFO, "Subtitle was deleted")
|
||||
redirect_url = subtitle.media.get_absolute_url()
|
||||
subtitle.delete()
|
||||
return HttpResponseRedirect(redirect_url)
|
||||
form = EditSubtitleForm(subtitle, request.POST)
|
||||
subtitle_text = form.data["subtitle"]
|
||||
with open(subtitle.subtitle_file.path, "w") as ff:
|
||||
ff.write(subtitle_text)
|
||||
|
||||
messages.add_message(request, messages.INFO, "Subtitle was edited")
|
||||
return HttpResponseRedirect(subtitle.media.get_absolute_url())
|
||||
return render(request, "cms/edit_subtitle.html", context)
|
||||
|
||||
|
||||
def categories(request):
|
||||
@@ -153,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"""
|
||||
@@ -165,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():
|
||||
@@ -187,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},
|
||||
)
|
||||
|
||||
|
||||
@@ -244,6 +506,9 @@ def liked_media(request):
|
||||
def manage_users(request):
|
||||
"""List users management view"""
|
||||
|
||||
if not is_mediacms_editor(request.user):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
context = {}
|
||||
return render(request, "cms/manage_users.html", context)
|
||||
|
||||
@@ -251,14 +516,19 @@ def manage_users(request):
|
||||
@login_required
|
||||
def manage_media(request):
|
||||
"""List media management view"""
|
||||
if not is_mediacms_editor(request.user):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
context = {}
|
||||
categories = Category.objects.all().order_by('title').values_list('title', flat=True)
|
||||
context = {'categories': list(categories)}
|
||||
return render(request, "cms/manage_media.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def manage_comments(request):
|
||||
"""List comments management view"""
|
||||
if not is_mediacms_editor(request.user):
|
||||
return HttpResponseRedirect("/")
|
||||
|
||||
context = {}
|
||||
return render(request, "cms/manage_comments.html", context)
|
||||
@@ -311,6 +581,7 @@ def tos(request):
|
||||
return render(request, "cms/tos.html", context)
|
||||
|
||||
|
||||
@login_required
|
||||
def upload_media(request):
|
||||
"""Upload media view"""
|
||||
|
||||
@@ -347,10 +618,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)
|
||||
|
||||
|
||||
@@ -459,9 +742,10 @@ class MediaDetail(APIView):
|
||||
# this need be explicitly called, and will call
|
||||
# has_object_permission() after has_permission has succeeded
|
||||
self.check_object_permissions(self.request, media)
|
||||
|
||||
if media.state == "private" and not (self.request.user == media.user or is_mediacms_editor(self.request.user)):
|
||||
if (not password) or (not media.password) or (password != media.password):
|
||||
if getattr(settings, 'USE_RBAC', False) and self.request.user.is_authenticated and self.request.user.has_member_access_to_media(media):
|
||||
pass
|
||||
elif (not password) or (not media.password) or (password != media.password):
|
||||
return Response(
|
||||
{"detail": "media is private"},
|
||||
status=status.HTTP_401_UNAUTHORIZED,
|
||||
@@ -539,7 +823,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")
|
||||
@@ -656,6 +940,9 @@ 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)):
|
||||
return Response({"detail": "not allowed"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if isinstance(media, Response):
|
||||
return media
|
||||
|
||||
@@ -733,7 +1020,7 @@ class MediaActions(APIView):
|
||||
|
||||
class MediaSearch(APIView):
|
||||
"""
|
||||
Retrieve results for searc
|
||||
Retrieve results for search
|
||||
Only GET is implemented here
|
||||
"""
|
||||
|
||||
@@ -793,6 +1080,11 @@ class MediaSearch(APIView):
|
||||
|
||||
if category:
|
||||
media = media.filter(category__title__contains=category)
|
||||
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
|
||||
c_object = Category.objects.filter(title=category, is_rbac_category=True).first()
|
||||
if c_object and request.user.has_member_access_to_category(c_object):
|
||||
# show all media where user has access based on RBAC
|
||||
media = Media.objects.filter(category=c_object)
|
||||
|
||||
if media_type:
|
||||
media = media.filter(media_type=media_type)
|
||||
@@ -909,9 +1201,10 @@ class PlaylistDetail(APIView):
|
||||
|
||||
serializer = PlaylistDetailSerializer(playlist, context={"request": request})
|
||||
|
||||
playlist_media = PlaylistMedia.objects.filter(playlist=playlist).prefetch_related("media__user")
|
||||
playlist_media = PlaylistMedia.objects.filter(playlist=playlist, media__state="public").prefetch_related("media__user")
|
||||
|
||||
playlist_media = [c.media for c in playlist_media]
|
||||
|
||||
playlist_media_serializer = MediaSerializer(playlist_media, many=True, context={"request": request})
|
||||
ret = serializer.data
|
||||
ret["playlist_media"] = playlist_media_serializer.data
|
||||
@@ -1176,7 +1469,7 @@ class CommentList(APIView):
|
||||
def get(self, request, format=None):
|
||||
pagination_class = api_settings.DEFAULT_PAGINATION_CLASS
|
||||
paginator = pagination_class()
|
||||
comments = Comment.objects.filter()
|
||||
comments = Comment.objects.filter(media__state="public").order_by("-add_date")
|
||||
comments = comments.prefetch_related("user")
|
||||
comments = comments.prefetch_related("media")
|
||||
params = self.request.query_params
|
||||
@@ -1336,7 +1629,17 @@ class CategoryList(APIView):
|
||||
},
|
||||
)
|
||||
def get(self, request, format=None):
|
||||
categories = Category.objects.filter().order_by("title")
|
||||
if is_mediacms_editor(request.user):
|
||||
categories = Category.objects.filter()
|
||||
else:
|
||||
categories = Category.objects.filter(is_rbac_category=False)
|
||||
|
||||
if getattr(settings, 'USE_RBAC', False) and request.user.is_authenticated:
|
||||
rbac_categories = request.user.get_rbac_categories_as_member()
|
||||
categories = categories.union(rbac_categories)
|
||||
|
||||
categories = categories.order_by("title")
|
||||
|
||||
serializer = CategorySerializer(categories, many=True, context={"request": request})
|
||||
ret = serializer.data
|
||||
return Response(ret)
|
||||
@@ -1404,3 +1707,38 @@ class TaskDetail(APIView):
|
||||
# This is not imported!
|
||||
# revoke(uid, terminate=True)
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
|
||||
def saml_metadata(request):
|
||||
if not (hasattr(settings, "USE_SAML") and settings.USE_SAML):
|
||||
raise Http404
|
||||
|
||||
xml_parts = ['<?xml version="1.0"?>']
|
||||
saml_social_apps = SocialApp.objects.filter(provider='saml')
|
||||
entity_id = f"{settings.FRONTEND_HOST}/saml/metadata/"
|
||||
xml_parts.append(f'<md:EntitiesDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" Name="{entity_id}">') # noqa
|
||||
xml_parts.append(f' <md:EntityDescriptor entityID="{entity_id}">') # noqa
|
||||
xml_parts.append(' <md:SPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">') # noqa
|
||||
|
||||
# Add multiple AssertionConsumerService elements with different indices
|
||||
for index, app in enumerate(saml_social_apps, start=1):
|
||||
xml_parts.append(
|
||||
f' <md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" ' # noqa
|
||||
f'Location="{settings.FRONTEND_HOST}/accounts/saml/{app.client_id}/acs/" index="{index}"/>' # noqa
|
||||
)
|
||||
|
||||
xml_parts.append(' </md:SPSSODescriptor>') # noqa
|
||||
xml_parts.append(' </md:EntityDescriptor>') # noqa
|
||||
xml_parts.append('</md:EntitiesDescriptor>') # noqa
|
||||
metadata_xml = '\n'.join(xml_parts)
|
||||
return HttpResponse(metadata_xml, content_type='application/xml')
|
||||
|
||||
|
||||
def custom_login_view(request):
|
||||
if not (hasattr(settings, "USE_IDENTITY_PROVIDERS") and settings.USE_IDENTITY_PROVIDERS):
|
||||
return redirect(reverse('login_system'))
|
||||
|
||||
login_options = []
|
||||
for option in LoginOption.objects.filter(active=True):
|
||||
login_options.append({'url': option.url, 'title': option.title})
|
||||
return render(request, 'account/custom_login_selector.html', {'login_options': login_options})
|
||||
|
||||
1
frontend-tools/static/video_editor/video-editor.css
Normal file
203
frontend-tools/static/video_editor/video-editor.js
Normal file
1
frontend-tools/static/video_editor/video-editor.js.map
Normal file
15
frontend-tools/video-editor/.gitignore
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
node_modules
|
||||
dist
|
||||
.DS_Store
|
||||
server/public
|
||||
vite.config.ts.*
|
||||
*.tar.gz
|
||||
yt.readme.md
|
||||
client/public/videos/sample-video.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-37s.mp4
|
||||
videos/sample-video-37s.mp4
|
||||
client/public/videos/sample-video-30s.mp4
|
||||
client/public/videos/sample-video-1.mp4
|
||||
client/public/videos/sample-video-10m.mp4
|
||||
client/public/videos/sample-video-10s.mp4
|
||||
0
frontend-tools/video-editor/.prettierignore
Normal file
22
frontend-tools/video-editor/.prettierrc
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"quoteProps": "as-needed",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"arrowParens": "always",
|
||||
"trailingComma": "none",
|
||||
"endOfLine": "lf",
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["*.css", "*.scss"],
|
||||
"options": {
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
5
frontend-tools/video-editor/.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"prettier.configPath": ".prettierrc"
|
||||
}
|
||||
171
frontend-tools/video-editor/README.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# MediaCMS Video Editor
|
||||
|
||||
A modern browser-based video editing tool built with React and TypeScript that integrates with MediaCMS. The editor allows users to trim, split, and manage video segments with a timeline interface.
|
||||
|
||||
## Features
|
||||
|
||||
- ⏱️ Trim video start and end points
|
||||
- ✂️ Split videos into multiple segments
|
||||
- 👁️ Preview individual segments or the full edited video
|
||||
- 🔄 Undo/redo support for all editing operations
|
||||
- 🔊 Audio mute controls
|
||||
- 💾 Save edits directly to MediaCMS
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React 18
|
||||
- TypeScript
|
||||
- Vite
|
||||
- Tailwind CSS
|
||||
- Express (for development server)
|
||||
- Drizzle ORM
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js (v20+) - Use `nvm use 20` if you have nvm installed
|
||||
- Yarn or npm package manager
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
# Navigate to the video editor directory
|
||||
cd frontend-tools/video-editor
|
||||
|
||||
# Install dependencies with Yarn
|
||||
yarn install
|
||||
|
||||
# Or with npm
|
||||
npm install
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
The video editor can be run in two modes:
|
||||
|
||||
### Standalone Development Mode
|
||||
|
||||
This starts a local development server with hot reloading:
|
||||
|
||||
```bash
|
||||
# Start the development server with Yarn
|
||||
yarn dev
|
||||
|
||||
# Or with npm
|
||||
npm run dev
|
||||
```
|
||||
|
||||
### Frontend-only Development Mode
|
||||
|
||||
If you want to work only on the frontend with MediaCMS backend:
|
||||
|
||||
```bash
|
||||
# Start frontend-only development with Yarn
|
||||
yarn dev:frontend
|
||||
|
||||
# Or with npm
|
||||
npm run dev:frontend
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
### For MediaCMS Integration
|
||||
|
||||
To build the video editor for integration with MediaCMS:
|
||||
|
||||
```bash
|
||||
# Build for Django integration with Yarn
|
||||
yarn build:django
|
||||
|
||||
# Or with npm
|
||||
npm run build:django
|
||||
```
|
||||
|
||||
This will compile the editor and place the output in the MediaCMS static directory.
|
||||
|
||||
### Standalone Build
|
||||
|
||||
To build the editor as a standalone application:
|
||||
|
||||
```bash
|
||||
# Build for production with Yarn
|
||||
yarn build
|
||||
|
||||
# Or with npm
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
To deploy the video editor, you can use the build and deploy script (recommended):
|
||||
|
||||
```bash
|
||||
# Run the deployment script
|
||||
sh deploy/scripts/build_and_deploy.sh
|
||||
```
|
||||
|
||||
The build script handles all necessary steps for compiling and deploying the editor to MediaCMS.
|
||||
|
||||
You can also deploy manually after building:
|
||||
|
||||
```bash
|
||||
# With Yarn
|
||||
yarn deploy
|
||||
|
||||
# Or with npm
|
||||
npm run deploy
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
- `/src` - Source code
|
||||
- `/components` - React components
|
||||
- `/hooks` - Custom React hooks
|
||||
- `/lib` - Utility functions and helpers
|
||||
- `/services` - API services
|
||||
- `/styles` - CSS and style definitions
|
||||
|
||||
## API Integration
|
||||
|
||||
The video editor interfaces with MediaCMS through a set of API endpoints for retrieving and saving video edits.
|
||||
|
||||
Sure! Here's your updated `README.md` section with a new **"Code Formatting"** section using Prettier. I placed it after the "Development" section to keep the flow logical:
|
||||
|
||||
---
|
||||
|
||||
## Code Formatting
|
||||
|
||||
To automatically format all source files using [Prettier](https://prettier.io):
|
||||
|
||||
```bash
|
||||
# Format all code in the src directory
|
||||
npx prettier --write src/
|
||||
```
|
||||
|
||||
Or for specific file types:
|
||||
|
||||
```bash
|
||||
cd frontend-tools/video-editor/
|
||||
npx prettier --write "client/src/**/*.{js,jsx,ts,tsx,json,css,scss,md}"
|
||||
```
|
||||
|
||||
You can also add this as a script in `package.json`:
|
||||
|
||||
```json
|
||||
"scripts": {
|
||||
"format": "prettier --write client/src/"
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
|
||||
```bash
|
||||
yarn format
|
||||
# or
|
||||
npm run format
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
Let me know if you'd like to auto-format on commit using `lint-staged` + `husky`.
|
||||
29
frontend-tools/video-editor/client/index.html
Normal file
@@ -0,0 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<title>Video Editor</title>
|
||||
<!-- Add meta tag to help iOS devices render as desktop -->
|
||||
<script>
|
||||
// Try to detect iOS
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream;
|
||||
|
||||
if (isIOS) {
|
||||
// Replace viewport meta tag with one optimized for desktop view
|
||||
const viewportMeta = document.querySelector('meta[name="viewport"]');
|
||||
if (viewportMeta) {
|
||||
viewportMeta.setAttribute('content', 'width=1024, initial-scale=1.0, maximum-scale=1.0, user-scalable=no');
|
||||
}
|
||||
|
||||
// Add a class to the HTML element for iOS-specific styles
|
||||
document.documentElement.classList.add('ios-device');
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="video-editor-trim-root"></div>
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
303
frontend-tools/video-editor/client/src/App.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "./lib/timeUtils";
|
||||
import logger from "./lib/logger";
|
||||
import VideoPlayer from "@/components/VideoPlayer";
|
||||
import TimelineControls from "@/components/TimelineControls";
|
||||
import EditingTools from "@/components/EditingTools";
|
||||
import ClipSegments from "@/components/ClipSegments";
|
||||
import MobilePlayPrompt from "@/components/IOSPlayPrompt";
|
||||
import useVideoTrimmer from "@/hooks/useVideoTrimmer";
|
||||
|
||||
const App = () => {
|
||||
const {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized,
|
||||
isPlayingSegments,
|
||||
handlePlaySegments
|
||||
} = useVideoTrimmer();
|
||||
|
||||
// Function to play from the beginning
|
||||
const playFromBeginning = () => {
|
||||
if (videoRef.current) {
|
||||
videoRef.current.currentTime = 0;
|
||||
handleMobileSafeSeek(0);
|
||||
if (!isPlaying) {
|
||||
handlePlay();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
const newTime = Math.max(0, currentTime - 15);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
const newTime = Math.min(duration, currentTime + 15);
|
||||
handleMobileSafeSeek(newTime);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
|
||||
// If already playing, just pause the video
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6)); // Fix to microsecond precision
|
||||
|
||||
// Find the next stopping point based on current position
|
||||
let stopTime = duration;
|
||||
let currentSegment = null;
|
||||
let nextSegment = null;
|
||||
|
||||
// Sort segments by start time to ensure correct order
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// First, check if we're inside a segment or exactly at its start/end
|
||||
currentSegment = sortedSegments.find((seg) => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
const segEndTime = Number(seg.endTime.toFixed(6));
|
||||
|
||||
// Check if we're inside the segment
|
||||
if (currentPosition > segStartTime && currentPosition < segEndTime) {
|
||||
return true;
|
||||
}
|
||||
// Check if we're exactly at the start
|
||||
if (currentPosition === segStartTime) {
|
||||
return true;
|
||||
}
|
||||
// Check if we're exactly at the end
|
||||
if (currentPosition === segEndTime) {
|
||||
// If we're at the end of a segment, we should look for the next one
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
// If we're not in a segment, find the next segment
|
||||
if (!currentSegment) {
|
||||
nextSegment = sortedSegments.find((seg) => {
|
||||
const segStartTime = Number(seg.startTime.toFixed(6));
|
||||
return segStartTime > currentPosition;
|
||||
});
|
||||
}
|
||||
|
||||
// Determine where to stop based on position
|
||||
if (currentSegment) {
|
||||
// If we're in a segment, stop at its end
|
||||
stopTime = Number(currentSegment.endTime.toFixed(6));
|
||||
} else if (nextSegment) {
|
||||
// If we're in a cutaway and there's a next segment, stop at its start
|
||||
stopTime = Number(nextSegment.startTime.toFixed(6));
|
||||
}
|
||||
|
||||
// Create a boundary checker function with high precision
|
||||
const checkBoundary = () => {
|
||||
if (!video) return;
|
||||
|
||||
const currentPosition = Number(video.currentTime.toFixed(6));
|
||||
const timeLeft = Number((stopTime - currentPosition).toFixed(6));
|
||||
|
||||
// If we've reached or passed the boundary
|
||||
if (timeLeft <= 0 || currentPosition >= stopTime) {
|
||||
// First pause playback
|
||||
video.pause();
|
||||
|
||||
// Force exact position with multiple verification attempts
|
||||
const setExactPosition = () => {
|
||||
if (!video) return;
|
||||
|
||||
// Set to exact boundary time
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
|
||||
const actualPosition = Number(video.currentTime.toFixed(6));
|
||||
const difference = Number(Math.abs(actualPosition - stopTime).toFixed(6));
|
||||
|
||||
logger.debug("Position verification:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(actualPosition),
|
||||
difference: difference
|
||||
});
|
||||
|
||||
// If we're not exactly at the target position, try one more time
|
||||
if (difference > 0) {
|
||||
video.currentTime = stopTime;
|
||||
handleMobileSafeSeek(stopTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Multiple attempts to ensure precision, with increasing delays
|
||||
setExactPosition();
|
||||
setTimeout(setExactPosition, 5); // Quick first retry
|
||||
setTimeout(setExactPosition, 10); // Second retry
|
||||
setTimeout(setExactPosition, 20); // Third retry if needed
|
||||
setTimeout(setExactPosition, 50); // Final verification
|
||||
|
||||
// Remove our boundary checker
|
||||
video.removeEventListener("timeupdate", checkBoundary);
|
||||
setIsPlaying(false);
|
||||
|
||||
// Log the final position for debugging
|
||||
logger.debug("Stopped at position:", {
|
||||
target: formatDetailedTime(stopTime),
|
||||
actual: formatDetailedTime(video.currentTime),
|
||||
type: currentSegment
|
||||
? "segment end"
|
||||
: nextSegment
|
||||
? "next segment start"
|
||||
: "end of video",
|
||||
segment: currentSegment
|
||||
? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
}
|
||||
: null,
|
||||
nextSegment: nextSegment
|
||||
? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
}
|
||||
: null
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Start our boundary checker
|
||||
video.addEventListener("timeupdate", checkBoundary);
|
||||
|
||||
// Start playing
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
logger.debug("Playback started:", {
|
||||
from: formatDetailedTime(currentPosition),
|
||||
to: formatDetailedTime(stopTime),
|
||||
currentSegment: currentSegment
|
||||
? {
|
||||
id: currentSegment.id,
|
||||
start: formatDetailedTime(currentSegment.startTime),
|
||||
end: formatDetailedTime(currentSegment.endTime)
|
||||
}
|
||||
: "None",
|
||||
nextSegment: nextSegment
|
||||
? {
|
||||
id: nextSegment.id,
|
||||
start: formatDetailedTime(nextSegment.startTime),
|
||||
end: formatDetailedTime(nextSegment.endTime)
|
||||
}
|
||||
: "None"
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background min-h-screen">
|
||||
<MobilePlayPrompt videoRef={videoRef} onPlay={handlePlay} />
|
||||
|
||||
<div className="container mx-auto px-4 py-6 max-w-6xl">
|
||||
{/* Video Player */}
|
||||
<VideoPlayer
|
||||
videoRef={videoRef}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
isPlaying={isPlaying}
|
||||
isMuted={isMuted}
|
||||
onPlayPause={handlePlay}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
onToggleMute={toggleMute}
|
||||
/>
|
||||
|
||||
{/* Editing Tools */}
|
||||
<EditingTools
|
||||
onSplit={handleSplit}
|
||||
onReset={handleReset}
|
||||
onUndo={handleUndo}
|
||||
onRedo={handleRedo}
|
||||
onPlaySegments={handlePlaySegments}
|
||||
onPlay={handlePlay}
|
||||
isPlaying={isPlaying}
|
||||
isPlayingSegments={isPlayingSegments}
|
||||
canUndo={historyPosition > 0}
|
||||
canRedo={historyPosition < history.length - 1}
|
||||
/>
|
||||
|
||||
{/* Timeline Controls */}
|
||||
<TimelineControls
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
thumbnails={thumbnails}
|
||||
trimStart={trimStart}
|
||||
trimEnd={trimEnd}
|
||||
splitPoints={splitPoints}
|
||||
zoomLevel={zoomLevel}
|
||||
clipSegments={clipSegments}
|
||||
onTrimStartChange={handleTrimStartChange}
|
||||
onTrimEndChange={handleTrimEndChange}
|
||||
onZoomChange={handleZoomChange}
|
||||
onSeek={handleMobileSafeSeek}
|
||||
videoRef={videoRef}
|
||||
onSave={handleSave}
|
||||
onSaveACopy={handleSaveACopy}
|
||||
onSaveSegments={handleSaveSegments}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
isIOSUninitialized={isMobile && !videoInitialized}
|
||||
isPlaying={isPlaying}
|
||||
setIsPlaying={setIsPlaying}
|
||||
onPlayPause={handlePlay}
|
||||
isPlayingSegments={isPlayingSegments}
|
||||
/>
|
||||
|
||||
{/* Clip Segments */}
|
||||
<ClipSegments segments={clipSegments} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M208.15,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C218.47,375.57,213.85,380.19,208.15,380.19z"/></g><g><path class="st0" d="M395.04,380.19h-91.19c-5.7,0-10.32-4.62-10.32-10.32V142.13c0-5.7,4.62-10.32,10.32-10.32h91.19 c5.7,0,10.32,4.62,10.32,10.32v227.74C405.36,375.57,400.74,380.19,395.04,380.19z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 832 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">.st0{fill:#333333;}.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 813 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><g><path class="st0" d="M85.26,277.5l164.08,94.73c16.55,9.56,37.24-2.39,37.24-21.5V161.27c0-19.11-20.69-31.06-37.24-21.5 L85.26,234.5C68.71,244.06,68.71,267.94,85.26,277.5z"/></g><g><path class="st0" d="M377.47,375.59h41.42c11.19,0,20.26-9.07,20.26-20.26V156.67c0-11.19-9.07-20.26-20.26-20.26h-41.42 c-11.19,0-20.26,9.07-20.26,20.26v198.67C357.21,366.52,366.28,375.59,377.47,375.59z"/></g></g></g></svg>
|
||||
|
After Width: | Height: | Size: 818 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 597 B |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" ?><svg style="enable-background:new 0 0 512 512;" version="1.1" viewBox="0 0 512 512" xml:space="preserve" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><style type="text/css">
|
||||
.st0{fill:#333333;}
|
||||
.st1{fill:none;stroke:#333333;stroke-width:32;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:10;}
|
||||
</style><g id="Layer_1"/><g id="Layer_2"><g><path class="st0" d="M350.45,277.5l-164.08,94.73c-16.55,9.56-37.24-2.39-37.24-21.5V161.27c0-19.11,20.69-31.06,37.24-21.5 l164.08,94.73C367,244.06,367,267.94,350.45,277.5z"/></g></g></svg>
|
||||
|
After Width: | Height: | Size: 611 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<title/>
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3Z"/>
|
||||
<g transform="translate(2, 0)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 439 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z" id="logout_account_exit_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(30, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 412 B |
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" ?>
|
||||
<svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg">
|
||||
<g data-name="1" id="_1">
|
||||
<path d="M5,3V29a1,1,0,0,0,1,1H26a1,1,0,0,0,1-1V25H25v3H7V4H25V7h2V3a1,1,0,0,0-1-1H6A1,1,0,0,0,5,3Z"/>
|
||||
<g transform="translate(28, 0) scale(-1, 1)">
|
||||
<path d="M10.71,20.29,7.41,17H18V15H7.41l3.3-3.29L9.29,10.29l-5,5a1,1,0,0,0,0,1.42l5,5Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 411 B |
@@ -0,0 +1 @@
|
||||
<?xml version="1.0" ?><svg height="32" viewBox="0 0 32 32" width="32" xmlns="http://www.w3.org/2000/svg"><title/><g data-name="1" id="_1"><path d="M27,3V29a1,1,0,0,1-1,1H6a1,1,0,0,1-1-1V27H7v1H25V4H7V7H5V3A1,1,0,0,1,6,2H26A1,1,0,0,1,27,3ZM12.29,20.29l1.42,1.42,5-5a1,1,0,0,0,0-1.42l-5-5-1.42,1.42L15.59,15H5v2H15.59Z" id="login_account_enter_door"/></g></svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,85 @@
|
||||
import { formatTime, formatLongTime } from "@/lib/timeUtils";
|
||||
import "../styles/ClipSegments.css";
|
||||
|
||||
export interface Segment {
|
||||
id: number;
|
||||
name: string;
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
thumbnail: string;
|
||||
}
|
||||
|
||||
interface ClipSegmentsProps {
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
const ClipSegments = ({ segments }: ClipSegmentsProps) => {
|
||||
// Sort segments by startTime
|
||||
const sortedSegments = [...segments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Handle delete segment click
|
||||
const handleDeleteSegment = (segmentId: number) => {
|
||||
// Create and dispatch the delete event
|
||||
const deleteEvent = new CustomEvent("delete-segment", {
|
||||
detail: { segmentId }
|
||||
});
|
||||
document.dispatchEvent(deleteEvent);
|
||||
};
|
||||
|
||||
// Generate the same color background for a segment as shown in the timeline
|
||||
const getSegmentColorClass = (index: number) => {
|
||||
// Return CSS class based on index modulo 8
|
||||
// This matches the CSS nth-child selectors in the timeline
|
||||
return `segment-default-color segment-color-${(index % 8) + 1}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="clip-segments-container">
|
||||
<h3 className="clip-segments-title">Clip Segments</h3>
|
||||
|
||||
{sortedSegments.map((segment, index) => (
|
||||
<div key={segment.id} className={`segment-item ${getSegmentColorClass(index)}`}>
|
||||
<div className="segment-content">
|
||||
<div
|
||||
className="segment-thumbnail"
|
||||
style={{ backgroundImage: `url(${segment.thumbnail})` }}
|
||||
></div>
|
||||
<div className="segment-info">
|
||||
<div className="segment-title">Segment {index + 1}</div>
|
||||
<div className="segment-time">
|
||||
{formatTime(segment.startTime)} - {formatTime(segment.endTime)}
|
||||
</div>
|
||||
<div className="segment-duration">
|
||||
Duration: {formatLongTime(segment.endTime - segment.startTime)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="segment-actions">
|
||||
<button
|
||||
className="delete-button"
|
||||
aria-label="Delete Segment"
|
||||
data-tooltip="Delete this segment"
|
||||
onClick={() => handleDeleteSegment(segment.id)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M9 2a1 1 0 00-.894.553L7.382 4H4a1 1 0 000 2v10a2 2 0 002 2h8a2 2 0 002-2V6a1 1 0 100-2h-3.382l-.724-1.447A1 1 0 0011 2H9zM7 8a1 1 0 012 0v6a1 1 0 11-2 0V8zm5-1a1 1 0 00-1 1v6a1 1 0 102 0V8a1 1 0 00-1-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{sortedSegments.length === 0 && (
|
||||
<div className="empty-message">
|
||||
No segments created yet. Use the split button to create segments.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClipSegments;
|
||||
@@ -0,0 +1,272 @@
|
||||
import "../styles/EditingTools.css";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface EditingToolsProps {
|
||||
onSplit: () => void;
|
||||
onReset: () => void;
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
onPlaySegments: () => void;
|
||||
onPlay: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
isPlaying?: boolean;
|
||||
isPlayingSegments?: boolean;
|
||||
}
|
||||
|
||||
const EditingTools = ({
|
||||
onSplit,
|
||||
onReset,
|
||||
onUndo,
|
||||
onRedo,
|
||||
onPlaySegments,
|
||||
onPlay,
|
||||
canUndo,
|
||||
canRedo,
|
||||
isPlaying = false,
|
||||
isPlayingSegments = false
|
||||
}: EditingToolsProps) => {
|
||||
const [isSmallScreen, setIsSmallScreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkScreenSize = () => {
|
||||
setIsSmallScreen(window.innerWidth <= 640);
|
||||
};
|
||||
|
||||
checkScreenSize();
|
||||
window.addEventListener("resize", checkScreenSize);
|
||||
return () => window.removeEventListener("resize", checkScreenSize);
|
||||
}, []);
|
||||
|
||||
// Handle play button click with iOS fix
|
||||
const handlePlay = () => {
|
||||
// Ensure lastSeekedPosition is used when play is clicked
|
||||
if (typeof window !== "undefined") {
|
||||
console.log("Play button clicked, current lastSeekedPosition:", window.lastSeekedPosition);
|
||||
}
|
||||
|
||||
// Call the original handler
|
||||
onPlay();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editing-tools-container">
|
||||
<div className="flex-container single-row">
|
||||
{/* Left side - Play buttons group */}
|
||||
<div className="button-group play-buttons-group">
|
||||
{/* Play Segments button */}
|
||||
<button
|
||||
className={`button segments-button`}
|
||||
onClick={onPlaySegments}
|
||||
data-tooltip={
|
||||
isPlayingSegments ? "Stop segments playback" : "Play segments in one continuous flow"
|
||||
}
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
>
|
||||
{isPlayingSegments ? (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop Preview</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Play Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Play Preview button */}
|
||||
{/* <button
|
||||
className="button preview-button"
|
||||
onClick={onPreview}
|
||||
data-tooltip={isPreviewMode ? "Stop preview playback" : "Play only segments (skips gaps between segments)"}
|
||||
style={{ fontSize: '0.875rem' }}
|
||||
>
|
||||
{isPreviewMode ? (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Stop Preview</span>
|
||||
<span className="short-text">Stop</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play Preview</span>
|
||||
<span className="short-text">Preview</span>
|
||||
</>
|
||||
)}
|
||||
</button> */}
|
||||
|
||||
{/* Standard Play button (only shown when not in segments playback on small screens) */}
|
||||
{(!isPlayingSegments || !isSmallScreen) && (
|
||||
<button
|
||||
className={`button play-button ${isPlayingSegments ? "greyed-out" : ""}`}
|
||||
onClick={handlePlay}
|
||||
data-tooltip={isPlaying ? "Pause video" : "Play full video"}
|
||||
style={{ fontSize: "0.875rem" }}
|
||||
disabled={isPlayingSegments}
|
||||
>
|
||||
{isPlaying && !isPlayingSegments ? (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="10" y1="15" x2="10" y2="9" />
|
||||
<line x1="14" y1="15" x2="14" y2="9" />
|
||||
</svg>
|
||||
<span className="full-text">Pause</span>
|
||||
<span className="short-text">Pause</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<polygon points="10 8 16 12 10 16 10 8" />
|
||||
</svg>
|
||||
<span className="full-text">Play</span>
|
||||
<span className="short-text">Play</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Segments Playback message (replaces play button during segments playback) */}
|
||||
{/* {isPlayingSegments && !isSmallScreen && (
|
||||
<div className="segments-playback-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
|
||||
{/* Preview mode message (replaces play button) */}
|
||||
{/* {isPreviewMode && (
|
||||
<div className="preview-mode-message">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12" y2="8" />
|
||||
</svg>
|
||||
Preview Mode
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
|
||||
{/* Right side - Editing tools */}
|
||||
<div className="button-group secondary">
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Undo"
|
||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Undo last action"}
|
||||
disabled={!canUndo || isPlayingSegments}
|
||||
onClick={onUndo}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M9 14 4 9l5-5" />
|
||||
<path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5v0a5.5 5.5 0 0 1-5.5 5.5H11" />
|
||||
</svg>
|
||||
<span className="button-text">Undo</span>
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
aria-label="Redo"
|
||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Redo last undone action"}
|
||||
disabled={!canRedo || isPlayingSegments}
|
||||
onClick={onRedo}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="m15 14 5-5-5-5" />
|
||||
<path d="M20 9H9.5A5.5 5.5 0 0 0 4 14.5v0A5.5 5.5 0 0 0 9.5 20H13" />
|
||||
</svg>
|
||||
<span className="button-text">Redo</span>
|
||||
</button>
|
||||
<div className="divider"></div>
|
||||
<button
|
||||
className="button"
|
||||
onClick={onReset}
|
||||
data-tooltip={isPlayingSegments ? "Disabled during preview" : "Reset to full video"}
|
||||
disabled={isPlayingSegments}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="reset-text">Reset</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditingTools;
|
||||
@@ -0,0 +1,76 @@
|
||||
import React, { useState, useEffect } from "react";
|
||||
import "../styles/IOSPlayPrompt.css";
|
||||
|
||||
interface MobilePlayPromptProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const MobilePlayPrompt: React.FC<MobilePlayPromptProps> = ({ videoRef, onPlay }) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
// Check if the device is mobile
|
||||
useEffect(() => {
|
||||
const checkIsMobile = () => {
|
||||
// More comprehensive check for mobile/tablet devices
|
||||
return /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
};
|
||||
|
||||
// Always show for mobile devices on each visit
|
||||
const isMobile = checkIsMobile();
|
||||
setIsVisible(isMobile);
|
||||
}, []);
|
||||
|
||||
// Close the prompt when video plays
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handlePlay = () => {
|
||||
// Just close the prompt when video plays
|
||||
setIsVisible(false);
|
||||
};
|
||||
|
||||
video.addEventListener("play", handlePlay);
|
||||
return () => {
|
||||
video.removeEventListener("play", handlePlay);
|
||||
};
|
||||
}, [videoRef]);
|
||||
|
||||
const handlePlayClick = () => {
|
||||
onPlay();
|
||||
// Prompt will be closed by the play event handler
|
||||
};
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
return (
|
||||
<div className="mobile-play-prompt-overlay">
|
||||
<div className="mobile-play-prompt">
|
||||
{/* <h3>Mobile Device Notice</h3>
|
||||
|
||||
<p>
|
||||
For the best video editing experience on mobile devices, you need to <strong>play the video first</strong> before
|
||||
using the timeline controls.
|
||||
</p>
|
||||
|
||||
<div className="mobile-prompt-instructions">
|
||||
<p>Please follow these steps:</p>
|
||||
<ol>
|
||||
<li>Tap the button below to start the video</li>
|
||||
<li>After the video starts, you can pause it</li>
|
||||
<li>Then you'll be able to use all timeline controls</li>
|
||||
</ol>
|
||||
</div> */}
|
||||
|
||||
<button className="mobile-play-button" onClick={handlePlayClick}>
|
||||
Click to start editing...
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MobilePlayPrompt;
|
||||
@@ -0,0 +1,184 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { formatTime } from "@/lib/timeUtils";
|
||||
import "../styles/IOSVideoPlayer.css";
|
||||
|
||||
interface IOSVideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
const IOSVideoPlayer = ({ videoRef, currentTime, duration }: IOSVideoPlayerProps) => {
|
||||
const [videoUrl, setVideoUrl] = useState<string>("");
|
||||
const [iosVideoRef, setIosVideoRef] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
// Refs for hold-to-continue functionality
|
||||
const incrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const decrementIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Clean up intervals on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get the video source URL from the main player
|
||||
useEffect(() => {
|
||||
if (videoRef.current && videoRef.current.querySelector("source")) {
|
||||
const source = videoRef.current.querySelector("source") as HTMLSourceElement;
|
||||
if (source && source.src) {
|
||||
setVideoUrl(source.src);
|
||||
}
|
||||
} else {
|
||||
// Fallback to sample video if needed
|
||||
setVideoUrl("/videos/sample-video-10m.mp4");
|
||||
}
|
||||
}, [videoRef]);
|
||||
|
||||
// Function to jump 15 seconds backward
|
||||
const jumpBackward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.max(0, iosVideoRef.currentTime - 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Function to jump 15 seconds forward
|
||||
const jumpForward15 = () => {
|
||||
if (iosVideoRef) {
|
||||
const newTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 15);
|
||||
iosVideoRef.currentTime = newTime;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms increment when button is held
|
||||
const startIncrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (incrementIntervalRef.current) clearInterval(incrementIntervalRef.current);
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
|
||||
// Setup continuous adjustment
|
||||
incrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
iosVideoRef.currentTime = Math.min(iosVideoRef.duration, iosVideoRef.currentTime + 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous increment
|
||||
const stopIncrement = () => {
|
||||
if (incrementIntervalRef.current) {
|
||||
clearInterval(incrementIntervalRef.current);
|
||||
incrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// Start continuous 50ms decrement when button is held
|
||||
const startDecrement = (e: React.MouseEvent | React.TouchEvent) => {
|
||||
// Prevent default to avoid text selection
|
||||
e.preventDefault();
|
||||
|
||||
if (!iosVideoRef) return;
|
||||
if (decrementIntervalRef.current) clearInterval(decrementIntervalRef.current);
|
||||
|
||||
// First immediate adjustment
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
|
||||
// Setup continuous adjustment
|
||||
decrementIntervalRef.current = setInterval(() => {
|
||||
if (iosVideoRef) {
|
||||
iosVideoRef.currentTime = Math.max(0, iosVideoRef.currentTime - 0.05);
|
||||
}
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Stop continuous decrement
|
||||
const stopDecrement = () => {
|
||||
if (decrementIntervalRef.current) {
|
||||
clearInterval(decrementIntervalRef.current);
|
||||
decrementIntervalRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ios-video-player-container">
|
||||
{/* Current Time / Duration Display */}
|
||||
<div className="ios-time-display mb-2">
|
||||
<span className="text-sm">
|
||||
{formatTime(currentTime)} / {formatTime(duration)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* iOS-optimized Video Element with Native Controls */}
|
||||
<video
|
||||
ref={(ref) => setIosVideoRef(ref)}
|
||||
className="w-full rounded-md"
|
||||
src={videoUrl}
|
||||
controls
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
>
|
||||
<source src={videoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS Video Skip Controls */}
|
||||
<div className="ios-skip-controls mt-3 flex justify-center gap-4">
|
||||
<button
|
||||
onClick={jumpBackward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
-15s
|
||||
</button>
|
||||
<button
|
||||
onClick={jumpForward15}
|
||||
className="ios-control-btn flex items-center justify-center bg-purple-500 text-white py-2 px-4 rounded-md"
|
||||
>
|
||||
+15s
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* iOS Fine Control Buttons */}
|
||||
<div className="ios-fine-controls mt-2 flex justify-center gap-4">
|
||||
<button
|
||||
onMouseDown={startDecrement}
|
||||
onTouchStart={startDecrement}
|
||||
onMouseUp={stopDecrement}
|
||||
onMouseLeave={stopDecrement}
|
||||
onTouchEnd={stopDecrement}
|
||||
onTouchCancel={stopDecrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
-50ms
|
||||
</button>
|
||||
<button
|
||||
onMouseDown={startIncrement}
|
||||
onTouchStart={startIncrement}
|
||||
onMouseUp={stopIncrement}
|
||||
onMouseLeave={stopIncrement}
|
||||
onTouchEnd={stopIncrement}
|
||||
onTouchCancel={stopIncrement}
|
||||
className="ios-control-btn flex items-center justify-center bg-indigo-600 text-white py-2 px-4 rounded-md no-select"
|
||||
>
|
||||
+50ms
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="ios-note mt-2 text-xs text-gray-500">
|
||||
<p>This player uses native iOS controls for better compatibility with iOS devices.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IOSVideoPlayer;
|
||||
74
frontend-tools/video-editor/client/src/components/Modal.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React, { useEffect } from "react";
|
||||
import "../styles/Modal.css";
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children, actions }) => {
|
||||
// Close modal when Escape key is pressed
|
||||
useEffect(() => {
|
||||
const handleEscapeKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && isOpen) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleEscapeKey);
|
||||
|
||||
// Disable body scrolling when modal is open
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = "hidden";
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleEscapeKey);
|
||||
document.body.style.overflow = "";
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Handle click outside the modal content to close it
|
||||
const handleClickOutside = (event: React.MouseEvent) => {
|
||||
if (event.target === event.currentTarget) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={handleClickOutside}>
|
||||
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2 className="modal-title">{title}</h2>
|
||||
<button className="modal-close-button" onClick={onClose} aria-label="Close modal">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="modal-content">{children}</div>
|
||||
|
||||
{actions && <div className="modal-actions">{actions}</div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Modal;
|
||||
@@ -0,0 +1,452 @@
|
||||
import React, { useRef, useEffect, useState } from "react";
|
||||
import { formatTime, formatDetailedTime } from "@/lib/timeUtils";
|
||||
import logger from "../lib/logger";
|
||||
import "../styles/VideoPlayer.css";
|
||||
|
||||
interface VideoPlayerProps {
|
||||
videoRef: React.RefObject<HTMLVideoElement>;
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
isPlaying: boolean;
|
||||
isMuted?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
onToggleMute?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer: React.FC<VideoPlayerProps> = ({
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
isMuted = false,
|
||||
onPlayPause,
|
||||
onSeek,
|
||||
onToggleMute
|
||||
}) => {
|
||||
const progressRef = useRef<HTMLDivElement>(null);
|
||||
const [isIOS, setIsIOS] = useState(false);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const [lastPosition, setLastPosition] = useState<number | null>(null);
|
||||
const [isDraggingProgress, setIsDraggingProgress] = useState(false);
|
||||
const isDraggingProgressRef = useRef(false);
|
||||
const [tooltipPosition, setTooltipPosition] = useState({ x: 0 });
|
||||
const [tooltipTime, setTooltipTime] = useState(0);
|
||||
|
||||
const sampleVideoUrl =
|
||||
(typeof window !== "undefined" && (window as any).MEDIA_DATA?.videoUrl) ||
|
||||
"/videos/sample-video-10m.mp4";
|
||||
|
||||
// Detect iOS device
|
||||
useEffect(() => {
|
||||
const checkIOS = () => {
|
||||
const userAgent = navigator.userAgent || navigator.vendor || (window as any).opera;
|
||||
return /iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream;
|
||||
};
|
||||
|
||||
setIsIOS(checkIOS());
|
||||
|
||||
// Check if video was previously initialized
|
||||
if (typeof window !== "undefined") {
|
||||
const wasInitialized = localStorage.getItem("video_initialized") === "true";
|
||||
setHasInitialized(wasInitialized);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Update initialized state when video plays
|
||||
useEffect(() => {
|
||||
if (isPlaying && !hasInitialized) {
|
||||
setHasInitialized(true);
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem("video_initialized", "true");
|
||||
}
|
||||
}
|
||||
}, [isPlaying, hasInitialized]);
|
||||
|
||||
// Add iOS-specific attributes to prevent fullscreen playback
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// These attributes need to be set directly on the DOM element
|
||||
// for iOS Safari to respect inline playback
|
||||
video.setAttribute("playsinline", "true");
|
||||
video.setAttribute("webkit-playsinline", "true");
|
||||
video.setAttribute("x-webkit-airplay", "allow");
|
||||
|
||||
// Store the last known good position for iOS
|
||||
const handleTimeUpdate = () => {
|
||||
if (!isDraggingProgressRef.current) {
|
||||
setLastPosition(video.currentTime);
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = video.currentTime;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle iOS-specific play/pause state
|
||||
const handlePlay = () => {
|
||||
logger.debug("Video play event fired");
|
||||
if (isIOS) {
|
||||
setHasInitialized(true);
|
||||
localStorage.setItem("video_initialized", "true");
|
||||
}
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
logger.debug("Video pause event fired");
|
||||
};
|
||||
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
};
|
||||
}, [videoRef, isIOS, isDraggingProgressRef]);
|
||||
|
||||
// Save current time to lastPosition when it changes (from external seeking)
|
||||
useEffect(() => {
|
||||
setLastPosition(currentTime);
|
||||
}, [currentTime]);
|
||||
|
||||
// Jump 10 seconds forward
|
||||
const handleForward = () => {
|
||||
const newTime = Math.min(currentTime + 10, duration);
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
// Jump 10 seconds backward
|
||||
const handleBackward = () => {
|
||||
const newTime = Math.max(currentTime - 10, 0);
|
||||
onSeek(newTime);
|
||||
setLastPosition(newTime);
|
||||
};
|
||||
|
||||
// Calculate progress percentage
|
||||
const progressPercentage = duration > 0 ? (currentTime / duration) * 100 : 0;
|
||||
|
||||
// Handle start of progress bar dragging
|
||||
const handleProgressDragStart = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
// Get initial position
|
||||
handleProgressDrag(e);
|
||||
|
||||
// Set up document-level event listeners for mouse movement and release
|
||||
const handleMouseMove = (moveEvent: MouseEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressDrag(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
// Handle progress dragging for both mouse and touch events
|
||||
const handleProgressDrag = (e: MouseEvent | React.MouseEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({ x: e.clientX });
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle touch events for progress bar
|
||||
const handleProgressTouchStart = (e: React.TouchEvent) => {
|
||||
if (!progressRef.current || !e.touches[0]) return;
|
||||
e.preventDefault();
|
||||
|
||||
setIsDraggingProgress(true);
|
||||
isDraggingProgressRef.current = true;
|
||||
|
||||
// Get initial position using touch
|
||||
handleProgressTouchMove(e);
|
||||
|
||||
// Set up document-level event listeners for touch movement and release
|
||||
const handleTouchMove = (moveEvent: TouchEvent) => {
|
||||
if (isDraggingProgressRef.current) {
|
||||
handleProgressTouchMove(moveEvent);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setIsDraggingProgress(false);
|
||||
isDraggingProgressRef.current = false;
|
||||
document.removeEventListener("touchmove", handleTouchMove);
|
||||
document.removeEventListener("touchend", handleTouchEnd);
|
||||
document.removeEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
|
||||
document.addEventListener("touchmove", handleTouchMove, { passive: false });
|
||||
document.addEventListener("touchend", handleTouchEnd);
|
||||
document.addEventListener("touchcancel", handleTouchEnd);
|
||||
};
|
||||
|
||||
// Handle touch dragging on progress bar
|
||||
const handleProgressTouchMove = (e: TouchEvent | React.TouchEvent) => {
|
||||
if (!progressRef.current) return;
|
||||
|
||||
// Get the touch coordinates
|
||||
const touch = "touches" in e ? e.touches[0] : null;
|
||||
if (!touch) return;
|
||||
|
||||
e.preventDefault(); // Prevent scrolling while dragging
|
||||
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const touchPosition = Math.max(0, Math.min(1, (touch.clientX - rect.left) / rect.width));
|
||||
const seekTime = duration * touchPosition;
|
||||
|
||||
// Update tooltip position and time
|
||||
setTooltipPosition({ x: touch.clientX });
|
||||
setTooltipTime(seekTime);
|
||||
|
||||
// Store position for iOS Safari
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
};
|
||||
|
||||
// Handle click on progress bar (for non-drag interactions)
|
||||
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
// If we're already dragging, don't handle the click
|
||||
if (isDraggingProgress) return;
|
||||
|
||||
if (progressRef.current) {
|
||||
const rect = progressRef.current.getBoundingClientRect();
|
||||
const clickPosition = (e.clientX - rect.left) / rect.width;
|
||||
const seekTime = duration * clickPosition;
|
||||
|
||||
// Store position locally for iOS Safari - critical for timeline seeking
|
||||
setLastPosition(seekTime);
|
||||
|
||||
// Also store globally for integration with other components
|
||||
if (typeof window !== "undefined") {
|
||||
(window as any).lastSeekedPosition = seekTime;
|
||||
}
|
||||
|
||||
onSeek(seekTime);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle toggling fullscreen
|
||||
const handleFullscreen = () => {
|
||||
if (videoRef.current) {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
videoRef.current.requestFullscreen();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click on video to play/pause
|
||||
const handleVideoClick = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// If the video is paused, we want to play it
|
||||
if (video.paused) {
|
||||
// For iOS Safari: Before playing, explicitly seek to the remembered position
|
||||
if (isIOS && lastPosition !== null && lastPosition > 0) {
|
||||
logger.debug("iOS: Explicitly setting position before play:", lastPosition);
|
||||
|
||||
// First, seek to the position
|
||||
video.currentTime = lastPosition;
|
||||
|
||||
// Use a small timeout to ensure seeking is complete before play
|
||||
setTimeout(() => {
|
||||
if (videoRef.current) {
|
||||
// Try to play with proper promise handling
|
||||
videoRef.current
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug(
|
||||
"iOS: Play started successfully at position:",
|
||||
videoRef.current?.currentTime
|
||||
);
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("iOS: Error playing video:", err);
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
} else {
|
||||
// Normal play (non-iOS or no remembered position)
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
logger.debug("Normal: Play started successfully");
|
||||
onPlayPause(); // Update parent state after successful play
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// If playing, pause and update state
|
||||
video.pause();
|
||||
onPlayPause();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="video-player-container">
|
||||
<video
|
||||
ref={videoRef}
|
||||
preload="auto"
|
||||
crossOrigin="anonymous"
|
||||
onClick={handleVideoClick}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
x-webkit-airplay="allow"
|
||||
controls={false}
|
||||
muted={isMuted}
|
||||
>
|
||||
<source src={sampleVideoUrl} type="video/mp4" />
|
||||
<p>Your browser doesn't support HTML5 video.</p>
|
||||
</video>
|
||||
|
||||
{/* iOS First-play indicator - only shown on first visit for iOS devices when not initialized */}
|
||||
{isIOS && !hasInitialized && !isPlaying && (
|
||||
<div className="ios-first-play-indicator">
|
||||
<div className="ios-play-message">Tap Play to initialize video controls</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Play/Pause Indicator (shows based on current state) */}
|
||||
<div className={`play-pause-indicator ${isPlaying ? "pause-icon" : "play-icon"}`}></div>
|
||||
|
||||
{/* Video Controls Overlay */}
|
||||
<div className="video-controls">
|
||||
{/* Time and Duration */}
|
||||
<div className="video-time-display">
|
||||
<span className="video-current-time">{formatTime(currentTime)}</span>
|
||||
<span className="video-duration">/ {formatTime(duration)}</span>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar with enhanced dragging */}
|
||||
<div
|
||||
ref={progressRef}
|
||||
className={`video-progress ${isDraggingProgress ? "dragging" : ""}`}
|
||||
onClick={handleProgressClick}
|
||||
onMouseDown={handleProgressDragStart}
|
||||
onTouchStart={handleProgressTouchStart}
|
||||
>
|
||||
<div className="video-progress-fill" style={{ width: `${progressPercentage}%` }}></div>
|
||||
<div className="video-scrubber" style={{ left: `${progressPercentage}%` }}></div>
|
||||
|
||||
{/* Floating time tooltip when dragging */}
|
||||
{isDraggingProgress && (
|
||||
<div
|
||||
className="video-time-tooltip"
|
||||
style={{
|
||||
left: `${tooltipPosition.x}px`,
|
||||
transform: "translateX(-50%)"
|
||||
}}
|
||||
>
|
||||
{formatDetailedTime(tooltipTime)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls - Mute and Fullscreen buttons */}
|
||||
<div className="video-controls-buttons">
|
||||
{/* Mute/Unmute Button */}
|
||||
{onToggleMute && (
|
||||
<button
|
||||
className="mute-button"
|
||||
aria-label={isMuted ? "Unmute" : "Mute"}
|
||||
onClick={onToggleMute}
|
||||
data-tooltip={isMuted ? "Unmute" : "Mute"}
|
||||
>
|
||||
{isMuted ? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="1" y1="1" x2="23" y2="23"></line>
|
||||
<path d="M9 9v3a3 3 0 0 0 5.12 2.12M15 9.34V4a3 3 0 0 0-5.94-.6"></path>
|
||||
<path d="M17 16.95A7 7 0 0 1 5 12v-2m14 0v2a7 7 0 0 1-.11 1.23"></path>
|
||||
<line x1="12" y1="19" x2="12" y2="23"></line>
|
||||
<line x1="8" y1="23" x2="16" y2="23"></line>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"></polygon>
|
||||
<path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"></path>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Fullscreen Button */}
|
||||
<button
|
||||
className="fullscreen-button"
|
||||
aria-label="Fullscreen"
|
||||
onClick={handleFullscreen}
|
||||
data-tooltip="Toggle fullscreen"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M3 4a1 1 0 011-1h4a1 1 0 010 2H6.414l2.293 2.293a1 1 0 01-1.414 1.414L5 6.414V8a1 1 0 01-2 0V4zm9 1a1 1 0 010-2h4a1 1 0 011 1v4a1 1 0 01-2 0V6.414l-2.293 2.293a1 1 0 11-1.414-1.414L13.586 5H12zm-9 7a1 1 0 012 0v1.586l2.293-2.293a1 1 0 011.414 1.414L6.414 15H8a1 1 0 010 2H4a1 1 0 01-1-1v-4zm13-1a1 1 0 011 1v4a1 1 0 01-1 1h-4a1 1 0 010-2h1.586l-2.293-2.293a1 1 0 011.414-1.414L15 13.586V12a1 1 0 011-1z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VideoPlayer;
|
||||
967
frontend-tools/video-editor/client/src/hooks/useVideoTrimmer.tsx
Normal file
@@ -0,0 +1,967 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { generateThumbnail } from "@/lib/videoUtils";
|
||||
import { formatDetailedTime } from "@/lib/timeUtils";
|
||||
import logger from "@/lib/logger";
|
||||
import type { Segment } from "@/components/ClipSegments";
|
||||
|
||||
// Represents a state of the editor for undo/redo
|
||||
interface EditorState {
|
||||
trimStart: number;
|
||||
trimEnd: number;
|
||||
splitPoints: number[];
|
||||
clipSegments: Segment[];
|
||||
action?: string;
|
||||
}
|
||||
|
||||
const useVideoTrimmer = () => {
|
||||
// Video element reference and state
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
const [duration, setDuration] = useState(0);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isMuted, setIsMuted] = useState(false);
|
||||
|
||||
// Timeline state
|
||||
const [thumbnails, setThumbnails] = useState<string[]>([]);
|
||||
const [trimStart, setTrimStart] = useState(0);
|
||||
const [trimEnd, setTrimEnd] = useState(0);
|
||||
const [splitPoints, setSplitPoints] = useState<number[]>([]);
|
||||
const [zoomLevel, setZoomLevel] = useState(1); // Start with 1x zoom level
|
||||
|
||||
// Clip segments state
|
||||
const [clipSegments, setClipSegments] = useState<Segment[]>([]);
|
||||
|
||||
// History state for undo/redo
|
||||
const [history, setHistory] = useState<EditorState[]>([]);
|
||||
const [historyPosition, setHistoryPosition] = useState(-1);
|
||||
|
||||
// Track unsaved changes
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
// State for playing segments
|
||||
const [isPlayingSegments, setIsPlayingSegments] = useState(false);
|
||||
const [currentSegmentIndex, setCurrentSegmentIndex] = useState(0);
|
||||
|
||||
// Monitor for history changes
|
||||
useEffect(() => {
|
||||
if (history.length > 0) {
|
||||
// For debugging - moved to console.debug
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug(
|
||||
`History state updated: ${history.length} entries, position: ${historyPosition}`
|
||||
);
|
||||
// Log actions in history to help debug undo/redo
|
||||
const actions = history.map(
|
||||
(state, idx) =>
|
||||
`${idx}: ${state.action || "unknown"} (segments: ${state.clipSegments.length})`
|
||||
);
|
||||
console.debug("History actions:", actions);
|
||||
}
|
||||
|
||||
// If there's at least one history entry and it wasn't a save operation, mark as having unsaved changes
|
||||
const lastAction = history[historyPosition]?.action || "";
|
||||
if (lastAction !== "save" && lastAction !== "save_copy" && lastAction !== "save_segments") {
|
||||
setHasUnsavedChanges(true);
|
||||
}
|
||||
}
|
||||
}, [history, historyPosition]);
|
||||
|
||||
// Set up page unload warning
|
||||
useEffect(() => {
|
||||
// Event handler for beforeunload
|
||||
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
|
||||
if (hasUnsavedChanges) {
|
||||
// Standard way of showing a confirmation dialog before leaving
|
||||
const message = "Your edits will get lost if you leave the page. Do you want to continue?";
|
||||
e.preventDefault();
|
||||
e.returnValue = message; // Chrome requires returnValue to be set
|
||||
return message; // For other browsers
|
||||
}
|
||||
};
|
||||
|
||||
// Add event listener
|
||||
window.addEventListener("beforeunload", handleBeforeUnload);
|
||||
|
||||
// Clean up
|
||||
return () => {
|
||||
window.removeEventListener("beforeunload", handleBeforeUnload);
|
||||
};
|
||||
}, [hasUnsavedChanges]);
|
||||
|
||||
// Initialize video event listeners
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
setTrimEnd(video.duration);
|
||||
|
||||
// Generate placeholders and create initial segment
|
||||
const initializeEditor = async () => {
|
||||
// Generate thumbnail for initial segment
|
||||
const segmentThumbnail = await generateThumbnail(video, video.duration / 2);
|
||||
|
||||
// Create an initial segment that spans the entire video
|
||||
const initialSegment: Segment = {
|
||||
id: 1,
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: video.duration,
|
||||
thumbnail: segmentThumbnail
|
||||
};
|
||||
|
||||
// Initialize history state with the full-length segment
|
||||
const initialState: EditorState = {
|
||||
trimStart: 0,
|
||||
trimEnd: video.duration,
|
||||
splitPoints: [],
|
||||
clipSegments: [initialSegment]
|
||||
};
|
||||
|
||||
setHistory([initialState]);
|
||||
setHistoryPosition(0);
|
||||
setClipSegments([initialSegment]);
|
||||
|
||||
// Generate timeline thumbnails
|
||||
const count = 6;
|
||||
const interval = video.duration / count;
|
||||
const placeholders: string[] = [];
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const time = interval * i + interval / 2;
|
||||
const thumbnail = await generateThumbnail(video, time);
|
||||
placeholders.push(thumbnail);
|
||||
}
|
||||
|
||||
setThumbnails(placeholders);
|
||||
};
|
||||
|
||||
initializeEditor();
|
||||
};
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
setIsPlaying(true);
|
||||
setVideoInitialized(true);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
setIsPlaying(false);
|
||||
};
|
||||
|
||||
const handleEnded = () => {
|
||||
setIsPlaying(false);
|
||||
video.currentTime = trimStart;
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
video.addEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.addEventListener("timeupdate", handleTimeUpdate);
|
||||
video.addEventListener("play", handlePlay);
|
||||
video.addEventListener("pause", handlePause);
|
||||
video.addEventListener("ended", handleEnded);
|
||||
|
||||
return () => {
|
||||
// Remove event listeners
|
||||
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
|
||||
video.removeEventListener("timeupdate", handleTimeUpdate);
|
||||
video.removeEventListener("play", handlePlay);
|
||||
video.removeEventListener("pause", handlePause);
|
||||
video.removeEventListener("ended", handleEnded);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Play/pause video
|
||||
const playPauseVideo = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
video.pause();
|
||||
} else {
|
||||
// iOS Safari fix: Use the last seeked position if available
|
||||
if (!isPlaying && typeof window !== "undefined" && window.lastSeekedPosition > 0) {
|
||||
// Only apply this if the video is not at the same position already
|
||||
// This avoids unnecessary seeking which might cause playback issues
|
||||
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
|
||||
video.currentTime = window.lastSeekedPosition;
|
||||
}
|
||||
}
|
||||
// If at the end of the trim range, reset to the beginning
|
||||
else if (video.currentTime >= trimEnd) {
|
||||
video.currentTime = trimStart;
|
||||
}
|
||||
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
// Play started successfully
|
||||
// Reset the last seeked position after successfully starting playback
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = 0;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error starting playback:", err);
|
||||
setIsPlaying(false); // Reset state if play failed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Seek to a specific time
|
||||
const seekVideo = (time: number) => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
// Track if the video was playing before seeking
|
||||
const wasPlaying = !video.paused;
|
||||
|
||||
// Update the video position
|
||||
video.currentTime = time;
|
||||
setCurrentTime(time);
|
||||
|
||||
// Store the position in a global state accessible to iOS Safari
|
||||
// This ensures when play is pressed later, it remembers the position
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = time;
|
||||
}
|
||||
|
||||
// Resume playback if it was playing before
|
||||
if (wasPlaying) {
|
||||
// Play immediately without delay
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true); // Update state to reflect we're playing
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error resuming playback:", err);
|
||||
setIsPlaying(false);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Save the current state to history with a debounce buffer
|
||||
// This helps prevent multiple rapid saves for small adjustments
|
||||
const saveState = (action?: string) => {
|
||||
// Deep clone to ensure state is captured correctly
|
||||
const newState: EditorState = {
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints: [...splitPoints],
|
||||
clipSegments: JSON.parse(JSON.stringify(clipSegments)), // Deep clone to avoid reference issues
|
||||
action: action || "manual_save" // Track the action that triggered this save
|
||||
};
|
||||
|
||||
// Check if state is significantly different from last saved state
|
||||
const lastState = history[historyPosition];
|
||||
|
||||
// Helper function to compare segments deeply
|
||||
const haveSegmentsChanged = () => {
|
||||
if (!lastState || lastState.clipSegments.length !== newState.clipSegments.length) {
|
||||
return true; // Different length means significant change
|
||||
}
|
||||
|
||||
// Compare each segment's start and end times
|
||||
for (let i = 0; i < newState.clipSegments.length; i++) {
|
||||
const oldSeg = lastState.clipSegments[i];
|
||||
const newSeg = newState.clipSegments[i];
|
||||
|
||||
if (!oldSeg || !newSeg) return true;
|
||||
|
||||
// Check if any time values changed by more than 0.001 seconds (1ms)
|
||||
if (
|
||||
Math.abs(oldSeg.startTime - newSeg.startTime) > 0.001 ||
|
||||
Math.abs(oldSeg.endTime - newSeg.endTime) > 0.001
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false; // No significant changes found
|
||||
};
|
||||
|
||||
const isSignificantChange =
|
||||
!lastState ||
|
||||
lastState.trimStart !== newState.trimStart ||
|
||||
lastState.trimEnd !== newState.trimEnd ||
|
||||
lastState.splitPoints.length !== newState.splitPoints.length ||
|
||||
haveSegmentsChanged();
|
||||
|
||||
// Additionally, check if there's an explicit action from a UI event
|
||||
const hasExplicitActionFlag = newState.action !== undefined;
|
||||
|
||||
// Only proceed if this is a significant change or if explicitly requested
|
||||
if (isSignificantChange || hasExplicitActionFlag) {
|
||||
// Get the current position to avoid closure issues
|
||||
const currentPosition = historyPosition;
|
||||
|
||||
// Use functional updates to ensure we're working with the latest state
|
||||
setHistory((prevHistory) => {
|
||||
// If we're not at the end of history, truncate
|
||||
if (currentPosition < prevHistory.length - 1) {
|
||||
const newHistory = prevHistory.slice(0, currentPosition + 1);
|
||||
return [...newHistory, newState];
|
||||
} else {
|
||||
// Just append to current history
|
||||
return [...prevHistory, newState];
|
||||
}
|
||||
});
|
||||
|
||||
// Update position using functional update
|
||||
setHistoryPosition((prev) => {
|
||||
const newPosition = prev + 1;
|
||||
// "Saved state to history position", newPosition)
|
||||
return newPosition;
|
||||
});
|
||||
} else {
|
||||
// logger.debug("Skipped non-significant state save");
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for trim handle update events
|
||||
useEffect(() => {
|
||||
const handleTrimUpdate = (e: CustomEvent) => {
|
||||
if (e.detail) {
|
||||
const { time, isStart, recordHistory, action } = e.detail;
|
||||
|
||||
if (isStart) {
|
||||
setTrimStart(time);
|
||||
} else {
|
||||
setTrimEnd(time);
|
||||
}
|
||||
|
||||
// Only record in history if explicitly requested
|
||||
if (recordHistory) {
|
||||
// Use a small timeout to ensure the state is updated
|
||||
setTimeout(() => {
|
||||
saveState(action || (isStart ? "adjust_trim_start" : "adjust_trim_end"));
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("update-trim", handleTrimUpdate as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("update-trim", handleTrimUpdate as EventListener);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for segment update events and split-at-time events
|
||||
useEffect(() => {
|
||||
const handleUpdateSegments = (e: CustomEvent) => {
|
||||
if (e.detail && e.detail.segments) {
|
||||
// Check if this is a significant change that should be recorded in history
|
||||
// Default to true to ensure all segment changes are recorded
|
||||
const isSignificantChange = e.detail.recordHistory !== false;
|
||||
// Get the action type if provided
|
||||
const actionType = e.detail.action || "update_segments";
|
||||
|
||||
// Log the update details
|
||||
logger.debug(
|
||||
`Updating segments with action: ${actionType}, recordHistory: ${isSignificantChange ? "true" : "false"}`
|
||||
);
|
||||
|
||||
// Update segment state immediately for UI feedback
|
||||
setClipSegments(e.detail.segments);
|
||||
|
||||
// Always save state to history for non-intermediate actions
|
||||
if (isSignificantChange) {
|
||||
// A slight delay helps avoid race conditions but we need to
|
||||
// ensure we capture the state properly
|
||||
setTimeout(() => {
|
||||
// Deep clone to ensure state is captured correctly
|
||||
const segmentsClone = JSON.parse(JSON.stringify(e.detail.segments));
|
||||
|
||||
// Create a complete state snapshot
|
||||
const stateWithAction: EditorState = {
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints: [...splitPoints],
|
||||
clipSegments: segmentsClone,
|
||||
action: actionType // Store the action type in the state
|
||||
};
|
||||
|
||||
// Get the current history position to ensure we're using the latest value
|
||||
const currentHistoryPosition = historyPosition;
|
||||
|
||||
// Update history with the functional pattern to avoid stale closure issues
|
||||
setHistory((prevHistory) => {
|
||||
// If we're not at the end of the history, truncate
|
||||
if (currentHistoryPosition < prevHistory.length - 1) {
|
||||
const newHistory = prevHistory.slice(0, currentHistoryPosition + 1);
|
||||
return [...newHistory, stateWithAction];
|
||||
} else {
|
||||
// Just append to current history
|
||||
return [...prevHistory, stateWithAction];
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure the historyPosition is updated to the correct position
|
||||
setHistoryPosition((prev) => {
|
||||
const newPosition = prev + 1;
|
||||
logger.debug(
|
||||
`Saved state with action: ${actionType} to history position ${newPosition}`
|
||||
);
|
||||
return newPosition;
|
||||
});
|
||||
}, 20); // Slightly increased delay to ensure state updates are complete
|
||||
} else {
|
||||
logger.debug(
|
||||
`Skipped saving state to history for action: ${actionType} (recordHistory=false)`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleSplitSegment = async (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
if (
|
||||
customEvent.detail &&
|
||||
typeof customEvent.detail.time === "number" &&
|
||||
typeof customEvent.detail.segmentId === "number"
|
||||
) {
|
||||
// Get the time and segment ID from the event
|
||||
const timeToSplit = customEvent.detail.time;
|
||||
const segmentId = customEvent.detail.segmentId;
|
||||
|
||||
// Move the current time to the split position
|
||||
seekVideo(timeToSplit);
|
||||
|
||||
// Find the segment to split
|
||||
const segmentToSplit = clipSegments.find((seg) => seg.id === segmentId);
|
||||
if (!segmentToSplit) return;
|
||||
|
||||
// Make sure the split point is within the segment
|
||||
if (timeToSplit <= segmentToSplit.startTime || timeToSplit >= segmentToSplit.endTime) {
|
||||
return; // Can't split outside segment boundaries
|
||||
}
|
||||
|
||||
// Create two new segments from the split
|
||||
const newSegments = [...clipSegments];
|
||||
|
||||
// Remove the original segment
|
||||
const segmentIndex = newSegments.findIndex((seg) => seg.id === segmentId);
|
||||
if (segmentIndex === -1) return;
|
||||
|
||||
newSegments.splice(segmentIndex, 1);
|
||||
|
||||
// Create first half of the split segment - no thumbnail needed
|
||||
const firstHalf: Segment = {
|
||||
id: Date.now(),
|
||||
name: `${segmentToSplit.name}-A`,
|
||||
startTime: segmentToSplit.startTime,
|
||||
endTime: timeToSplit,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Create second half of the split segment - no thumbnail needed
|
||||
const secondHalf: Segment = {
|
||||
id: Date.now() + 1,
|
||||
name: `${segmentToSplit.name}-B`,
|
||||
startTime: timeToSplit,
|
||||
endTime: segmentToSplit.endTime,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Add the new segments
|
||||
newSegments.push(firstHalf, secondHalf);
|
||||
|
||||
// Sort segments by start time
|
||||
newSegments.sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Update state
|
||||
setClipSegments(newSegments);
|
||||
saveState("split_segment");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete segment event
|
||||
const handleDeleteSegment = async (e: Event) => {
|
||||
const customEvent = e as CustomEvent;
|
||||
if (customEvent.detail && typeof customEvent.detail.segmentId === "number") {
|
||||
const segmentId = customEvent.detail.segmentId;
|
||||
|
||||
// Find and remove the segment
|
||||
const newSegments = clipSegments.filter((segment) => segment.id !== segmentId);
|
||||
|
||||
if (newSegments.length !== clipSegments.length) {
|
||||
// If all segments are deleted, create a new full video segment
|
||||
if (newSegments.length === 0 && videoRef.current) {
|
||||
// Create a new default segment that spans the entire video
|
||||
// No need to generate a thumbnail - we'll use dynamic colors
|
||||
const defaultSegment: Segment = {
|
||||
id: Date.now(),
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: videoRef.current.duration,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
// Reset the trim points as well
|
||||
setTrimStart(0);
|
||||
setTrimEnd(videoRef.current.duration);
|
||||
setSplitPoints([]);
|
||||
setClipSegments([defaultSegment]);
|
||||
} else {
|
||||
// Just update the segments normally
|
||||
setClipSegments(newSegments);
|
||||
}
|
||||
saveState("delete_segment");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("update-segments", handleUpdateSegments as EventListener);
|
||||
document.addEventListener("split-segment", handleSplitSegment as EventListener);
|
||||
document.addEventListener("delete-segment", handleDeleteSegment as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("update-segments", handleUpdateSegments as EventListener);
|
||||
document.removeEventListener("split-segment", handleSplitSegment as EventListener);
|
||||
document.removeEventListener("delete-segment", handleDeleteSegment as EventListener);
|
||||
};
|
||||
}, [clipSegments, duration]);
|
||||
|
||||
// Handle trim start change
|
||||
const handleTrimStartChange = (time: number) => {
|
||||
setTrimStart(time);
|
||||
saveState("adjust_trim_start");
|
||||
};
|
||||
|
||||
// Handle trim end change
|
||||
const handleTrimEndChange = (time: number) => {
|
||||
setTrimEnd(time);
|
||||
saveState("adjust_trim_end");
|
||||
};
|
||||
|
||||
// Handle split at current position
|
||||
const handleSplit = async () => {
|
||||
if (!videoRef.current) return;
|
||||
|
||||
// Add current time to split points if not already present
|
||||
if (!splitPoints.includes(currentTime)) {
|
||||
const newSplitPoints = [...splitPoints, currentTime].sort((a, b) => a - b);
|
||||
setSplitPoints(newSplitPoints);
|
||||
|
||||
// Generate segments based on split points
|
||||
const newSegments: Segment[] = [];
|
||||
let startTime = 0;
|
||||
|
||||
for (let i = 0; i <= newSplitPoints.length; i++) {
|
||||
const endTime = i < newSplitPoints.length ? newSplitPoints[i] : duration;
|
||||
|
||||
if (startTime < endTime) {
|
||||
// No need to generate thumbnails - we'll use dynamic colors
|
||||
newSegments.push({
|
||||
id: Date.now() + i,
|
||||
name: `Segment ${i + 1}`,
|
||||
startTime,
|
||||
endTime,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
});
|
||||
|
||||
startTime = endTime;
|
||||
}
|
||||
}
|
||||
|
||||
setClipSegments(newSegments);
|
||||
saveState("create_split_points");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle reset of all edits
|
||||
const handleReset = async () => {
|
||||
setTrimStart(0);
|
||||
setTrimEnd(duration);
|
||||
setSplitPoints([]);
|
||||
|
||||
// Create a new default segment that spans the entire video
|
||||
if (!videoRef.current) return;
|
||||
|
||||
// No need to generate thumbnails - we'll use dynamic colors
|
||||
const defaultSegment: Segment = {
|
||||
id: Date.now(),
|
||||
name: "segment",
|
||||
startTime: 0,
|
||||
endTime: duration,
|
||||
thumbnail: "" // Empty placeholder - we'll use dynamic colors instead
|
||||
};
|
||||
|
||||
setClipSegments([defaultSegment]);
|
||||
saveState("reset_all");
|
||||
};
|
||||
|
||||
// Handle undo
|
||||
const handleUndo = () => {
|
||||
if (historyPosition > 0) {
|
||||
const previousState = history[historyPosition - 1];
|
||||
logger.debug(
|
||||
`** UNDO ** to position ${historyPosition - 1}, action: ${previousState.action}, segments: ${previousState.clipSegments.length}`
|
||||
);
|
||||
|
||||
// Log segment details to help debug
|
||||
logger.debug(
|
||||
"Segment details after undo:",
|
||||
previousState.clipSegments.map(
|
||||
(seg) =>
|
||||
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
|
||||
)
|
||||
);
|
||||
|
||||
// Apply the previous state with deep cloning to avoid reference issues
|
||||
setTrimStart(previousState.trimStart);
|
||||
setTrimEnd(previousState.trimEnd);
|
||||
setSplitPoints([...previousState.splitPoints]);
|
||||
setClipSegments(JSON.parse(JSON.stringify(previousState.clipSegments)));
|
||||
setHistoryPosition(historyPosition - 1);
|
||||
} else {
|
||||
logger.debug("Cannot undo: at earliest history position");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle redo
|
||||
const handleRedo = () => {
|
||||
if (historyPosition < history.length - 1) {
|
||||
const nextState = history[historyPosition + 1];
|
||||
logger.debug(
|
||||
`** REDO ** to position ${historyPosition + 1}, action: ${nextState.action}, segments: ${nextState.clipSegments.length}`
|
||||
);
|
||||
|
||||
// Log segment details to help debug
|
||||
logger.debug(
|
||||
"Segment details after redo:",
|
||||
nextState.clipSegments.map(
|
||||
(seg) =>
|
||||
`ID: ${seg.id}, Time: ${formatDetailedTime(seg.startTime)} - ${formatDetailedTime(seg.endTime)}`
|
||||
)
|
||||
);
|
||||
|
||||
// Apply the next state with deep cloning to avoid reference issues
|
||||
setTrimStart(nextState.trimStart);
|
||||
setTrimEnd(nextState.trimEnd);
|
||||
setSplitPoints([...nextState.splitPoints]);
|
||||
setClipSegments(JSON.parse(JSON.stringify(nextState.clipSegments)));
|
||||
setHistoryPosition(historyPosition + 1);
|
||||
} else {
|
||||
logger.debug("Cannot redo: at latest history position");
|
||||
}
|
||||
};
|
||||
|
||||
// Handle zoom level change
|
||||
const handleZoomChange = (level: number) => {
|
||||
setZoomLevel(level);
|
||||
};
|
||||
|
||||
// Handle play/pause of the full video
|
||||
const handlePlay = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
if (isPlaying) {
|
||||
// Pause the video
|
||||
video.pause();
|
||||
setIsPlaying(false);
|
||||
} else {
|
||||
// iOS Safari fix: Check for lastSeekedPosition
|
||||
if (typeof window !== "undefined" && window.lastSeekedPosition > 0) {
|
||||
// Only seek if the position is significantly different
|
||||
if (Math.abs(video.currentTime - window.lastSeekedPosition) > 0.1) {
|
||||
console.log("handlePlay: Using lastSeekedPosition", window.lastSeekedPosition);
|
||||
video.currentTime = window.lastSeekedPosition;
|
||||
}
|
||||
}
|
||||
|
||||
// Play the video from current position with proper promise handling
|
||||
video
|
||||
.play()
|
||||
.then(() => {
|
||||
setIsPlaying(true);
|
||||
// Reset lastSeekedPosition after successful play
|
||||
if (typeof window !== "undefined") {
|
||||
window.lastSeekedPosition = 0;
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error playing video:", err);
|
||||
setIsPlaying(false); // Reset state if play failed
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Toggle mute state
|
||||
const toggleMute = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
|
||||
video.muted = !video.muted;
|
||||
setIsMuted(!isMuted);
|
||||
};
|
||||
|
||||
// Handle save action
|
||||
const handleSave = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving
|
||||
const saveData = {
|
||||
type: "save",
|
||||
segments: sortedSegments.map((segment) => ({
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Saving data:", saveData);
|
||||
}
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Changes saved - reset unsaved changes flag");
|
||||
}
|
||||
|
||||
// Save to history with special "save" action to mark saved state
|
||||
saveState("save");
|
||||
|
||||
// In a real implementation, this would make a POST request to save the data
|
||||
// logger.debug("Save data:", saveData);
|
||||
};
|
||||
|
||||
// Handle save a copy action
|
||||
const handleSaveACopy = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving as a copy
|
||||
const saveData = {
|
||||
type: "save_as_a_copy",
|
||||
segments: sortedSegments.map((segment) => ({
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Saving data as copy:", saveData);
|
||||
}
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Changes saved as copy - reset unsaved changes flag");
|
||||
}
|
||||
|
||||
// Save to history with special "save_copy" action to mark saved state
|
||||
saveState("save_copy");
|
||||
};
|
||||
|
||||
// Handle save segments individually action
|
||||
const handleSaveSegments = () => {
|
||||
// Sort segments chronologically by start time before saving
|
||||
const sortedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Create the JSON data for saving individual segments
|
||||
const saveData = {
|
||||
type: "save_segments",
|
||||
segments: sortedSegments.map((segment) => ({
|
||||
name: segment.name,
|
||||
startTime: formatDetailedTime(segment.startTime),
|
||||
endTime: formatDetailedTime(segment.endTime)
|
||||
}))
|
||||
};
|
||||
|
||||
// Display JSON in alert (for demonstration purposes)
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug("Saving data as segments:", saveData);
|
||||
}
|
||||
|
||||
// Mark as saved - no unsaved changes
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Debug message
|
||||
logger.debug("All segments saved individually - reset unsaved changes flag");
|
||||
|
||||
// Save to history with special "save_segments" action to mark saved state
|
||||
saveState("save_segments");
|
||||
};
|
||||
|
||||
// Handle seeking with mobile check
|
||||
const handleMobileSafeSeek = (time: number) => {
|
||||
// Only allow seeking if not on mobile or if video has been played
|
||||
if (!isMobile || videoInitialized) {
|
||||
seekVideo(time);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if device is mobile
|
||||
const isMobile =
|
||||
typeof window !== "undefined" &&
|
||||
/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile|mobile|CriOS/i.test(
|
||||
navigator.userAgent
|
||||
);
|
||||
|
||||
// Add videoInitialized state
|
||||
const [videoInitialized, setVideoInitialized] = useState(false);
|
||||
|
||||
// Effect to handle segments playback
|
||||
useEffect(() => {
|
||||
if (!isPlayingSegments || !videoRef.current) return;
|
||||
|
||||
const video = videoRef.current;
|
||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
const handleSegmentsPlayback = () => {
|
||||
const currentSegment = orderedSegments[currentSegmentIndex];
|
||||
if (!currentSegment) return;
|
||||
|
||||
const currentTime = video.currentTime;
|
||||
|
||||
// If we're before the current segment's start, jump to it
|
||||
if (currentTime < currentSegment.startTime) {
|
||||
video.currentTime = currentSegment.startTime;
|
||||
return;
|
||||
}
|
||||
|
||||
// If we've reached the end of the current segment
|
||||
if (currentTime >= currentSegment.endTime - 0.01) {
|
||||
if (currentSegmentIndex < orderedSegments.length - 1) {
|
||||
// Move to next segment
|
||||
const nextSegment = orderedSegments[currentSegmentIndex + 1];
|
||||
video.currentTime = nextSegment.startTime;
|
||||
setCurrentSegmentIndex(currentSegmentIndex + 1);
|
||||
|
||||
// If video is somehow paused, ensure it keeps playing
|
||||
if (video.paused) {
|
||||
logger.debug("Ensuring playback continues to next segment");
|
||||
video.play().catch((err) => {
|
||||
console.error("Error continuing segment playback:", err);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// End of all segments - only pause when we reach the very end
|
||||
video.pause();
|
||||
setIsPlayingSegments(false);
|
||||
setCurrentSegmentIndex(0);
|
||||
video.removeEventListener("timeupdate", handleSegmentsPlayback);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener("timeupdate", handleSegmentsPlayback);
|
||||
|
||||
// Start playing if not already playing
|
||||
if (video.paused && orderedSegments.length > 0) {
|
||||
video.currentTime = orderedSegments[0].startTime;
|
||||
video.play().catch(console.error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
video.removeEventListener("timeupdate", handleSegmentsPlayback);
|
||||
};
|
||||
}, [isPlayingSegments, currentSegmentIndex, clipSegments]);
|
||||
|
||||
// Effect to handle manual segment index updates during segments playback
|
||||
useEffect(() => {
|
||||
const handleSegmentIndexUpdate = (event: CustomEvent) => {
|
||||
const { segmentIndex } = event.detail;
|
||||
if (isPlayingSegments && segmentIndex !== currentSegmentIndex) {
|
||||
logger.debug(
|
||||
`Updating current segment index from ${currentSegmentIndex} to ${segmentIndex}`
|
||||
);
|
||||
setCurrentSegmentIndex(segmentIndex);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("update-segment-index", handleSegmentIndexUpdate as EventListener);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener(
|
||||
"update-segment-index",
|
||||
handleSegmentIndexUpdate as EventListener
|
||||
);
|
||||
};
|
||||
}, [isPlayingSegments, currentSegmentIndex]);
|
||||
|
||||
// Handle play segments
|
||||
const handlePlaySegments = () => {
|
||||
const video = videoRef.current;
|
||||
if (!video || clipSegments.length === 0) return;
|
||||
|
||||
if (isPlayingSegments) {
|
||||
// Stop segments playback
|
||||
video.pause();
|
||||
setIsPlayingSegments(false);
|
||||
setCurrentSegmentIndex(0);
|
||||
} else {
|
||||
// Start segments playback
|
||||
setIsPlayingSegments(true);
|
||||
setCurrentSegmentIndex(0);
|
||||
|
||||
// Start segments playback
|
||||
|
||||
// Sort segments by start time
|
||||
const orderedSegments = [...clipSegments].sort((a, b) => a.startTime - b.startTime);
|
||||
|
||||
// Start from the first segment
|
||||
video.currentTime = orderedSegments[0].startTime;
|
||||
|
||||
// Start playback with proper error handling
|
||||
video.play().catch((err) => {
|
||||
console.error("Error starting segments playback:", err);
|
||||
setIsPlayingSegments(false);
|
||||
});
|
||||
|
||||
logger.debug("Starting playback of all segments continuously");
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
videoRef,
|
||||
currentTime,
|
||||
duration,
|
||||
isPlaying,
|
||||
setIsPlaying,
|
||||
isMuted,
|
||||
isPlayingSegments,
|
||||
thumbnails,
|
||||
trimStart,
|
||||
trimEnd,
|
||||
splitPoints,
|
||||
zoomLevel,
|
||||
clipSegments,
|
||||
hasUnsavedChanges,
|
||||
historyPosition,
|
||||
history,
|
||||
handleTrimStartChange,
|
||||
handleTrimEndChange,
|
||||
handleZoomChange,
|
||||
handleMobileSafeSeek,
|
||||
handleSplit,
|
||||
handleReset,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
handlePlaySegments,
|
||||
toggleMute,
|
||||
handleSave,
|
||||
handleSaveACopy,
|
||||
handleSaveSegments,
|
||||
isMobile,
|
||||
videoInitialized,
|
||||
setVideoInitialized
|
||||
};
|
||||
};
|
||||
|
||||
export default useVideoTrimmer;
|
||||
805
frontend-tools/video-editor/client/src/index.css
Normal file
@@ -0,0 +1,805 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
: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%; /* Changed from red (0) to orange (30) */
|
||||
--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: 0.5rem;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
}
|
||||
|
||||
/* Video Player Styles */
|
||||
.video-player {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
background-color: #000;
|
||||
overflow: hidden;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.video-controls {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 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: rgba(255, 255, 255, 0.3);
|
||||
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: white;
|
||||
border-radius: 50%;
|
||||
top: -4px;
|
||||
}
|
||||
|
||||
/* Play/Pause indicator for video player */
|
||||
.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: rgba(0, 0, 0, 0.5);
|
||||
z-index: 20;
|
||||
opacity: 0;
|
||||
transition: opacity 0.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");
|
||||
}
|
||||
|
||||
/* Only show play/pause indicator on hover */
|
||||
.video-player-container:hover .play-pause-indicator {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Timeline Styles */
|
||||
.timeline-scroll-container {
|
||||
height: 6rem;
|
||||
border-radius: 0.375rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
margin-bottom: 0.75rem;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
position: relative;
|
||||
background-color: #eee; /* Very light gray background */
|
||||
height: 6rem;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.timeline-marker {
|
||||
position: absolute;
|
||||
top: -10px;
|
||||
height: calc(100% + 10px);
|
||||
width: 2px;
|
||||
background-color: red;
|
||||
z-index: 100; /* Highest z-index to stay on top of everything */
|
||||
pointer-events: none; /* Allow clicks to pass through to segments underneath */
|
||||
box-shadow: 0 0 4px rgba(255, 0, 0, 0.5); /* Add subtle glow effect */
|
||||
}
|
||||
|
||||
.trim-line-marker {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.trim-handle {
|
||||
width: 8px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
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, 0.1);
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
.split-point {
|
||||
position: absolute;
|
||||
width: 2px;
|
||||
background-color: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
/* Clip Segment styles */
|
||||
.clip-segment {
|
||||
position: absolute;
|
||||
height: 95%;
|
||||
top: 0;
|
||||
border-radius: 4px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-blend-mode: soft-light;
|
||||
/* Border is now set in the color-specific rules */
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
transition:
|
||||
box-shadow 0.2s,
|
||||
transform 0.1s;
|
||||
/* Original z-index for stacking order based on segment ID */
|
||||
z-index: 15;
|
||||
}
|
||||
|
||||
/* No background colors for segments, just borders with 2-color scheme */
|
||||
.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, 0.9); /* Blue border */
|
||||
}
|
||||
.clip-segment:nth-child(even),
|
||||
.segment-color-2,
|
||||
.segment-color-4,
|
||||
.segment-color-6,
|
||||
.segment-color-8 {
|
||||
background-color: transparent;
|
||||
border: 2px solid rgba(108, 117, 125, 0.9); /* Gray border */
|
||||
}
|
||||
|
||||
.clip-segment:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(-1px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.clip-segment:active {
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.clip-segment.selected {
|
||||
border-width: 3px; /* Make border thicker instead of changing color */
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
z-index: 25;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
.clip-segment-info {
|
||||
background-color: rgba(226, 230, 234, 0.9); /* Light gray background */
|
||||
color: #000000; /* Pure black text */
|
||||
padding: 6px 8px;
|
||||
font-size: 0.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: bold;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-time {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.clip-segment-duration {
|
||||
font-size: 0.65rem;
|
||||
color: #000000; /* Pure black text */
|
||||
background: rgba(179, 217, 255, 0.4); /* Light blue background */
|
||||
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: rgba(108, 117, 125, 0.9); /* Secondary gray color */
|
||||
cursor: ew-resize;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.clip-segment-handle::after {
|
||||
content: "↔";
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
text-shadow: 0 0 2px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.clip-segment-handle.left {
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle.right {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
.clip-segment-handle:hover {
|
||||
background-color: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
width: 10px;
|
||||
}
|
||||
|
||||
/* Zoom Slider */
|
||||
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: rgba(0, 123, 255, 0.9); /* Primary blue color */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Tooltip styles */
|
||||
[data-tooltip] {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
margin-bottom: 8px;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
white-space: nowrap;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
[data-tooltip]::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
border-width: 5px;
|
||||
border-style: solid;
|
||||
border-color: rgba(0, 0, 0, 0.8) transparent transparent transparent;
|
||||
margin-bottom: 0px;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity 0.2s,
|
||||
visibility 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
[data-tooltip]:hover::before,
|
||||
[data-tooltip]:hover::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Hide button tooltips (simple hover labels) on touch devices */
|
||||
@media (pointer: coarse) {
|
||||
[data-tooltip]::before,
|
||||
[data-tooltip]::after {
|
||||
display: none !important;
|
||||
content: none !important;
|
||||
opacity: 0 !important;
|
||||
visibility: hidden !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Fix for buttons with disabled state */
|
||||
button[disabled][data-tooltip]::before,
|
||||
button[disabled][data-tooltip]::after {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Custom tooltip for action buttons - completely different approach */
|
||||
.tooltip-action-btn {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before,
|
||||
.tooltip-action-btn[data-tooltip]::after {
|
||||
/* Reset standard tooltip styles first */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.tooltip-action-btn[data-tooltip]::before {
|
||||
content: attr(data-tooltip);
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
|
||||
/* Position below the button */
|
||||
bottom: -35px;
|
||||
left: 50%;
|
||||
transform: translateX(-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, 0.8) transparent;
|
||||
|
||||
/* Position the arrow */
|
||||
bottom: -15px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
/* Only show tooltips on devices with mouse hover capability */
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.tooltip-action-btn:hover[data-tooltip]::before,
|
||||
.tooltip-action-btn:hover[data-tooltip]::after {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure tooltip container has proper space */
|
||||
|
||||
/* Segment tooltip styles */
|
||||
.segment-tooltip {
|
||||
background-color: rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
color: #000000; /* Pure black text */
|
||||
border-radius: 4px;
|
||||
padding: 6px; /* Regular padding now that we have custom tooltips */
|
||||
min-width: 140px; /* Increased width to accommodate the new delete button */
|
||||
z-index: 1000; /* Increased z-index */
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.segment-tooltip::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 6px solid transparent;
|
||||
border-right: 6px solid transparent;
|
||||
border-top: 6px solid rgba(179, 217, 255, 0.95); /* Light blue color */
|
||||
}
|
||||
|
||||
.tooltip-time {
|
||||
font-size: 0.85rem;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.tooltip-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 5px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tooltip-action-btn {
|
||||
background-color: rgba(0, 123, 255, 0.2); /* Light blue background */
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
transition: background-color 0.2s;
|
||||
min-width: 20px !important;
|
||||
}
|
||||
|
||||
.tooltip-action-btn:hover {
|
||||
background-color: rgba(0, 123, 255, 0.4); /* Slightly darker on hover */
|
||||
}
|
||||
|
||||
.tooltip-action-btn svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
/* Adjust for the hand icons specifically */
|
||||
.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 styling */
|
||||
.empty-space-tooltip {
|
||||
background-color: white;
|
||||
border-radius: 6px;
|
||||
box-shadow: 0px 2px 8px rgba(0, 0, 0, 0.15);
|
||||
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: translateX(-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: 0.8rem;
|
||||
white-space: nowrap;
|
||||
color: #000000; /* Pure black text */
|
||||
}
|
||||
|
||||
.icon-new-segment {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* Zoom dropdown styling */
|
||||
.zoom-dropdown-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.zoom-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.zoom-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
.zoom-dropdown {
|
||||
background-color: white;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.15);
|
||||
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: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.zoom-option.selected {
|
||||
background-color: rgba(0, 123, 255, 0.2);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Save buttons styling */
|
||||
.save-button,
|
||||
.save-copy-button,
|
||||
.save-segments-button {
|
||||
background-color: rgba(0, 123, 255, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.save-button:hover,
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(0, 123, 255, 1);
|
||||
}
|
||||
|
||||
.save-copy-button {
|
||||
background-color: rgba(108, 117, 125, 0.8);
|
||||
}
|
||||
|
||||
.save-copy-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Time navigation input styling */
|
||||
.time-nav-label {
|
||||
font-weight: 500;
|
||||
font-size: 0.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: rgba(108, 117, 125, 0.8);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.time-button:hover {
|
||||
background-color: rgba(108, 117, 125, 1);
|
||||
}
|
||||
|
||||
/* Timeline navigation and zoom controls responsiveness */
|
||||
.timeline-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap; /* Allow wrapping on smaller screens */
|
||||
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 queries for responsive design */
|
||||
@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;
|
||||
text-align: center;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* Timeline header styling */
|
||||
.timeline-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
margin-bottom: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-weight: bold;
|
||||
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;
|
||||
}
|
||||
}
|
||||
31
frontend-tools/video-editor/client/src/lib/logger.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* A consistent logger utility that only outputs debug messages in development
|
||||
* but always shows errors, warnings, and info messages.
|
||||
*/
|
||||
const logger = {
|
||||
/**
|
||||
* Logs debug messages only in development environment
|
||||
*/
|
||||
debug: (...args: any[]) => {
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.debug(...args);
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Always logs error messages
|
||||
*/
|
||||
error: (...args: any[]) => console.error(...args),
|
||||
|
||||
/**
|
||||
* Always logs warning messages
|
||||
*/
|
||||
warn: (...args: any[]) => console.warn(...args),
|
||||
|
||||
/**
|
||||
* Always logs info messages
|
||||
*/
|
||||
info: (...args: any[]) => console.info(...args)
|
||||
};
|
||||
|
||||
export default logger;
|
||||
55
frontend-tools/video-editor/client/src/lib/queryClient.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { QueryClient, QueryFunction } from "@tanstack/react-query";
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function apiRequest(
|
||||
method: string,
|
||||
url: string,
|
||||
data?: unknown | undefined
|
||||
): Promise<Response> {
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: data ? { "Content-Type": "application/json" } : {},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
type UnauthorizedBehavior = "returnNull" | "throw";
|
||||
export const getQueryFn: <T>(options: { on401: UnauthorizedBehavior }) => QueryFunction<T> =
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetch(queryKey[0] as string, {
|
||||
credentials: "include"
|
||||
});
|
||||
|
||||
if (unauthorizedBehavior === "returnNull" && res.status === 401) {
|
||||
return null;
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
queryFn: getQueryFn({ on401: "throw" }),
|
||||
refetchInterval: false,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: Infinity,
|
||||
retry: false
|
||||
},
|
||||
mutations: {
|
||||
retry: false
|
||||
}
|
||||
}
|
||||
});
|
||||
34
frontend-tools/video-editor/client/src/lib/timeUtils.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Format seconds to HH:MM:SS.mmm format with millisecond precision
|
||||
*/
|
||||
export const formatDetailedTime = (seconds: number): string => {
|
||||
if (isNaN(seconds)) return "00:00:00.000";
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = Math.floor(seconds % 60);
|
||||
const milliseconds = Math.round((seconds % 1) * 1000);
|
||||
|
||||
const formattedHours = String(hours).padStart(2, "0");
|
||||
const formattedMinutes = String(minutes).padStart(2, "0");
|
||||
const formattedSeconds = String(remainingSeconds).padStart(2, "0");
|
||||
const formattedMilliseconds = String(milliseconds).padStart(3, "0");
|
||||
|
||||
return `${formattedHours}:${formattedMinutes}:${formattedSeconds}.${formattedMilliseconds}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to MM:SS format - now uses the detailed format with hours and milliseconds
|
||||
*/
|
||||
export const formatTime = (seconds: number): string => {
|
||||
// Use the detailed format instead of the old MM:SS format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||
|
||||
/**
|
||||
* Format seconds to HH:MM:SS format - now uses the detailed format with milliseconds
|
||||
*/
|
||||
export const formatLongTime = (seconds: number): string => {
|
||||
// Use the detailed format
|
||||
return formatDetailedTime(seconds);
|
||||
};
|
||||