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 {
normalizeCaptionPosition,
topValueToCaptionPosition
} from "../utils/captions";
import {
buildClipEffectsFormState,
DEFAULT_AUDIO_LEVEL,
DEFAULT_MOTION_STATE,
isEffectDisallowed,
normalizeEffectOption,
sanitizeAppliedEffectsForUi,
instantiateEffectFromDefinition,
sortEffectsAlphabetically
} from "../utils/clipEffects";
export default {
name: "ClipEffectsModal",
......@@ -240,12 +250,15 @@ export default {
return label
},
normalizedEffectOptions() {
let source = []
if (Array.isArray(this.effectCatalog) && this.effectCatalog.length) {
return this.effectCatalog
.filter(effect => !this.isCaptionEffect(effect))
.map((effect, index) => this.normalizeEffectOption(effect, index))
source = this.effectCatalog
} else {
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: {
......@@ -253,15 +266,8 @@ export default {
return {
text: "",
position: DEFAULT_CAPTION_POSITION,
audioLevel: 100,
motion: {
slideIn: false,
slideOut: false,
fadeIn: false,
fadeOut: false,
zoomIn: false,
zoomOut: false
},
audioLevel: DEFAULT_AUDIO_LEVEL,
motion: { ...DEFAULT_MOTION_STATE },
selectedEffectId: "",
appliedEffects: []
}
......@@ -276,28 +282,6 @@ export default {
}
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 = {}) {
this.clipContext = {
clipIndex: payload.clipIndex ?? null,
......@@ -308,6 +292,7 @@ export default {
}
this.activeClip = payload.clip || null
const defaults = this.createEmptyForm()
const clipEffectsState = buildClipEffectsFormState(payload.clip)
const clipCaptionState = getClipCaptionState(payload.clip)
const overridePosition = typeof payload.position === "string"
? payload.position
......@@ -318,24 +303,25 @@ export default {
const resolvedPosition = overridePosition
? normalizeCaptionPosition(overridePosition)
: positionFromTop
const appliedEffects = Array.isArray(payload.appliedEffects)
const appliedSource = Array.isArray(payload.appliedEffects) && payload.appliedEffects.length > 0
? payload.appliedEffects
.filter(effect => !this.isCaptionEffect(effect))
.map((effect, index) => this.normalizeAppliedEffect(effect, index))
: defaults.appliedEffects
: clipEffectsState.appliedEffects
const appliedEffects = sanitizeAppliedEffectsForUi(appliedSource)
this.form = {
...defaults,
text: payload.text ?? clipCaptionState.text ?? defaults.text,
position: resolvedPosition || defaults.position,
audioLevel: typeof payload.audioLevel === "number" ? payload.audioLevel : defaults.audioLevel,
audioLevel: typeof payload.audioLevel === "number" ? payload.audioLevel : clipEffectsState.audioLevel,
motion: {
...defaults.motion,
...DEFAULT_MOTION_STATE,
...clipEffectsState.motion,
...(payload.motion || {})
},
appliedEffects,
selectedEffectId: ""
}
this.snapshot = JSON.parse(JSON.stringify(this.form))
this.sortAppliedEffects()
this.ensureModalInstance().show()
this.$nextTick(() => {
this.focusTextInput()
......@@ -398,7 +384,15 @@ export default {
}
const selected = this.normalizedEffectOptions.find(effect => effect.value === this.form.selectedEffectId)
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 = ""
},
......@@ -434,6 +428,9 @@ export default {
return true
}
return false
},
sortAppliedEffects() {
this.form.appliedEffects = sortEffectsAlphabetically(this.form.appliedEffects || [])
}
},
mounted() {
......
......@@ -131,7 +131,7 @@
import {mapActions, mapState, mapMutations, mapGetters} from "vuex"
import { Modal } from "bootstrap"
import ClipEffectsModal from "./ClipEffectsModal"
import { createClipWithCaptionForm, hasCaptionChanges } from "../utils/captions"
import { createClipWithEffectsForm, hasClipEffectsChanges } from "../utils/clipEffects"
export default {
name: "Clips.vue",
......@@ -156,6 +156,9 @@ export default {
openClipEffectsModal(clipObj, index) {
const fileName = clipObj?.name || clipObj?.file_name || ""
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) {
this.$refs.clipEffectsModal.open({
clip: clipObj,
......@@ -193,11 +196,11 @@ export default {
return
}
const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasCaptionChanges(clipObj, form) || currentFxEnabled !== fxEnabled
const needsUpdate = hasClipEffectsChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) {
return
}
const updatedClip = createClipWithCaptionForm(clipObj, form, { fxEnabled })
const updatedClip = createClipWithEffectsForm(clipObj, form, { fxEnabled })
if (!updatedClip) {
return
}
......
......@@ -113,7 +113,8 @@
import {mapState, mapActions, mapGetters, mapMutations} from "vuex";
import {fixImageDuration} from "../store/axios";
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 AUDIO_FILE_EXTENSIONS = ['mp3', 'aac', 'm4a', 'wav', 'oga', 'ogg', 'flac', 'opus', 'wma', 'aif', 'aiff'];
......@@ -202,9 +203,11 @@ export default {
{
// Edit existing clip
let clipObj = this.preview.clip
const existingMotionState = getClipMotionState(clipObj)
clipObj.start = this.preview.start * fixImageDuration(this.preview.file.json.duration)
clipObj.end = this.preview.end * fixImageDuration(this.preview.file.json.duration)
syncCaptionEffectWithClip(clipObj)
syncClipMotionWithClip(clipObj, existingMotionState)
let payload = { data: clipObj, latest: false, thumbnail: true }
await this.editClip(payload)
await this.normalizeClipPositions()
......@@ -604,12 +607,12 @@ export default {
return null
}
const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasCaptionChanges(clipObj, form) || currentFxEnabled !== fxEnabled
const needsUpdate = hasClipEffectsChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) {
clipObj.json.fx_enabled = fxEnabled
return clipObj
}
const updatedClip = createClipWithCaptionForm(clipObj, form, { fxEnabled })
const updatedClip = createClipWithEffectsForm(clipObj, form, { fxEnabled })
if (!updatedClip) {
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