Extend submission config: .mxf/codec/frame-rate checks, regex naming, shot/asset tabs
- Movie spec checks now also cover codec (h264/h265/mjpeg/dnxhd/dnxhr/
prores/uncompressed/avid/cineform) and frame rate (23.976/24/30/48/60),
detected in-browser via mediainfo.js container parsing - works even
for formats the native <video> element can't decode (MXF, ProRes,
DNxHD). Added .mxf to the supported/allowed format lists.
- Naming pattern supports a raw regex mode (validated by compiling
server-side at save time) as an alternative to the token pattern,
and gained three more tokens: {task_name}, {project_name},
{project_code} (fetched lazily only when referenced).
- Submission Configuration settings page now splits Shot Tasks /
Asset Tasks into separate tabs instead of one merged list; the
per-task-type card was extracted into SubmissionTypeConfigCard.vue
to avoid duplicating it across both tabs.
This commit is contained in:
@@ -7,8 +7,8 @@ import re
|
|||||||
|
|
||||||
from utils.file_handler import file_handler
|
from utils.file_handler import file_handler
|
||||||
|
|
||||||
# Tokens supported in a naming_pattern: {name} (shot/asset name), {task_type}, {version}
|
# Tokens supported in a token-mode naming_pattern
|
||||||
NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|version)\}')
|
NAMING_PATTERN_TOKEN_RE = re.compile(r'\{(name|task_type|task_name|project_name|project_code|version)\}')
|
||||||
NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$')
|
NAMING_PATTERN_ALLOWED_CHARS_RE = re.compile(r'^[A-Za-z0-9_\-\.]*$')
|
||||||
|
|
||||||
|
|
||||||
@@ -26,7 +26,12 @@ class SubmissionTypeConfig(BaseModel):
|
|||||||
itself, it is never used to inspect an uploaded file server-side.
|
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']")
|
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")
|
naming_pattern_is_regex: bool = Field(False, description="If true, naming_pattern is a raw regular expression instead of a token pattern")
|
||||||
|
naming_pattern: Optional[str] = Field(
|
||||||
|
None,
|
||||||
|
description="Either a token pattern (using {name}, {task_type}, {task_name}, {project_name}, "
|
||||||
|
"{project_code}, {version}) or, if naming_pattern_is_regex is set, a raw regular expression"
|
||||||
|
)
|
||||||
check_naming: bool = Field(True, description="Whether the naming pattern is actively checked")
|
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)")
|
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")
|
check_movie_spec: bool = Field(False, description="Whether to check video submissions against movie_resolution/movie_format/movie_codec/movie_frame_rate")
|
||||||
@@ -51,15 +56,21 @@ class SubmissionTypeConfig(BaseModel):
|
|||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
@validator('naming_pattern')
|
@validator('naming_pattern')
|
||||||
def validate_naming_pattern(cls, v):
|
def validate_naming_pattern(cls, v, values):
|
||||||
if v is None or v == '':
|
if v is None or v == '':
|
||||||
return None
|
return None
|
||||||
# Strip out known tokens, then everything left must be safe literal characters
|
if values.get('naming_pattern_is_regex'):
|
||||||
|
try:
|
||||||
|
re.compile(v)
|
||||||
|
except re.error as e:
|
||||||
|
raise ValueError(f'Invalid regular expression: {e}')
|
||||||
|
return v
|
||||||
|
# Token mode: strip out known tokens, then everything left must be safe literal characters
|
||||||
stripped = NAMING_PATTERN_TOKEN_RE.sub('', v)
|
stripped = NAMING_PATTERN_TOKEN_RE.sub('', v)
|
||||||
if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped):
|
if not NAMING_PATTERN_ALLOWED_CHARS_RE.match(stripped):
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
'Naming pattern may only contain letters, numbers, "_", "-", "." '
|
'Naming pattern may only contain letters, numbers, "_", "-", "." '
|
||||||
'and the tokens {name}, {task_type}, {version}'
|
'and the tokens {name}, {task_type}, {task_name}, {project_name}, {project_code}, {version}'
|
||||||
)
|
)
|
||||||
return v
|
return v
|
||||||
|
|
||||||
|
|||||||
@@ -11,132 +11,36 @@
|
|||||||
Loading submission configuration...
|
Loading submission configuration...
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<Tabs v-else default-value="shot" class="w-full">
|
||||||
<Card v-for="taskType in taskTypes" :key="taskType">
|
<TabsList class="grid w-full grid-cols-2">
|
||||||
<CardHeader>
|
<TabsTrigger value="shot">Shot Tasks</TabsTrigger>
|
||||||
<div class="flex items-center justify-between">
|
<TabsTrigger value="asset">Asset Tasks</TabsTrigger>
|
||||||
<CardTitle class="text-base capitalize">{{ formatTaskType(taskType) }}</CardTitle>
|
</TabsList>
|
||||||
<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>
|
<TabsContent value="shot" class="space-y-4 mt-4">
|
||||||
<div class="flex items-center justify-between">
|
<SubmissionTypeConfigCard
|
||||||
<Label :for="`${taskType}_pattern`" class="text-xs">Naming Pattern</Label>
|
v-for="taskType in shotTaskTypes"
|
||||||
<div class="flex items-center gap-2">
|
:key="taskType"
|
||||||
<Label :for="`${taskType}_check_naming`" class="text-xs text-muted-foreground">Check</Label>
|
:task-type="taskType"
|
||||||
<Switch
|
:config="getConfig(taskType)"
|
||||||
:id="`${taskType}_check_naming`"
|
|
||||||
:model-value="getConfig(taskType).check_naming"
|
|
||||||
@update:model-value="(val) => (getConfig(taskType).check_naming = !!val)"
|
|
||||||
/>
|
/>
|
||||||
|
<div v-if="shotTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
No shot task types configured for this project yet.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
<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>
|
<TabsContent value="asset" class="space-y-4 mt-4">
|
||||||
<div class="flex items-center justify-between">
|
<SubmissionTypeConfigCard
|
||||||
<Label class="text-xs">Movie Spec</Label>
|
v-for="taskType in assetTaskTypes"
|
||||||
<div class="flex items-center gap-2">
|
:key="taskType"
|
||||||
<Label :for="`${taskType}_check_movie_spec`" class="text-xs text-muted-foreground">Check</Label>
|
:task-type="taskType"
|
||||||
<Switch
|
:config="getConfig(taskType)"
|
||||||
:id="`${taskType}_check_movie_spec`"
|
|
||||||
:model-value="getConfig(taskType).check_movie_spec"
|
|
||||||
@update:model-value="(val) => (getConfig(taskType).check_movie_spec = !!val)"
|
|
||||||
/>
|
/>
|
||||||
|
<div v-if="assetTaskTypes.length === 0" class="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
No asset task types configured for this project yet.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
<div v-if="getConfig(taskType).check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
|
</Tabs>
|
||||||
<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">
|
<div class="flex justify-end">
|
||||||
<Button :disabled="isLoading || isSaving" @click="onSave">
|
<Button :disabled="isLoading || isSaving" @click="onSave">
|
||||||
@@ -150,15 +54,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
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 { useToast } from '@/components/ui/toast/use-toast'
|
||||||
import { projectService, type SubmissionTypeConfig } from '@/services/project'
|
import { projectService, type SubmissionTypeConfig } from '@/services/project'
|
||||||
import { customTaskTypeService } from '@/services/customTaskType'
|
import { customTaskTypeService } from '@/services/customTaskType'
|
||||||
|
import SubmissionTypeConfigCard from './SubmissionTypeConfigCard.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
projectId: number
|
projectId: number
|
||||||
@@ -167,30 +67,17 @@ interface Props {
|
|||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
const { toast } = useToast()
|
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 isLoading = ref(false)
|
||||||
const isSaving = ref(false)
|
const isSaving = ref(false)
|
||||||
const taskTypes = ref<string[]>([])
|
const shotTaskTypes = ref<string[]>([])
|
||||||
|
const assetTaskTypes = ref<string[]>([])
|
||||||
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
|
const formState = reactive<Record<string, SubmissionTypeConfig>>({})
|
||||||
|
|
||||||
const formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
|
|
||||||
|
|
||||||
const getConfig = (taskType: string): SubmissionTypeConfig => {
|
const getConfig = (taskType: string): SubmissionTypeConfig => {
|
||||||
if (!formState[taskType]) {
|
if (!formState[taskType]) {
|
||||||
formState[taskType] = {
|
formState[taskType] = {
|
||||||
allowed_extensions: [],
|
allowed_extensions: [],
|
||||||
|
naming_pattern_is_regex: false,
|
||||||
naming_pattern: '',
|
naming_pattern: '',
|
||||||
check_naming: true,
|
check_naming: true,
|
||||||
required: false,
|
required: false,
|
||||||
@@ -204,15 +91,6 @@ const getConfig = (taskType: string): SubmissionTypeConfig => {
|
|||||||
return formState[taskType]
|
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 () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
@@ -221,11 +99,13 @@ const load = async () => {
|
|||||||
projectService.getProjectSubmissionConfig(props.projectId)
|
projectService.getProjectSubmissionConfig(props.projectId)
|
||||||
])
|
])
|
||||||
|
|
||||||
taskTypes.value = Array.from(new Set([...allTypes.asset_task_types, ...allTypes.shot_task_types]))
|
shotTaskTypes.value = allTypes.shot_task_types
|
||||||
|
assetTaskTypes.value = allTypes.asset_task_types
|
||||||
|
|
||||||
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
|
for (const [taskType, cfg] of Object.entries(config.config_by_task_type)) {
|
||||||
formState[taskType] = {
|
formState[taskType] = {
|
||||||
allowed_extensions: [...cfg.allowed_extensions],
|
allowed_extensions: [...cfg.allowed_extensions],
|
||||||
|
naming_pattern_is_regex: cfg.naming_pattern_is_regex,
|
||||||
naming_pattern: cfg.naming_pattern || '',
|
naming_pattern: cfg.naming_pattern || '',
|
||||||
check_naming: cfg.check_naming,
|
check_naming: cfg.check_naming,
|
||||||
required: cfg.required,
|
required: cfg.required,
|
||||||
@@ -252,7 +132,7 @@ const onSave = async () => {
|
|||||||
try {
|
try {
|
||||||
isSaving.value = true
|
isSaving.value = true
|
||||||
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
|
const configByTaskType: Record<string, SubmissionTypeConfig> = {}
|
||||||
for (const taskType of taskTypes.value) {
|
for (const taskType of [...shotTaskTypes.value, ...assetTaskTypes.value]) {
|
||||||
const config = formState[taskType]
|
const config = formState[taskType]
|
||||||
if (!config) continue
|
if (!config) continue
|
||||||
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|
const hasConfig = config.allowed_extensions.length > 0 || !!config.naming_pattern
|
||||||
@@ -260,6 +140,7 @@ const onSave = async () => {
|
|||||||
if (hasConfig) {
|
if (hasConfig) {
|
||||||
configByTaskType[taskType] = {
|
configByTaskType[taskType] = {
|
||||||
allowed_extensions: config.allowed_extensions,
|
allowed_extensions: config.allowed_extensions,
|
||||||
|
naming_pattern_is_regex: config.naming_pattern_is_regex,
|
||||||
naming_pattern: config.naming_pattern || null,
|
naming_pattern: config.naming_pattern || null,
|
||||||
check_naming: config.check_naming,
|
check_naming: config.check_naming,
|
||||||
required: config.required,
|
required: config.required,
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
<template>
|
||||||
|
<Card>
|
||||||
|
<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="config.required"
|
||||||
|
@update:model-value="(val) => (config.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="config.allowed_extensions.includes(ext)"
|
||||||
|
@update:model-value="(val) => toggleExtension(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-3">
|
||||||
|
<label class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<Checkbox
|
||||||
|
:model-value="config.naming_pattern_is_regex"
|
||||||
|
@update:model-value="(val) => (config.naming_pattern_is_regex = !!val)"
|
||||||
|
/>
|
||||||
|
Regex
|
||||||
|
</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="config.check_naming"
|
||||||
|
@update:model-value="(val) => (config.check_naming = !!val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
:id="`${taskType}_pattern`"
|
||||||
|
v-model="config.naming_pattern"
|
||||||
|
:placeholder="config.naming_pattern_is_regex ? 'e.g. ^[A-Z]+\\d{3}_layout_v\\d{3}$' : 'e.g. {project_code}_{name}_{task_type}_v{version}'"
|
||||||
|
class="h-8 font-mono"
|
||||||
|
/>
|
||||||
|
<p v-if="config.naming_pattern_is_regex" class="text-xs text-muted-foreground mt-1">
|
||||||
|
Matched as a regular expression against the filename (without extension)
|
||||||
|
</p>
|
||||||
|
<p v-else class="text-xs text-muted-foreground mt-1">
|
||||||
|
Tokens: <code>{name}</code> (shot/asset name), <code>{task_name}</code>, <code>{task_type}</code>,
|
||||||
|
<code>{project_name}</code>, <code>{project_code}</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="config.check_movie_spec"
|
||||||
|
@update:model-value="(val) => (config.check_movie_spec = !!val)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="config.check_movie_spec" class="grid grid-cols-4 gap-3 mt-1">
|
||||||
|
<div>
|
||||||
|
<Input
|
||||||
|
v-model="config.movie_resolution"
|
||||||
|
placeholder="e.g. 1920x1080"
|
||||||
|
class="h-8"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<Select
|
||||||
|
:model-value="config.movie_format || undefined"
|
||||||
|
@update:model-value="(val) => (config.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="config.movie_codec || undefined"
|
||||||
|
@update:model-value="(val) => (config.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="config.movie_frame_rate ? String(config.movie_frame_rate) : undefined"
|
||||||
|
@update:model-value="(val) => (config.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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
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 { Input } from '@/components/ui/input'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||||
|
import type { SubmissionTypeConfig } from '@/services/project'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
taskType: string
|
||||||
|
config: SubmissionTypeConfig
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 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 formatTaskType = (taskType: string) => taskType.replace(/_/g, ' ')
|
||||||
|
|
||||||
|
function toggleExtension(ext: string, checked: boolean) {
|
||||||
|
if (checked) {
|
||||||
|
if (!props.config.allowed_extensions.includes(ext)) props.config.allowed_extensions.push(ext)
|
||||||
|
} else {
|
||||||
|
props.config.allowed_extensions = props.config.allowed_extensions.filter(e => e !== ext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -207,6 +207,8 @@
|
|||||||
:task-type="task?.task_type"
|
:task-type="task?.task_type"
|
||||||
:project-id="task?.project_id"
|
:project-id="task?.project_id"
|
||||||
:name="task?.shot_name || task?.asset_name"
|
:name="task?.shot_name || task?.asset_name"
|
||||||
|
:task-name="task?.name"
|
||||||
|
:project-name="task?.project_name"
|
||||||
@submissions-updated="loadSubmissions"
|
@submissions-updated="loadSubmissions"
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|||||||
@@ -30,7 +30,8 @@
|
|||||||
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
|
<Badge v-if="submissionConfig.required" variant="outline" class="text-[10px] px-1 py-0">Required</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
|
<div v-if="submissionConfig.naming_pattern && submissionConfig.check_naming">
|
||||||
Naming: <code>{{ submissionConfig.naming_pattern }}</code>
|
{{ submissionConfig.naming_pattern_is_regex ? 'Naming (regex):' : 'Naming:' }}
|
||||||
|
<code>{{ submissionConfig.naming_pattern }}</code>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
|
<div v-if="submissionConfig.check_movie_spec && (submissionConfig.movie_resolution || submissionConfig.movie_format || submissionConfig.movie_codec || submissionConfig.movie_frame_rate)">
|
||||||
Movie spec:
|
Movie spec:
|
||||||
@@ -155,6 +156,8 @@ const props = defineProps<{
|
|||||||
taskType?: string
|
taskType?: string
|
||||||
projectId?: number
|
projectId?: number
|
||||||
name?: string
|
name?: string
|
||||||
|
taskName?: string
|
||||||
|
projectName?: string
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
@@ -170,13 +173,19 @@ const viewerOpen = ref(false)
|
|||||||
const selectedSubmission = ref<Submission | null>(null)
|
const selectedSubmission = ref<Submission | null>(null)
|
||||||
const mediaBlobUrl = ref<string | null>(null)
|
const mediaBlobUrl = ref<string | null>(null)
|
||||||
const submissionConfig = ref<SubmissionTypeConfig | null>(null)
|
const submissionConfig = ref<SubmissionTypeConfig | null>(null)
|
||||||
|
const projectCode = ref<string | null>(null)
|
||||||
|
|
||||||
async function loadSubmissionConfig() {
|
async function loadSubmissionConfig() {
|
||||||
submissionConfig.value = null
|
submissionConfig.value = null
|
||||||
|
projectCode.value = null
|
||||||
if (!props.projectId || !props.taskType) return
|
if (!props.projectId || !props.taskType) return
|
||||||
try {
|
try {
|
||||||
const config = await projectService.getProjectSubmissionConfig(props.projectId)
|
const config = await projectService.getProjectSubmissionConfig(props.projectId)
|
||||||
submissionConfig.value = config.config_by_task_type[props.taskType] || null
|
submissionConfig.value = config.config_by_task_type[props.taskType] || null
|
||||||
|
if (submissionConfig.value?.naming_pattern?.includes('{project_code}')) {
|
||||||
|
const project = await projectService.getProject(props.projectId)
|
||||||
|
projectCode.value = project.code_name
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load submission configuration:', error)
|
console.error('Failed to load submission configuration:', error)
|
||||||
}
|
}
|
||||||
@@ -273,16 +282,33 @@ async function findSubmissionViolation(file: File): Promise<string | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (config.check_naming && config.naming_pattern) {
|
if (config.check_naming && config.naming_pattern) {
|
||||||
if (config.naming_pattern.includes('{name}') && !props.name) {
|
if (config.naming_pattern_is_regex) {
|
||||||
return null // can't resolve {name} client-side
|
try {
|
||||||
|
const regex = new RegExp(config.naming_pattern)
|
||||||
|
if (!regex.test(stem)) {
|
||||||
|
return `Filename does not match the required pattern '${config.naming_pattern}'`
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Invalid naming pattern regex:', error)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const tokenValues: Record<string, string | undefined> = {
|
||||||
|
name: props.name,
|
||||||
|
task_type: props.taskType,
|
||||||
|
task_name: props.taskName,
|
||||||
|
project_name: props.projectName,
|
||||||
|
project_code: projectCode.value || undefined
|
||||||
|
}
|
||||||
|
const usedTokens = config.naming_pattern.match(/\{(name|task_name|project_name|project_code)\}/g) || []
|
||||||
|
const unresolvable = usedTokens.some(token => !tokenValues[token.slice(1, -1)])
|
||||||
|
if (!unresolvable) {
|
||||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||||
const pattern = config.naming_pattern
|
const pattern = config.naming_pattern
|
||||||
.split(/(\{name\}|\{task_type\}|\{version\})/)
|
.split(/(\{name\}|\{task_type\}|\{task_name\}|\{project_name\}|\{project_code\}|\{version\})/)
|
||||||
.map(part => {
|
.map(part => {
|
||||||
if (part === '{name}') return escapeRegex(props.name || '')
|
|
||||||
if (part === '{task_type}') return escapeRegex(props.taskType || '')
|
|
||||||
if (part === '{version}') return '\\d{3}'
|
if (part === '{version}') return '\\d{3}'
|
||||||
|
const key = part.startsWith('{') && part.endsWith('}') ? part.slice(1, -1) : null
|
||||||
|
if (key && key in tokenValues) return escapeRegex(tokenValues[key] || '')
|
||||||
return escapeRegex(part)
|
return escapeRegex(part)
|
||||||
})
|
})
|
||||||
.join('')
|
.join('')
|
||||||
@@ -290,11 +316,17 @@ async function findSubmissionViolation(file: File): Promise<string | null> {
|
|||||||
if (!regex.test(stem)) {
|
if (!regex.test(stem)) {
|
||||||
const example = config.naming_pattern
|
const example = config.naming_pattern
|
||||||
.replace('{name}', props.name || 'name')
|
.replace('{name}', props.name || 'name')
|
||||||
|
.replace('{task_name}', props.taskName || 'taskname')
|
||||||
|
.replace('{project_name}', props.projectName || 'project')
|
||||||
|
.replace('{project_code}', projectCode.value || 'CODE')
|
||||||
.replace('{task_type}', props.taskType || '')
|
.replace('{task_type}', props.taskType || '')
|
||||||
.replace('{version}', '001')
|
.replace('{version}', '001')
|
||||||
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')`
|
return `Filename does not match the required naming convention '${config.naming_pattern}' (e.g. '${example}${extension}')`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If a token couldn't be resolved client-side, don't false-block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (config.check_movie_spec && MOVIE_EXTENSIONS.includes(extension)) {
|
if (config.check_movie_spec && MOVIE_EXTENSIONS.includes(extension)) {
|
||||||
if (config.movie_format && extension.slice(1) !== config.movie_format.toLowerCase()) {
|
if (config.movie_format && extension.slice(1) !== config.movie_format.toLowerCase()) {
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export interface ProjectSettings {
|
|||||||
|
|
||||||
export interface SubmissionTypeConfig {
|
export interface SubmissionTypeConfig {
|
||||||
allowed_extensions: string[]
|
allowed_extensions: string[]
|
||||||
|
naming_pattern_is_regex: boolean
|
||||||
naming_pattern?: string | null
|
naming_pattern?: string | null
|
||||||
check_naming: boolean
|
check_naming: boolean
|
||||||
required: boolean
|
required: boolean
|
||||||
|
|||||||
Reference in New Issue
Block a user