Commit b0db20ad by Jonathan Thomas

Integrating the rest of the clip effects property UI - better caption effect…

Integrating the rest of the clip effects property UI - better caption effect handling, motion keyframes, volume keyframes, and effect adding/deleting.
parent 57c793c5
...@@ -192,6 +192,16 @@ import { ...@@ -192,6 +192,16 @@ import {
normalizeCaptionPosition, normalizeCaptionPosition,
topValueToCaptionPosition topValueToCaptionPosition
} from "../utils/captions"; } from "../utils/captions";
import {
buildClipEffectsFormState,
DEFAULT_AUDIO_LEVEL,
DEFAULT_MOTION_STATE,
isEffectDisallowed,
normalizeEffectOption,
sanitizeAppliedEffectsForUi,
instantiateEffectFromDefinition,
sortEffectsAlphabetically
} from "../utils/clipEffects";
export default { export default {
name: "ClipEffectsModal", name: "ClipEffectsModal",
...@@ -240,12 +250,15 @@ export default { ...@@ -240,12 +250,15 @@ export default {
return label return label
}, },
normalizedEffectOptions() { normalizedEffectOptions() {
let source = []
if (Array.isArray(this.effectCatalog) && this.effectCatalog.length) { if (Array.isArray(this.effectCatalog) && this.effectCatalog.length) {
return this.effectCatalog source = this.effectCatalog
.filter(effect => !this.isCaptionEffect(effect)) } else {
.map((effect, index) => this.normalizeEffectOption(effect, index)) source = this.sampleEffectOptions
} }
return this.sampleEffectOptions const filtered = source.filter(effect => !isEffectDisallowed(effect))
const normalized = filtered.map((effect, index) => normalizeEffectOption(effect, index))
return sortEffectsAlphabetically(normalized)
} }
}, },
methods: { methods: {
...@@ -253,15 +266,8 @@ export default { ...@@ -253,15 +266,8 @@ export default {
return { return {
text: "", text: "",
position: DEFAULT_CAPTION_POSITION, position: DEFAULT_CAPTION_POSITION,
audioLevel: 100, audioLevel: DEFAULT_AUDIO_LEVEL,
motion: { motion: { ...DEFAULT_MOTION_STATE },
slideIn: false,
slideOut: false,
fadeIn: false,
fadeOut: false,
zoomIn: false,
zoomOut: false
},
selectedEffectId: "", selectedEffectId: "",
appliedEffects: [] appliedEffects: []
} }
...@@ -276,28 +282,6 @@ export default { ...@@ -276,28 +282,6 @@ export default {
} }
return this.modalInstance return this.modalInstance
}, },
normalizeEffectOption(effect, fallbackIndex) {
const label = effect.title || effect.name || effect.display_name || effect.slug || `Effect ${fallbackIndex + 1}`
const value = effect.id || effect.slug || effect.value || `effect-${fallbackIndex}`
return { label, value }
},
normalizeAppliedEffect(effect, fallbackIndex) {
if (typeof effect === 'string') {
return { label: effect, value: effect }
}
const normalized = this.normalizeEffectOption(effect, fallbackIndex)
return normalized
},
isCaptionEffect(effect) {
if (!effect) {
return false
}
if (typeof effect === 'string') {
return effect.toLowerCase() === 'caption'
}
const rawLabel = effect.title || effect.name || effect.display_name || effect.slug || effect.type || effect.class_name || ""
return typeof rawLabel === 'string' && rawLabel.toLowerCase().includes('caption')
},
open(payload = {}) { open(payload = {}) {
this.clipContext = { this.clipContext = {
clipIndex: payload.clipIndex ?? null, clipIndex: payload.clipIndex ?? null,
...@@ -308,6 +292,7 @@ export default { ...@@ -308,6 +292,7 @@ export default {
} }
this.activeClip = payload.clip || null this.activeClip = payload.clip || null
const defaults = this.createEmptyForm() const defaults = this.createEmptyForm()
const clipEffectsState = buildClipEffectsFormState(payload.clip)
const clipCaptionState = getClipCaptionState(payload.clip) const clipCaptionState = getClipCaptionState(payload.clip)
const overridePosition = typeof payload.position === "string" const overridePosition = typeof payload.position === "string"
? payload.position ? payload.position
...@@ -318,24 +303,25 @@ export default { ...@@ -318,24 +303,25 @@ export default {
const resolvedPosition = overridePosition const resolvedPosition = overridePosition
? normalizeCaptionPosition(overridePosition) ? normalizeCaptionPosition(overridePosition)
: positionFromTop : positionFromTop
const appliedEffects = Array.isArray(payload.appliedEffects) const appliedSource = Array.isArray(payload.appliedEffects) && payload.appliedEffects.length > 0
? payload.appliedEffects ? payload.appliedEffects
.filter(effect => !this.isCaptionEffect(effect)) : clipEffectsState.appliedEffects
.map((effect, index) => this.normalizeAppliedEffect(effect, index)) const appliedEffects = sanitizeAppliedEffectsForUi(appliedSource)
: defaults.appliedEffects
this.form = { this.form = {
...defaults, ...defaults,
text: payload.text ?? clipCaptionState.text ?? defaults.text, text: payload.text ?? clipCaptionState.text ?? defaults.text,
position: resolvedPosition || defaults.position, position: resolvedPosition || defaults.position,
audioLevel: typeof payload.audioLevel === "number" ? payload.audioLevel : defaults.audioLevel, audioLevel: typeof payload.audioLevel === "number" ? payload.audioLevel : clipEffectsState.audioLevel,
motion: { motion: {
...defaults.motion, ...DEFAULT_MOTION_STATE,
...clipEffectsState.motion,
...(payload.motion || {}) ...(payload.motion || {})
}, },
appliedEffects, appliedEffects,
selectedEffectId: "" selectedEffectId: ""
} }
this.snapshot = JSON.parse(JSON.stringify(this.form)) this.snapshot = JSON.parse(JSON.stringify(this.form))
this.sortAppliedEffects()
this.ensureModalInstance().show() this.ensureModalInstance().show()
this.$nextTick(() => { this.$nextTick(() => {
this.focusTextInput() this.focusTextInput()
...@@ -398,7 +384,15 @@ export default { ...@@ -398,7 +384,15 @@ export default {
} }
const selected = this.normalizedEffectOptions.find(effect => effect.value === this.form.selectedEffectId) const selected = this.normalizedEffectOptions.find(effect => effect.value === this.form.selectedEffectId)
if (selected) { if (selected) {
this.form.appliedEffects.push(selected) const definition = selected.definition || selected
const instantiated = instantiateEffectFromDefinition(definition, this.form.appliedEffects.length)
if (instantiated) {
const normalized = sanitizeAppliedEffectsForUi([instantiated])[0]
if (normalized) {
this.form.appliedEffects.push(normalized)
this.sortAppliedEffects()
}
}
} }
this.form.selectedEffectId = "" this.form.selectedEffectId = ""
}, },
...@@ -434,6 +428,9 @@ export default { ...@@ -434,6 +428,9 @@ export default {
return true return true
} }
return false return false
},
sortAppliedEffects() {
this.form.appliedEffects = sortEffectsAlphabetically(this.form.appliedEffects || [])
} }
}, },
mounted() { mounted() {
......
...@@ -131,7 +131,7 @@ ...@@ -131,7 +131,7 @@
import {mapActions, mapState, mapMutations, mapGetters} from "vuex" import {mapActions, mapState, mapMutations, mapGetters} from "vuex"
import { Modal } from "bootstrap" import { Modal } from "bootstrap"
import ClipEffectsModal from "./ClipEffectsModal" import ClipEffectsModal from "./ClipEffectsModal"
import { createClipWithCaptionForm, hasCaptionChanges } from "../utils/captions" import { createClipWithEffectsForm, hasClipEffectsChanges } from "../utils/clipEffects"
export default { export default {
name: "Clips.vue", name: "Clips.vue",
...@@ -156,6 +156,9 @@ export default { ...@@ -156,6 +156,9 @@ export default {
openClipEffectsModal(clipObj, index) { openClipEffectsModal(clipObj, index) {
const fileName = clipObj?.name || clipObj?.file_name || "" const fileName = clipObj?.name || clipObj?.file_name || ""
const appliedEffects = Array.isArray(clipObj?.json?.effects) ? clipObj.json.effects : [] const appliedEffects = Array.isArray(clipObj?.json?.effects) ? clipObj.json.effects : []
if (!this.preview.clip || this.preview.clip.id !== clipObj.id) {
this.setPreviewClip(clipObj)
}
if (this.$refs.clipEffectsModal) { if (this.$refs.clipEffectsModal) {
this.$refs.clipEffectsModal.open({ this.$refs.clipEffectsModal.open({
clip: clipObj, clip: clipObj,
...@@ -193,11 +196,11 @@ export default { ...@@ -193,11 +196,11 @@ export default {
return return
} }
const currentFxEnabled = !!clipObj.json?.fx_enabled const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasCaptionChanges(clipObj, form) || currentFxEnabled !== fxEnabled const needsUpdate = hasClipEffectsChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) { if (!needsUpdate) {
return return
} }
const updatedClip = createClipWithCaptionForm(clipObj, form, { fxEnabled }) const updatedClip = createClipWithEffectsForm(clipObj, form, { fxEnabled })
if (!updatedClip) { if (!updatedClip) {
return return
} }
......
...@@ -113,7 +113,8 @@ ...@@ -113,7 +113,8 @@
import {mapState, mapActions, mapGetters, mapMutations} from "vuex"; import {mapState, mapActions, mapGetters, mapMutations} from "vuex";
import {fixImageDuration} from "../store/axios"; import {fixImageDuration} from "../store/axios";
import ClipEffectsModal from "./ClipEffectsModal"; import ClipEffectsModal from "./ClipEffectsModal";
import { createClipWithCaptionForm, hasCaptionChanges, syncCaptionEffectWithClip } from "../utils/captions"; import { syncCaptionEffectWithClip } from "../utils/captions";
import { createClipWithEffectsForm, getClipMotionState, hasClipEffectsChanges, syncClipMotionWithClip } from "../utils/clipEffects";
const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'avif', 'svg', 'tif', 'tiff', 'heic', 'heif']; const IMAGE_FILE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'avif', 'svg', 'tif', 'tiff', 'heic', 'heif'];
const AUDIO_FILE_EXTENSIONS = ['mp3', 'aac', 'm4a', 'wav', 'oga', 'ogg', 'flac', 'opus', 'wma', 'aif', 'aiff']; const AUDIO_FILE_EXTENSIONS = ['mp3', 'aac', 'm4a', 'wav', 'oga', 'ogg', 'flac', 'opus', 'wma', 'aif', 'aiff'];
...@@ -202,9 +203,11 @@ export default { ...@@ -202,9 +203,11 @@ export default {
{ {
// Edit existing clip // Edit existing clip
let clipObj = this.preview.clip let clipObj = this.preview.clip
const existingMotionState = getClipMotionState(clipObj)
clipObj.start = this.preview.start * fixImageDuration(this.preview.file.json.duration) clipObj.start = this.preview.start * fixImageDuration(this.preview.file.json.duration)
clipObj.end = this.preview.end * fixImageDuration(this.preview.file.json.duration) clipObj.end = this.preview.end * fixImageDuration(this.preview.file.json.duration)
syncCaptionEffectWithClip(clipObj) syncCaptionEffectWithClip(clipObj)
syncClipMotionWithClip(clipObj, existingMotionState)
let payload = { data: clipObj, latest: false, thumbnail: true } let payload = { data: clipObj, latest: false, thumbnail: true }
await this.editClip(payload) await this.editClip(payload)
await this.normalizeClipPositions() await this.normalizeClipPositions()
...@@ -604,12 +607,12 @@ export default { ...@@ -604,12 +607,12 @@ export default {
return null return null
} }
const currentFxEnabled = !!clipObj.json?.fx_enabled const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasCaptionChanges(clipObj, form) || currentFxEnabled !== fxEnabled const needsUpdate = hasClipEffectsChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) { if (!needsUpdate) {
clipObj.json.fx_enabled = fxEnabled clipObj.json.fx_enabled = fxEnabled
return clipObj return clipObj
} }
const updatedClip = createClipWithCaptionForm(clipObj, form, { fxEnabled }) const updatedClip = createClipWithEffectsForm(clipObj, form, { fxEnabled })
if (!updatedClip) { if (!updatedClip) {
return null return null
} }
......
import { v4 as uuidv4 } from "uuid"
import captionTemplate from "../data/caption.json"
export const CAPTION_POSITION_MAP = {
top: 0.1,
center: 0.45,
bottom: 0.75
}
export const DEFAULT_CAPTION_POSITION = 'bottom'
const CAPTION_KEYWORDS = ['caption']
function deepClone(value) {
return JSON.parse(JSON.stringify(value))
}
function padNumber(value, size) {
const normalized = Math.max(0, Math.floor(Math.abs(value)))
return normalized.toString().padStart(size, '0')
}
function secondsToTimestamp(seconds) {
const normalized = Number.isFinite(seconds) ? Math.max(0, seconds) : 0
const totalMilliseconds = Math.round(normalized * 1000)
const hours = Math.floor(totalMilliseconds / 3600000)
const minutes = Math.floor((totalMilliseconds % 3600000) / 60000)
const secs = Math.floor((totalMilliseconds % 60000) / 1000)
const millis = totalMilliseconds % 1000
return `${padNumber(hours, 2)}:${padNumber(minutes, 2)}:${padNumber(secs, 2)}:${padNumber(millis, 3)}`
}
function buildCaptionTimestampText(clip) {
const start = Number.isFinite(clip?.start) ? clip.start : 0
let end = Number.isFinite(clip?.end) ? clip.end : start
if (end < start) {
end = start
}
const startText = secondsToTimestamp(start)
const endText = secondsToTimestamp(end)
return { start, end, startText, endText }
}
function extractCaptionBody(effect) {
if (!effect || typeof effect.caption_text !== 'string') {
return ""
}
const sanitized = effect.caption_text.replace(/\r/g, '')
const newlineIndex = sanitized.indexOf('\n')
if (newlineIndex === -1) {
return sanitized.trim()
}
return sanitized.slice(newlineIndex + 1).replace(/\n+$/g, '').trim()
}
export function isCaptionEffect(effect) {
if (!effect) {
return false
}
const identifiers = [
effect.class_name,
effect.type,
effect.title,
effect.name,
effect.display_name
]
return identifiers.some(identifier => {
if (typeof identifier !== 'string') {
return false
}
const normalized = identifier.toLowerCase()
return CAPTION_KEYWORDS.some(keyword => normalized.includes(keyword))
})
}
function getClipEffects(clip) {
if (Array.isArray(clip?.json?.effects)) {
return clip.json.effects
}
return []
}
function findCaptionEffectInEffects(effects) {
if (!Array.isArray(effects) || effects.length === 0) {
return { effect: null, index: -1 }
}
const index = effects.findIndex(effect => isCaptionEffect(effect))
if (index === -1) {
return { effect: null, index: -1 }
}
return { effect: effects[index], index }
}
function findCaptionEffect(clip) {
const effects = getClipEffects(clip)
return findCaptionEffectInEffects(effects)
}
function getEffectTopValue(effect) {
if (!effect || !effect.top || !Array.isArray(effect.top.Points) || effect.top.Points.length === 0) {
return null
}
const point = effect.top.Points[0]
if (point && point.co && typeof point.co.Y === 'number') {
return point.co.Y
}
return null
}
function ensureEffectTop(effect, topValue) {
if (!effect.top || !Array.isArray(effect.top.Points) || effect.top.Points.length === 0) {
if (captionTemplate?.top && Array.isArray(captionTemplate.top.Points)) {
effect.top = deepClone(captionTemplate.top)
} else {
effect.top = {
Points: [
{
co: { X: 1, Y: captionPositionToTop(DEFAULT_CAPTION_POSITION) },
handle_type: 0,
interpolation: 0
}
]
}
}
}
if (!effect.top.Points[0].co) {
effect.top.Points[0].co = { X: 1, Y: captionPositionToTop(DEFAULT_CAPTION_POSITION) }
}
effect.top.Points[0].co.Y = topValue
}
function ensureEffectIdentity(effect) {
if (typeof effect.id !== 'string' || !effect.id.length) {
effect.id = uuidv4().split('-')[0]
}
}
function applyEffectTiming(effect, timing) {
effect.position = 0
effect.start = timing.start
effect.end = timing.end
effect.duration = Math.max(0, timing.end - timing.start)
}
function buildCaptionEffect(baseEffect, textValue, clip, normalizedPosition) {
const effect = baseEffect ? deepClone(baseEffect) : deepClone(captionTemplate)
ensureEffectIdentity(effect)
const timing = buildCaptionTimestampText(clip)
applyEffectTiming(effect, timing)
effect.caption_text = `${timing.startText} --> ${timing.endText}\n${textValue}`
effect.apply_before_clip = false
ensureEffectTop(effect, captionPositionToTop(normalizedPosition))
return effect
}
export function normalizeCaptionPosition(position) {
if (typeof position !== 'string') {
return DEFAULT_CAPTION_POSITION
}
const normalized = position.toLowerCase()
if (Object.prototype.hasOwnProperty.call(CAPTION_POSITION_MAP, normalized)) {
return normalized
}
return DEFAULT_CAPTION_POSITION
}
export function captionPositionToTop(position) {
const normalized = normalizeCaptionPosition(position)
return CAPTION_POSITION_MAP[normalized]
}
export function topValueToCaptionPosition(value) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return DEFAULT_CAPTION_POSITION
}
let closestKey = DEFAULT_CAPTION_POSITION
let smallestDelta = Infinity
Object.entries(CAPTION_POSITION_MAP).forEach(([key, topValue]) => {
const delta = Math.abs(topValue - value)
if (delta < smallestDelta) {
smallestDelta = delta
closestKey = key
}
})
return closestKey
}
export function getClipCaptionState(clip) {
const { effect } = findCaptionEffect(clip)
const effectText = extractCaptionBody(effect)
const legacyText = clip?.json?.text
const text = effectText || (typeof legacyText === 'string' ? legacyText : "")
const effectTop = getEffectTopValue(effect)
const storedTop = typeof clip?.json?.caption_top === 'number' ? clip.json.caption_top : null
const storedPosition = clip?.json?.caption_position
let normalizedPosition = DEFAULT_CAPTION_POSITION
let captionTop = captionPositionToTop(normalizedPosition)
if (typeof effectTop === 'number') {
captionTop = effectTop
normalizedPosition = topValueToCaptionPosition(effectTop)
} else if (typeof storedTop === 'number') {
captionTop = storedTop
normalizedPosition = topValueToCaptionPosition(storedTop)
} else if (storedPosition) {
normalizedPosition = normalizeCaptionPosition(storedPosition)
captionTop = captionPositionToTop(normalizedPosition)
}
return {
text,
position: normalizedPosition,
top: captionTop,
hasCaptionEffect: !!effect
}
}
export function createClipWithCaptionForm(clip, form, options = {}) {
if (!clip) {
return null
}
const normalizedPosition = normalizeCaptionPosition(form?.position)
const textValue = form?.text ?? ""
const nextClip = {
...clip,
json: {
...(clip.json || {})
}
}
const existingEffects = Array.isArray(clip?.json?.effects)
? clip.json.effects.map(effect => deepClone(effect))
: []
const { index } = findCaptionEffectInEffects(existingEffects)
const hasText = typeof textValue === 'string' && textValue.length > 0
if (hasText) {
const existingEffect = index >= 0 ? existingEffects[index] : null
const updatedEffect = buildCaptionEffect(existingEffect, textValue, nextClip, normalizedPosition)
if (index >= 0) {
existingEffects[index] = updatedEffect
} else {
existingEffects.push(updatedEffect)
}
} else if (index >= 0) {
existingEffects.splice(index, 1)
}
if (existingEffects.length > 0) {
nextClip.json.effects = existingEffects
} else {
delete nextClip.json.effects
}
delete nextClip.json.text
delete nextClip.json.caption_top
delete nextClip.json.caption_position
nextClip.json.fx_enabled = !!options.fxEnabled
return nextClip
}
export function syncCaptionEffectWithClip(clip) {
if (!clip || !clip.json || !Array.isArray(clip.json.effects)) {
return clip
}
const { index } = findCaptionEffect(clip)
if (index === -1) {
return clip
}
const state = getClipCaptionState(clip)
if (!state.text) {
clip.json.effects.splice(index, 1)
if (clip.json.effects.length === 0) {
delete clip.json.effects
}
return clip
}
const updatedEffect = buildCaptionEffect(clip.json.effects[index], state.text, clip, state.position)
clip.json.effects[index] = updatedEffect
return clip
}
export function hasCaptionChanges(clip, form) {
if (!clip) {
return false
}
const currentState = getClipCaptionState(clip)
const desiredText = form?.text ?? ""
const desiredPosition = normalizeCaptionPosition(form?.position)
const wantsEffect = !!desiredText
const hasEffect = !!currentState.hasCaptionEffect
return currentState.text !== desiredText
|| currentState.position !== desiredPosition
|| wantsEffect !== hasEffect
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment