Add per-task-type submission configuration with client-side movie spec checks
Coordinators can configure, per task type, accepted file extensions, a
naming convention, and (for video files) required resolution/format/
codec/frame rate. All checking happens entirely client-side in the
browser before upload - the backend only stores and validates the
configuration itself, never inspects submitted files.
- backend: submission_config_by_task_type JSON column on Project,
schemas/submission_config.py for validation, GET/PUT
/projects/{id}/submission-config endpoints. Added .mxf/.ma/.usd(a/c)
to the supported file formats.
- frontend: new Submissions tab in Project Settings
(SubmissionConfigManager.vue) to configure rules per task type.
TaskSubmissions.vue enforces them before upload: extension/naming
checks via regex, and real video resolution/codec/frame rate
detection via mediainfo.js (parses container metadata directly,
so it works for formats browsers can't play natively like MXF,
ProRes, or DNxHD).
This commit is contained in:
@@ -0,0 +1,85 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to add the submission_config_by_task_type column to the projects table.
|
||||
|
||||
Usage:
|
||||
python migrate_project_submission_config.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def get_database_path():
|
||||
"""Get the database path, trying multiple possible locations."""
|
||||
possible_paths = [
|
||||
"vfx_project_management.db", # Primary database
|
||||
"database.db",
|
||||
"../vfx_project_management.db"
|
||||
]
|
||||
|
||||
for path in possible_paths:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
|
||||
return "vfx_project_management.db"
|
||||
|
||||
|
||||
def check_column_exists(cursor, table_name, column_name):
|
||||
"""Check if a column exists in a table."""
|
||||
cursor.execute(f"PRAGMA table_info({table_name})")
|
||||
columns = [column[1] for column in cursor.fetchall()]
|
||||
return column_name in columns
|
||||
|
||||
|
||||
def migrate_database():
|
||||
"""Add submission_config_by_task_type column to the projects table."""
|
||||
db_path = get_database_path()
|
||||
print(f"Using database: {db_path}")
|
||||
|
||||
conn = None
|
||||
try:
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='projects'")
|
||||
if not cursor.fetchone():
|
||||
print("Projects table not found. Creating new database schema...")
|
||||
conn.close()
|
||||
return
|
||||
|
||||
if check_column_exists(cursor, "projects", "submission_config_by_task_type"):
|
||||
print("Column submission_config_by_task_type already exists, skipping...")
|
||||
else:
|
||||
print("Adding column: submission_config_by_task_type")
|
||||
cursor.execute("ALTER TABLE projects ADD COLUMN submission_config_by_task_type JSON")
|
||||
|
||||
conn.commit()
|
||||
|
||||
cursor.execute("SELECT COUNT(*) FROM projects")
|
||||
project_count = cursor.fetchone()[0]
|
||||
print(f"Migration completed successfully! {project_count} projects unaffected (column defaults to NULL).")
|
||||
|
||||
except sqlite3.Error as e:
|
||||
print(f"Database error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Unexpected error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
sys.exit(1)
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("VFX Project Management - Submission Configuration Migration")
|
||||
print("=" * 60)
|
||||
|
||||
migrate_database()
|
||||
|
||||
print("\nMigration completed successfully!")
|
||||
@@ -53,7 +53,10 @@ class Project(Base):
|
||||
|
||||
# Custom task statuses
|
||||
custom_task_statuses = Column(JSON, nullable=True) # Custom task statuses for project
|
||||
|
||||
|
||||
# Submission configuration per task type
|
||||
submission_config_by_task_type = Column(JSON, nullable=True) # Allowed file types, naming pattern, required flag per task type
|
||||
|
||||
# Project thumbnail
|
||||
thumbnail_path = Column(String, nullable=True) # Path to project thumbnail image
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from schemas.project import (
|
||||
ProjectTechnicalSpecs, DeliveryMovieSpec, DEFAULT_DELIVERY_MOVIE_SPECS,
|
||||
ProjectSettings, ProjectSettingsUpdate, DEFAULT_ASSET_TASKS, DEFAULT_SHOT_TASKS
|
||||
)
|
||||
from schemas.submission_config import ProjectSubmissionConfig, SubmissionTypeConfig
|
||||
from utils.auth import get_current_user, require_role, get_current_user_from_token
|
||||
|
||||
router = APIRouter()
|
||||
@@ -1077,6 +1078,87 @@ async def delete_custom_task_type(
|
||||
return _build_all_task_types_response(db_project)
|
||||
|
||||
|
||||
# Submission Configuration Endpoints
|
||||
|
||||
@router.get("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||
async def get_project_submission_config(
|
||||
project_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_with_db)
|
||||
):
|
||||
"""Get the project's per-task-type submission configuration"""
|
||||
project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
if current_user.role == UserRole.ARTIST:
|
||||
member = db.query(ProjectMember).filter(
|
||||
ProjectMember.project_id == project_id,
|
||||
ProjectMember.user_id == current_user.id
|
||||
).first()
|
||||
if not member:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Access denied to this project"
|
||||
)
|
||||
|
||||
config_data = project.submission_config_by_task_type
|
||||
if isinstance(config_data, str):
|
||||
try:
|
||||
config_data = json.loads(config_data)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
config_data = {}
|
||||
|
||||
return ProjectSubmissionConfig(config_by_task_type=config_data or {})
|
||||
|
||||
|
||||
@router.put("/{project_id}/submission-config", response_model=ProjectSubmissionConfig)
|
||||
async def update_project_submission_config(
|
||||
project_id: int,
|
||||
submission_config: ProjectSubmissionConfig,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(require_coordinator_or_admin)
|
||||
):
|
||||
"""Replace the project's per-task-type submission configuration"""
|
||||
db_project = db.query(Project).filter(Project.id == project_id).first()
|
||||
|
||||
if not db_project:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="Project not found"
|
||||
)
|
||||
|
||||
all_types_response = _build_all_task_types_response(db_project)
|
||||
valid_task_types = set(all_types_response.asset_task_types) | set(all_types_response.shot_task_types)
|
||||
for task_type in submission_config.config_by_task_type.keys():
|
||||
if task_type not in valid_task_types:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"'{task_type}' is not a valid task type for this project"
|
||||
)
|
||||
|
||||
db_project.submission_config_by_task_type = {
|
||||
task_type: config.dict()
|
||||
for task_type, config in submission_config.config_by_task_type.items()
|
||||
}
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
db.refresh(db_project)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to update submission configuration"
|
||||
)
|
||||
|
||||
return ProjectSubmissionConfig(config_by_task_type=db_project.submission_config_by_task_type or {})
|
||||
|
||||
|
||||
# Project Thumbnail Management Endpoints
|
||||
|
||||
@router.post("/{project_id}/thumbnail", status_code=status.HTTP_201_CREATED)
|
||||
|
||||
@@ -1567,7 +1567,7 @@ async def submit_work(
|
||||
|
||||
# Validate file using file handler
|
||||
file_handler.validate_file(file, file_handler.MAX_SUBMISSION_SIZE, db)
|
||||
|
||||
|
||||
# Get next version number
|
||||
latest_submission = db.query(Submission).filter(
|
||||
Submission.task_id == task_id,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Pydantic schemas for per-task-type submission configuration
|
||||
"""
|
||||
from pydantic import BaseModel, Field, validator
|
||||
from typing import Optional, Dict, List
|
||||
import re
|
||||
|
||||
from utils.file_handler import file_handler
|
||||
|
||||
# Tokens supported in a naming_pattern: {name} (shot/asset name), {task_type}, {version}
|
||||
NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|version)\}')
|
||||
NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$')
|
||||
|
||||
|
||||
MOVIE_FORMATS = {ext.lstrip('.') for ext in file_handler.MOVIE_EXTENSIONS}
|
||||
MOVIE_CODECS = {'h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform'}
|
||||
MOVIE_FRAME_RATES = {23.976, 24, 30, 48, 60}
|
||||
RESOLUTION_RE = re.compile(r'^\d+x\d+$')
|
||||
|
||||
|
||||
class SubmissionTypeConfig(BaseModel):
|
||||
"""Submission rules for a single task type.
|
||||
|
||||
All checking against these rules happens client-side (in TaskSubmissions.vue) when an
|
||||
artist picks a file to submit - this schema only validates and stores the configuration
|
||||
itself, it is never used to inspect an uploaded file server-side.
|
||||
"""
|
||||
allowed_extensions: List[str] = Field(default_factory=list, description="Accepted file extensions, e.g. ['.mov', '.exr']")
|
||||
naming_pattern: Optional[str] = Field(None, description="Filename pattern using {name}, {task_type}, {version} tokens")
|
||||
check_naming: bool = Field(True, description="Whether the naming pattern is actively checked")
|
||||
required: bool = Field(False, description="Whether a submission is expected for this task type (informational only)")
|
||||
check_movie_spec: bool = Field(False, description="Whether to check video submissions against movie_resolution/movie_format/movie_codec/movie_frame_rate")
|
||||
movie_resolution: Optional[str] = Field(None, description="Required video resolution, e.g. '1920x1080'")
|
||||
movie_format: Optional[str] = Field(None, description="Required video format, e.g. 'mov'")
|
||||
movie_codec: Optional[str] = Field(None, description="Required video codec, e.g. 'h264'")
|
||||
movie_frame_rate: Optional[float] = Field(None, description="Required video frame rate, e.g. 23.976")
|
||||
|
||||
@validator('allowed_extensions')
|
||||
def validate_allowed_extensions(cls, v):
|
||||
normalized = []
|
||||
for ext in v:
|
||||
ext = ext.lower()
|
||||
if not ext.startswith('.'):
|
||||
ext = f'.{ext}'
|
||||
if ext not in file_handler.SUPPORTED_FORMATS:
|
||||
raise ValueError(
|
||||
f"Extension '{ext}' is not a supported file format. "
|
||||
f"Supported formats: {', '.join(sorted(file_handler.SUPPORTED_FORMATS))}"
|
||||
)
|
||||
normalized.append(ext)
|
||||
return normalized
|
||||
|
||||
@validator('naming_pattern')
|
||||
def validate_naming_pattern(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
# Strip out known tokens, then everything left must be safe literal characters
|
||||
stripped = NAMING_PATTERN_TOKEN_RE.sub('', v)
|
||||
if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped):
|
||||
raise ValueError(
|
||||
'Naming pattern may only contain letters, numbers, "_", "-", "." '
|
||||
'and the tokens {name}, {task_type}, {version}'
|
||||
)
|
||||
return v
|
||||
|
||||
@validator('movie_resolution')
|
||||
def validate_movie_resolution(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
if not RESOLUTION_RE.match(v):
|
||||
raise ValueError('Movie resolution must be in format "WIDTHxHEIGHT" (e.g., "1920x1080")')
|
||||
return v
|
||||
|
||||
@validator('movie_format')
|
||||
def validate_movie_format(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
v = v.lower().lstrip('.')
|
||||
if v not in MOVIE_FORMATS:
|
||||
raise ValueError(f'Movie format must be one of: {", ".join(sorted(MOVIE_FORMATS))}')
|
||||
return v
|
||||
|
||||
@validator('movie_codec')
|
||||
def validate_movie_codec(cls, v):
|
||||
if v is None or v == '':
|
||||
return None
|
||||
v = v.lower()
|
||||
if v not in MOVIE_CODECS:
|
||||
raise ValueError(f'Movie codec must be one of: {", ".join(sorted(MOVIE_CODECS))}')
|
||||
return v
|
||||
|
||||
@validator('movie_frame_rate')
|
||||
def validate_movie_frame_rate(cls, v):
|
||||
if v is None:
|
||||
return None
|
||||
if not any(abs(v - rate) < 0.001 for rate in MOVIE_FRAME_RATES):
|
||||
raise ValueError(f'Movie frame rate must be one of: {", ".join(str(r) for r in sorted(MOVIE_FRAME_RATES))}')
|
||||
return v
|
||||
|
||||
|
||||
class ProjectSubmissionConfig(BaseModel):
|
||||
"""Submission configuration for a project, keyed by task type"""
|
||||
config_by_task_type: Dict[str, SubmissionTypeConfig] = Field(default_factory=dict)
|
||||
@@ -21,21 +21,23 @@ class FileHandler:
|
||||
# Supported VFX media formats
|
||||
SUPPORTED_FORMATS = {
|
||||
# Video formats
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm',
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
|
||||
# Image formats
|
||||
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
|
||||
# Document formats
|
||||
'.pdf', '.txt', '.doc', '.docx',
|
||||
# Archive formats
|
||||
'.zip', '.rar', '.7z'
|
||||
'.zip', '.rar', '.7z',
|
||||
# Scene formats
|
||||
'.ma', '.usd', '.usda', '.usdc'
|
||||
}
|
||||
|
||||
|
||||
# File size limits (in bytes)
|
||||
MAX_ATTACHMENT_SIZE = 10 * 1024 * 1024 # 10MB for attachments
|
||||
MAX_SUBMISSION_SIZE = 500 * 1024 * 1024 # 500MB for submissions (fallback)
|
||||
|
||||
|
||||
# Movie file extensions that should use global upload limit
|
||||
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm'}
|
||||
MOVIE_EXTENSIONS = {'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf'}
|
||||
|
||||
# Thumbnail settings
|
||||
THUMBNAIL_SIZE = (200, 200)
|
||||
|
||||
Generated
+137
@@ -16,6 +16,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.294.0",
|
||||
"mediainfo.js": "^0.3.7",
|
||||
"pinia": "^2.1.7",
|
||||
"reka-ui": "^2.6.1",
|
||||
"shadcn-vue": "^2.3.2",
|
||||
@@ -2789,6 +2790,60 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "9.0.1",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
|
||||
"integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^7.2.0",
|
||||
"strip-ansi": "^7.1.0",
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/cliui/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
@@ -4053,6 +4108,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||
@@ -4817,6 +4881,21 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/mediainfo.js": {
|
||||
"version": "0.3.7",
|
||||
"resolved": "https://registry.npmjs.org/mediainfo.js/-/mediainfo.js-0.3.7.tgz",
|
||||
"integrity": "sha512-mgsmb2GrCTAguVcTohW7KrF+QXNaihrbljj75pLWDQcTTQyqfGoZG4Dy++AZ+FDVCT14LHvVGC9SXxcF5VePag==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"yargs": "^18.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"mediainfo.js": "dist/esm/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
|
||||
@@ -7595,12 +7674,70 @@
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "18.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
|
||||
"integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^9.0.1",
|
||||
"escalade": "^3.1.1",
|
||||
"get-caller-file": "^2.0.5",
|
||||
"string-width": "^7.2.0",
|
||||
"y18n": "^5.0.5",
|
||||
"yargs-parser": "^22.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "22.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
|
||||
"integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "^20.19.0 || ^22.12.0 || >=23"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs/node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/yargs/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yocto-queue": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-vue-next": "^0.294.0",
|
||||
"mediainfo.js": "^0.3.7",
|
||||
"pinia": "^2.1.7",
|
||||
"reka-ui": "^2.6.1",
|
||||
"shadcn-vue": "^2.3.2",
|
||||
|
||||
@@ -0,0 +1,294 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold">Submission Configuration</h3>
|
||||
<p class="text-sm text-muted-foreground">Configure accepted file types and naming conventions artists must follow when submitting work, per task type</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLoading" class="text-center py-8 text-sm text-muted-foreground">
|
||||
Loading submission configuration...
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<Card v-for="taskType in taskTypes" :key="taskType">
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between">
|
||||
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label :for="`${taskType}_required`" class="text-xs text-muted-foreground">Required</Label>
|
||||
<Switch
|
||||
:id="`${taskType}_required`"
|
||||
:model-value="getConfig(taskType).required"
|
||||
@update:model-value="(val) => (getConfig(taskType).required = !!val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
<div>
|
||||
<Label class="text-xs">Allowed File Types</Label>
|
||||
<div class="flex flex-wrap gap-3 mt-1">
|
||||
<label
|
||||
v-for="ext in ALLOWED_EXTENSIONS"
|
||||
:key="ext"
|
||||
class="flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<Checkbox
|
||||
:model-value="getConfig(taskType).allowed_extensions.includes(ext)"
|
||||
@update:model-value="(val) => toggleExtension(taskType, ext, !!val)"
|
||||
/>
|
||||
{{ ext }}
|
||||
</label>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Leave all unchecked to accept any supported file type</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label>
|
||||
<Switch
|
||||
:id="`${taskType}_check_naming`"
|
||||
:model-value="getConfig(taskType).check_naming"
|
||||
@update:model-value="(val) => (getConfig(taskType).check_naming = !!val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
:id="`${taskType}_pattern`"
|
||||
v-model="getConfig(taskType).naming_pattern"
|
||||
placeholder="e.g. {name}_{task_type}_v{version}"
|
||||
class="h-8"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
Tokens: <code>{name}</code> (shot/asset name), <code>{task_type}</code>, <code>{version}</code> (auto 3-digit)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between">
|
||||
<Label class="text-xs">Movie Spec</Label>
|
||||
<div class="flex items-center gap-2">
|
||||
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label>
|
||||
<Switch
|
||||
:id="`${taskType}_check_movie_spec`"
|
||||
:model-value="getConfig(taskType).check_movie_spec"
|
||||
@update:model-value="(val) => (getConfig(taskType).check_movie_spec = !!val)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="getConfig(taskType).check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
|
||||
<div>
|
||||
<Input
|
||||
v-model="getConfig(taskType).movie_resolution"
|
||||
placeholder="e.g. 1920x1080"
|
||||
class="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="getConfig(taskType).movie_format || undefined"
|
||||
@update:model-value="(val) => (getConfig(taskType).movie_format = val ? String(val) : '')"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Format" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="fmt in MOVIE_FORMATS" :key="fmt" :value="fmt">{{ fmt }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="getConfig(taskType).movie_codec || undefined"
|
||||
@update:model-value="(val) => (getConfig(taskType).movie_codec = val ? String(val) : '')"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Codec" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="codec in MOVIE_CODECS" :key="codec" :value="codec">{{ codec }}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Select
|
||||
:model-value="getConfig(taskType).movie_frame_rate ? String(getConfig(taskType).movie_frame_rate) : undefined"
|
||||
@update:model-value="(val) => (getConfig(taskType).movie_frame_rate = val ? Number(val) : null)"
|
||||
>
|
||||
<SelectTrigger class="h-8">
|
||||
<SelectValue placeholder="Frame rate" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem v-for="rate in MOVIE_FRAME_RATES" :key="rate" :value="String(rate)">{{ rate }} fps</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground mt-1">Checks resolution/format/codec/frame rate for video submissions only, in-browser before upload</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div v-if="taskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
|
||||
No task types configured for this project yet.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button :disabled="isLoading || isSaving" @click="onSave">
|
||||
<div v-if="isSaving" class="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
|
||||
Save Submission Configuration
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from 'vue'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { projectService, type SubmissionTypeConfig } from '@/services/project'
|
||||
import { customTaskTypeService } from '@/services/customTaskType'
|
||||
|
||||
interface Props {
|
||||
projectId: number
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { toast } = useToast()
|
||||
|
||||
// Mirrors backend's file_handler.SUPPORTED_FORMATS - kept for the picker only; the actual check happens client-side at submit time
|
||||
const ALLOWED_EXTENSIONS = [
|
||||
'.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf',
|
||||
'.exr', '.jpg', '.jpeg', '.png', '.tiff', '.tif', '.dpx', '.hdr',
|
||||
'.pdf', '.txt', '.doc', '.docx',
|
||||
'.zip', '.rar', '.7z',
|
||||
'.ma', '.usd', '.usda', '.usdc'
|
||||
]
|
||||
|
||||
const MOVIE_FORMATS = ['mov', 'mp4', 'avi', 'mkv', 'webm', 'mxf']
|
||||
const MOVIE_CODECS = ['h264', 'h265', 'mjpeg', 'dnxhd', 'dnxhr', 'prores', 'uncompressed', 'avid', 'cineform']
|
||||
const MOVIE_FRAME_RATES = [23.976, 24, 30, 48, 60]
|
||||
|
||||
const isLoading = ref(false)
|
||||
const isSaving = ref(false)
|
||||
const taskTypes = ref<string[]>([])
|
||||
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
|
||||
|
||||
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
|
||||
|
||||
const getConfig = (taskType: string): SubmissionTypeConfig => {
|
||||
if (!formState[taskType]) {
|
||||
formState[taskType] = {
|
||||
allowed_extensions: [],
|
||||
naming_pattern: '',
|
||||
check_naming: true,
|
||||
required: false,
|
||||
check_movie_spec: false,
|
||||
movie_resolution: '',
|
||||
movie_format: '',
|
||||
movie_codec: '',
|
||||
movie_frame_rate: null
|
||||
}
|
||||
}
|
||||
return formState[taskType]
|
||||
}
|
||||
|
||||
const toggleExtension = (taskType: string, ext: string, checked: boolean) => {
|
||||
const config = getConfig(taskType)
|
||||
if (checked) {
|
||||
if (!config.allowed_extensions.includes(ext)) config.allowed_extensions.push(ext)
|
||||
} else {
|
||||
config.allowed_extensions = config.allowed_extensions.filter(e => e !== ext)
|
||||
}
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const [allTypes, config] = await Promise.all([
|
||||
customTaskTypeService.getAllTaskTypes(props.projectId),
|
||||
projectService.getProjectSubmissionConfig(props.projectId)
|
||||
])
|
||||
|
||||
taskTypes.value = Array.from(new Set([...allTypes.asset_task_types, ...allTypes.shot_task_types]))
|
||||
|
||||
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
|
||||
formState[taskType] = {
|
||||
allowed_extensions: [...cfg.allowed_extensions],
|
||||
naming_pattern: cfg.naming_pattern || '',
|
||||
check_naming: cfg.check_naming,
|
||||
required: cfg.required,
|
||||
check_movie_spec: cfg.check_movie_spec,
|
||||
movie_resolution: cfg.movie_resolution || '',
|
||||
movie_format: cfg.movie_format || '',
|
||||
movie_codec: cfg.movie_codec || '',
|
||||
movie_frame_rate: cfg.movie_frame_rate ?? null
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load submission configuration:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: 'Failed to load submission configuration',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSave = async () => {
|
||||
try {
|
||||
isSaving.value = true
|
||||
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
|
||||
for (const taskType of taskTypes.value) {
|
||||
const config = formState[taskType]
|
||||
if (!config) continue
|
||||
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|
||||
|| config.required || config.check_movie_spec
|
||||
if (hasConfig) {
|
||||
configByTaskType[taskType] = {
|
||||
allowed_extensions: config.allowed_extensions,
|
||||
naming_pattern: config.naming_pattern || null,
|
||||
check_naming: config.check_naming,
|
||||
required: config.required,
|
||||
check_movie_spec: config.check_movie_spec,
|
||||
movie_resolution: config.movie_resolution || null,
|
||||
movie_format: config.movie_format || null,
|
||||
movie_codec: config.movie_codec || null,
|
||||
movie_frame_rate: config.movie_frame_rate || null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await projectService.updateProjectSubmissionConfig(props.projectId, { config_by_task_type: configByTaskType })
|
||||
|
||||
toast({
|
||||
title: 'Submission Configuration Updated',
|
||||
description: 'Submission rules have been saved successfully'
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Failed to save submission configuration:', error)
|
||||
toast({
|
||||
title: 'Error',
|
||||
description: error.response?.data?.detail || 'Failed to save submission configuration',
|
||||
variant: 'destructive'
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
@@ -201,7 +201,14 @@
|
||||
|
||||
<!-- Submissions Tab -->
|
||||
<TabsContent value="submissions" class="flex-1 m-0 overflow-hidden">
|
||||
<TaskSubmissions :task-id="taskId" :submissions="submissions" @submissions-updated="loadSubmissions" />
|
||||
<TaskSubmissions
|
||||
:task-id="taskId"
|
||||
:submissions="submissions"
|
||||
:task-type="task?.task_type"
|
||||
:project-id="task?.project_id"
|
||||
:name="task?.shot_name || task?.asset_name"
|
||||
@submissions-updated="loadSubmissions"
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,25 @@
|
||||
<!-- Submit Work Form (Bottom) -->
|
||||
<div class="flex-shrink-0 border-t bg-background p-2">
|
||||
<div class="space-y-2">
|
||||
<div v-if="submissionConfig" class="text-xs text-muted-foreground space-y-0.5 px-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span v-if="submissionConfig.allowed_extensions.length > 0">
|
||||
Allowed: {{ submissionConfig.allowed_extensions.join(', ') }}
|
||||
</span>
|
||||
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
|
||||
</div>
|
||||
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
|
||||
Naming: <code>{{ submissionConfig.naming_pattern }}</code>
|
||||
</div>
|
||||
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
|
||||
Movie spec:
|
||||
<span v-if="submissionConfig.movie_resolution">{{ submissionConfig.movie_resolution }}</span>
|
||||
<span v-if="submissionConfig.movie_format">.{{ submissionConfig.movie_format }}</span>
|
||||
<span v-if="submissionConfig.movie_codec">({{ submissionConfig.movie_codec }})</span>
|
||||
<span v-if="submissionConfig.movie_frame_rate">{{ submissionConfig.movie_frame_rate }}fps</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-2 border-dashed rounded-lg p-3 text-center hover:border-primary/50 transition-colors">
|
||||
<input
|
||||
ref="fileInput"
|
||||
@@ -110,7 +129,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Upload } from 'lucide-vue-next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
@@ -124,12 +143,18 @@ import {
|
||||
} from '@/components/ui/dialog'
|
||||
import SubmissionCard from './SubmissionCard.vue'
|
||||
import { taskService, type Submission } from '@/services/task'
|
||||
import { projectService, type SubmissionTypeConfig } from '@/services/project'
|
||||
import { useToast } from '@/components/ui/toast/use-toast'
|
||||
import { apiClient } from '@/services/api'
|
||||
import mediaInfoFactory, { isTrackType } from 'mediainfo.js'
|
||||
import mediaInfoWasmUrl from 'mediainfo.js/MediaInfoModule.wasm?url'
|
||||
|
||||
const props = defineProps<{
|
||||
taskId: number
|
||||
submissions: Submission[]
|
||||
taskType?: string
|
||||
projectId?: number
|
||||
name?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -144,12 +169,184 @@ const submissionNotes = ref('')
|
||||
const viewerOpen = ref(false)
|
||||
const selectedSubmission = ref<Submission | null>(null)
|
||||
const mediaBlobUrl = ref<string | null>(null)
|
||||
const submissionConfig = ref<SubmissionTypeConfig | null>(null)
|
||||
|
||||
async function loadSubmissionConfig() {
|
||||
submissionConfig.value = null
|
||||
if (!props.projectId || !props.taskType) return
|
||||
try {
|
||||
const config = await projectService.getProjectSubmissionConfig(props.projectId)
|
||||
submissionConfig.value = config.config_by_task_type[props.taskType] || null
|
||||
} catch (error) {
|
||||
console.error('Failed to load submission configuration:', error)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadSubmissionConfig)
|
||||
watch(() => [props.projectId, props.taskType], loadSubmissionConfig)
|
||||
|
||||
const MOVIE_EXTENSIONS = ['.mov', '.mp4', '.avi', '.mkv', '.webm', '.mxf']
|
||||
|
||||
let mediaInfoPromise: ReturnType<typeof mediaInfoFactory> | null = null
|
||||
function getMediaInfo() {
|
||||
if (!mediaInfoPromise) {
|
||||
mediaInfoPromise = mediaInfoFactory({ locateFile: () => mediaInfoWasmUrl })
|
||||
}
|
||||
return mediaInfoPromise
|
||||
}
|
||||
|
||||
interface VideoTrackInfo {
|
||||
width: number | null
|
||||
height: number | null
|
||||
format: string | null
|
||||
codecId: string | null
|
||||
formatCommercial: string | null
|
||||
frameRate: number | null
|
||||
}
|
||||
|
||||
// Parses container/codec metadata directly from the file's bytes (no playback) - works for
|
||||
// containers browsers can't play natively, e.g. MXF, or codecs like ProRes/DNxHD
|
||||
async function analyzeVideoFile(file: File): Promise<VideoTrackInfo | null> {
|
||||
try {
|
||||
const mediainfo = await getMediaInfo()
|
||||
const getSize = () => file.size
|
||||
const readChunk = (chunkSize: number, offset: number) =>
|
||||
new Promise<Uint8Array>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(new Uint8Array(reader.result as ArrayBuffer))
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize))
|
||||
})
|
||||
const result = await mediainfo.analyzeData(getSize, readChunk)
|
||||
const videoTrack = result.media?.track.find(t => isTrackType(t, 'Video'))
|
||||
if (!videoTrack) return null
|
||||
const track = videoTrack as unknown as Record<string, unknown>
|
||||
const commercial = [track.Format_Commercial, track.Format_Commercial_IfAny]
|
||||
.filter((v): v is string => typeof v === 'string')
|
||||
.join(' ')
|
||||
return {
|
||||
width: videoTrack.Width ?? null,
|
||||
height: videoTrack.Height ?? null,
|
||||
format: videoTrack.Format ?? null,
|
||||
codecId: videoTrack.CodecID ?? null,
|
||||
formatCommercial: commercial || null,
|
||||
frameRate: videoTrack.FrameRate ?? null
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze video file:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const CODEC_PATTERNS: Record<string, RegExp> = {
|
||||
h264: /\b(avc|h\.?264)\b/i,
|
||||
h265: /\b(hevc|h\.?265)\b/i,
|
||||
mjpeg: /\bm?jpeg\b/i,
|
||||
dnxhd: /\bdnxhd\b/i,
|
||||
dnxhr: /\bdnxhr\b/i,
|
||||
prores: /\bprores\b/i,
|
||||
uncompressed: /\b(uncompressed|raw)\b/i,
|
||||
avid: /\bavid\b/i,
|
||||
cineform: /\bcineform\b/i
|
||||
}
|
||||
|
||||
function normalizeCodec(info: VideoTrackInfo): string | null {
|
||||
const haystack = [info.format, info.formatCommercial, info.codecId].filter(Boolean).join(' ')
|
||||
for (const [key, pattern] of Object.entries(CODEC_PATTERNS)) {
|
||||
if (pattern.test(haystack)) return key
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// All checking happens client-side, entirely in the browser, before the file is ever uploaded
|
||||
async function findSubmissionViolation(file: File): Promise<string | null> {
|
||||
const config = submissionConfig.value
|
||||
if (!config) return null
|
||||
|
||||
const filename = file.name
|
||||
const dotIndex = filename.lastIndexOf('.')
|
||||
const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename
|
||||
const extension = dotIndex > 0 ? filename.slice(dotIndex).toLowerCase() : ''
|
||||
|
||||
if (config.allowed_extensions.length > 0 && !config.allowed_extensions.includes(extension)) {
|
||||
return `File type '${extension}' is not accepted for ${props.taskType} submissions. Allowed types: ${config.allowed_extensions.join(', ')}`
|
||||
}
|
||||
|
||||
if (config.check_naming && config.naming_pattern) {
|
||||
if (config.naming_pattern.includes('{name}') && !props.name) {
|
||||
return null // can't resolve {name} client-side
|
||||
}
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const pattern = config.naming_pattern
|
||||
.split(/(\{name\}|\{task_type\}|\{version\})/)
|
||||
.map(part => {
|
||||
if (part === '{name}') return escapeRegex(props.name || '')
|
||||
if (part === '{task_type}') return escapeRegex(props.taskType || '')
|
||||
if (part === '{version}') return '\\d{3}'
|
||||
return escapeRegex(part)
|
||||
})
|
||||
.join('')
|
||||
const regex = new RegExp(`^${pattern}$`)
|
||||
if (!regex.test(stem)) {
|
||||
const example = config.naming_pattern
|
||||
.replace('{name}', props.name || 'name')
|
||||
.replace('{task_type}', props.taskType || '')
|
||||
.replace('{version}', '001')
|
||||
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')`
|
||||
}
|
||||
}
|
||||
|
||||
if (config.check_movie_spec && MOVIE_EXTENSIONS.includes(extension)) {
|
||||
if (config.movie_format && extension.slice(1) !== config.movie_format.toLowerCase()) {
|
||||
return `Movie format '${extension}' does not match the required format '.${config.movie_format}'`
|
||||
}
|
||||
if (config.movie_resolution || config.movie_codec || config.movie_frame_rate) {
|
||||
const info = await analyzeVideoFile(file)
|
||||
if (info) {
|
||||
if (config.movie_resolution && info.width && info.height) {
|
||||
const actual = `${info.width}x${info.height}`
|
||||
if (actual !== config.movie_resolution) {
|
||||
return `Video resolution ${actual} does not match the required resolution ${config.movie_resolution}`
|
||||
}
|
||||
}
|
||||
if (config.movie_codec) {
|
||||
const actualCodec = normalizeCodec(info)
|
||||
if (actualCodec && actualCodec !== config.movie_codec) {
|
||||
return `Video codec '${actualCodec}' does not match the required codec '${config.movie_codec}'`
|
||||
}
|
||||
// If the codec couldn't be identified, don't false-block
|
||||
}
|
||||
if (config.movie_frame_rate && info.frameRate) {
|
||||
if (Math.abs(info.frameRate - config.movie_frame_rate) > 0.05) {
|
||||
return `Video frame rate ${info.frameRate}fps does not match the required frame rate ${config.movie_frame_rate}fps`
|
||||
}
|
||||
}
|
||||
}
|
||||
// If the file couldn't be analyzed at all, don't false-block
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function handleFileSelect(event: Event) {
|
||||
const target = event.target as HTMLInputElement
|
||||
const file = target.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
const violation = await findSubmissionViolation(file)
|
||||
if (violation) {
|
||||
toast({
|
||||
title: 'Invalid submission',
|
||||
description: violation,
|
||||
variant: 'destructive'
|
||||
})
|
||||
if (fileInput.value) {
|
||||
fileInput.value.value = ''
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
try {
|
||||
await taskService.submitWork(
|
||||
|
||||
@@ -95,6 +95,22 @@ export interface ProjectSettings {
|
||||
enabled_shot_tasks?: string[]
|
||||
}
|
||||
|
||||
export interface SubmissionTypeConfig {
|
||||
allowed_extensions: string[]
|
||||
naming_pattern?: string | null
|
||||
check_naming: boolean
|
||||
required: boolean
|
||||
check_movie_spec: boolean
|
||||
movie_resolution?: string | null
|
||||
movie_format?: string | null
|
||||
movie_codec?: string | null
|
||||
movie_frame_rate?: number | null
|
||||
}
|
||||
|
||||
export interface ProjectSubmissionConfig {
|
||||
config_by_task_type: Record<string, SubmissionTypeConfig>
|
||||
}
|
||||
|
||||
export const projectService = {
|
||||
async getUserProjects(): Promise<Project[]> {
|
||||
const response = await apiClient.get('/projects/')
|
||||
@@ -182,6 +198,16 @@ export const projectService = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async getProjectSubmissionConfig(projectId: number): Promise<ProjectSubmissionConfig> {
|
||||
const response = await apiClient.get(`/projects/${projectId}/submission-config`)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async updateProjectSubmissionConfig(projectId: number, config: ProjectSubmissionConfig): Promise<ProjectSubmissionConfig> {
|
||||
const response = await apiClient.put(`/projects/${projectId}/submission-config`, config)
|
||||
return response.data
|
||||
},
|
||||
|
||||
async uploadThumbnail(projectId: number, file: File): Promise<{ message: string; thumbnail_url: string }> {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- Tabbed Interface -->
|
||||
<Tabs :default-value="activeTab" @update:model-value="(val) => activeTab = String(val)" class="w-full">
|
||||
<TabsList class="grid w-full grid-cols-6">
|
||||
<TabsList class="grid w-full grid-cols-7">
|
||||
<TabsTrigger value="general">
|
||||
<Settings class="h-4 w-4 mr-2" />
|
||||
General
|
||||
@@ -54,6 +54,10 @@
|
||||
<ListChecks class="h-4 w-4 mr-2" />
|
||||
Tasks
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="submissions">
|
||||
<UploadCloud class="h-4 w-4 mr-2" />
|
||||
Submissions
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="storage">
|
||||
<FolderOpen class="h-4 w-4 mr-2" />
|
||||
Storage
|
||||
@@ -156,6 +160,13 @@
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Submission Configuration Tab -->
|
||||
<TabsContent value="submissions" class="mt-6">
|
||||
<div class="bg-card rounded-lg border p-6">
|
||||
<SubmissionConfigManager :project-id="projectId" />
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<!-- Upload Location Tab -->
|
||||
<TabsContent value="storage" class="mt-6">
|
||||
<div class="bg-card rounded-lg border p-6">
|
||||
@@ -175,9 +186,9 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import {
|
||||
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
|
||||
ListChecks, FolderOpen
|
||||
import {
|
||||
AlertCircle, ArrowLeft, Settings, Cog, Users, Film,
|
||||
ListChecks, FolderOpen, UploadCloud
|
||||
} from "lucide-vue-next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -195,6 +206,7 @@ import CustomTaskStatusManager from "@/components/settings/CustomTaskStatusManag
|
||||
import CustomTaskTypeManager from "@/components/settings/CustomTaskTypeManager.vue";
|
||||
import DefaultTaskTemplatesEditor from "@/components/settings/DefaultTaskTemplatesEditor.vue";
|
||||
import UploadLocationConfig from "@/components/settings/UploadLocationConfig.vue";
|
||||
import SubmissionConfigManager from "@/components/project/SubmissionConfigManager.vue";
|
||||
import { projectService } from "@/services/project";
|
||||
import type { Project } from "@/stores/projects";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user