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
}
import { v4 as uuidv4 } from "uuid"
import { createClipWithCaptionForm, hasCaptionChanges, isCaptionEffect } from "./captions"
export const DEFAULT_AUDIO_LEVEL = 100
export const DEFAULT_MOTION_STATE = Object.freeze({
slideIn: false,
slideOut: false,
fadeIn: false,
fadeOut: false,
zoomIn: false,
zoomOut: false
})
export const DISALLOWED_EFFECT_KEYWORDS = ['stabilizer', 'object detector', 'tracker']
const DEFAULT_FPS = 30
const MOTION_DURATION_SECONDS = 2
const FRAME_EPSILON = 0.5
const VALUE_EPSILON = 0.05
const DEFAULT_ZOOM_IN_START = 0.8
const DEFAULT_ZOOM_OUT_END = 0.8
const DEFAULT_ZOOM_TARGET = 1
const MIN_FRAME_GAP = 1
function deepClone(value) {
if (value === undefined) {
return undefined
}
return JSON.parse(JSON.stringify(value))
}
function clamp(value, min, max) {
if (!Number.isFinite(value)) {
return min
}
if (value < min) {
return min
}
if (value > max) {
return max
}
return value
}
function sanitizeAudioLevel(level) {
if (!Number.isFinite(level)) {
return DEFAULT_AUDIO_LEVEL
}
return clamp(Math.round(level), 0, 100)
}
function getClipDurationSeconds(clip) {
const start = Number.isFinite(clip?.start) ? clip.start : 0
const end = Number.isFinite(clip?.end) ? clip.end : start
return Math.max(0.001, end - start)
}
function getClipFps(clip) {
const fps = clip?.json?.reader?.fps
if (fps && Number.isFinite(fps.num) && Number.isFinite(fps.den) && fps.den !== 0) {
const computed = fps.num / fps.den
if (Number.isFinite(computed) && computed > 0) {
return computed
}
}
return DEFAULT_FPS
}
function getClipFrameCount(clip) {
const fps = getClipFps(clip)
return Math.max(1, Math.round(getClipDurationSeconds(clip) * fps))
}
function getClipStartSeconds(clip) {
if (Number.isFinite(clip?.start)) {
return Math.max(0, clip.start)
}
return 0
}
function getClipStartFrame(clip, fps) {
const startSeconds = getClipStartSeconds(clip)
return Math.max(0, Math.round(startSeconds * fps))
}
function getClipEndFrame(clip, fps, clipFrames) {
return getClipStartFrame(clip, fps) + clipFrames
}
function getMotionDurationFrames(fps, clipFrames) {
const duration = Math.max(1, Math.round(fps * MOTION_DURATION_SECONDS))
return Math.max(1, Math.min(duration, clipFrames))
}
function createPoint(frame, value, interpolation = 0, handleType = 0) {
return {
co: {
X: Number.isFinite(frame) ? frame : 0,
Y: Number.isFinite(value) ? value : 0
},
handle_type: handleType,
interpolation
}
}
function pushPoint(points, frame, value, clampFn) {
const clampedValue = clampFn ? clampFn(value) : value
const normalizedFrame = Number.isFinite(frame) ? frame : 0
const idx = points.findIndex(point => Math.abs(point.co.X - normalizedFrame) <= FRAME_EPSILON)
if (idx >= 0) {
points[idx] = createPoint(normalizedFrame, clampedValue)
return
}
points.push(createPoint(normalizedFrame, clampedValue))
}
function sortPoints(points) {
return points
.slice()
.sort((a, b) => a.co.X - b.co.X)
}
export function normalizeEffectOption(effect, fallbackIndex = 0) {
if (effect === null || effect === undefined) {
return {
value: `effect-${fallbackIndex}`,
label: `Effect ${fallbackIndex + 1}`
}
}
if (typeof effect === 'string') {
return {
name: effect,
value: effect,
label: effect,
definition: { name: effect, value: effect }
}
}
const clone = deepClone(effect)
const label = effect.title || effect.name || effect.display_name || effect.slug || effect.value || `Effect ${fallbackIndex + 1}`
const value = effect.id || effect.slug || effect.value || effect.name || `effect-${fallbackIndex}`
return { label, value, definition: clone }
}
function effectIdentifier(effect, fallbackIndex = 0) {
if (!effect) {
return `effect-${fallbackIndex}`
}
if (typeof effect === 'string') {
return effect.toLowerCase()
}
const candidate = effect.id || effect.slug || effect.value || effect.name || effect.title
if (candidate) {
return String(candidate).toLowerCase()
}
return `effect-${fallbackIndex}`
}
function effectDisplayName(effect) {
if (typeof effect === 'string') {
return effect
}
return effect.title || effect.name || effect.display_name || effect.slug || effect.value || ''
}
export function isEffectDisallowed(effect) {
if (isCaptionEffect(effect)) {
return true
}
const label = effectDisplayName(effect)
if (!label) {
return false
}
const normalized = label.toLowerCase()
return DISALLOWED_EFFECT_KEYWORDS.some(keyword => normalized.includes(keyword))
}
function filterAllowedEffects(effects) {
if (!Array.isArray(effects)) {
return []
}
return effects.filter(effect => !isEffectDisallowed(effect))
}
export function sortEffectsAlphabetically(effects) {
return (Array.isArray(effects) ? effects.slice() : []).sort((a, b) => {
const labelA = (a?.label || effectDisplayName(a) || '').toLowerCase()
const labelB = (b?.label || effectDisplayName(b) || '').toLowerCase()
if (labelA < labelB) {
return -1
}
if (labelA > labelB) {
return 1
}
return 0
})
}
function normalizeEffectInstance(effect, fallbackIndex = 0) {
const clone = deepClone(effect) || {}
clone.label = clone.label || effectDisplayName(clone) || `Effect ${fallbackIndex + 1}`
clone.value = clone.value || effectIdentifier(clone, fallbackIndex)
return clone
}
export function sanitizeAppliedEffectsForUi(effects) {
const allowed = filterAllowedEffects(effects)
const normalized = allowed.map((effect, index) => normalizeEffectInstance(effect, index))
return sortEffectsAlphabetically(normalized)
}
function stripUiMetadata(effect) {
if (!effect) {
return effect
}
const clone = deepClone(effect)
if (clone) {
delete clone.label
delete clone.value
delete clone.definition
}
return clone
}
function dedupeEffects(effects) {
const seen = Object.create(null)
const result = []
for (let i = 0; i < effects.length; i++) {
const effect = effects[i]
const identifier = effectIdentifier(effect, i)
if (!seen[identifier]) {
seen[identifier] = true
result.push(deepClone(effect))
}
}
return result
}
function getClipEffectsWithoutCaptions(clip) {
if (!clip || !Array.isArray(clip?.json?.effects)) {
return []
}
return clip.json.effects.filter(effect => !isCaptionEffect(effect))
}
function prepareEffectsForSave(effects) {
const allowed = filterAllowedEffects(effects)
const normalized = allowed.map((effect, index) => normalizeEffectInstance(effect, index))
const deduped = dedupeEffects(normalized)
return deduped.map(effect => stripUiMetadata(effect))
}
export function buildClipEffectsFormState(clip) {
return {
audioLevel: getClipAudioLevelPercent(clip),
motion: getClipMotionState(clip),
appliedEffects: sanitizeAppliedEffectsForUi(getClipEffectsWithoutCaptions(clip))
}
}
function applyEffectsToClip(clip, effects) {
const captionEffects = Array.isArray(clip?.json?.effects)
? clip.json.effects.filter(effect => isCaptionEffect(effect))
: []
const prepared = prepareEffectsForSave(effects)
const combined = [...captionEffects, ...prepared]
if (combined.length > 0) {
clip.json.effects = combined
} else if (captionEffects.length > 0) {
clip.json.effects = captionEffects
} else {
delete clip.json.effects
}
}
export function getClipAudioLevelPercent(clip) {
const point = clip?.json?.volume?.Points?.[0]
if (point && point.co && Number.isFinite(point.co.Y)) {
return clamp(Math.round(point.co.Y * 100), 0, 100)
}
return DEFAULT_AUDIO_LEVEL
}
function applyVolumeToClip(clip, audioLevel) {
const normalized = sanitizeAudioLevel(audioLevel) / 100
clip.json.volume = {
Points: [
createPoint(1, normalized)
]
}
}
function getPoints(config) {
if (!config || !Array.isArray(config.Points)) {
return []
}
return config.Points.filter(point => point && point.co && Number.isFinite(point.co.X) && Number.isFinite(point.co.Y))
}
function sanitizeMotionState(motion) {
const normalized = { ...DEFAULT_MOTION_STATE }
if (!motion) {
return normalized
}
Object.keys(DEFAULT_MOTION_STATE).forEach(key => {
normalized[key] = !!motion[key]
})
return normalized
}
function applyMotionToClip(clip, motion) {
const fps = getClipFps(clip)
const clipFrames = getClipFrameCount(clip)
const sanitized = sanitizeMotionState(motion)
const slidePoints = buildSlidePoints(sanitized, clip, fps, clipFrames)
if (slidePoints && slidePoints.length > 0) {
clip.json.location_x = { Points: slidePoints }
} else {
delete clip.json.location_x
}
const fadePoints = buildFadePoints(sanitized, clip, fps, clipFrames)
if (fadePoints && fadePoints.length > 0) {
clip.json.alpha = { Points: fadePoints }
} else {
delete clip.json.alpha
}
const zoomPoints = buildZoomPoints(sanitized, clip, fps, clipFrames)
if (zoomPoints && zoomPoints.length > 0) {
clip.json.scale_x = { Points: zoomPoints }
clip.json.scale_y = { Points: zoomPoints.map(point => deepClone(point)) }
} else {
delete clip.json.scale_x
delete clip.json.scale_y
}
}
function buildSlidePoints(motion, clip, fps, clipFrames) {
if (!motion.slideIn && !motion.slideOut) {
return null
}
const points = []
const duration = getMotionDurationFrames(fps, clipFrames)
const startFrame = getClipStartFrame(clip, fps)
const endFrame = getClipEndFrame(clip, fps, clipFrames)
let exitFrame = startFrame
if (motion.slideIn) {
let slideExit = Math.min(endFrame - (motion.slideOut ? MIN_FRAME_GAP : 0), startFrame + duration)
if (slideExit <= startFrame && endFrame - startFrame > MIN_FRAME_GAP) {
slideExit = Math.min(endFrame - MIN_FRAME_GAP, startFrame + duration)
}
if (slideExit > startFrame) {
exitFrame = slideExit
pushPoint(points, startFrame, -1, value => clamp(value, -1, 1))
pushPoint(points, slideExit, 0, value => clamp(value, -1, 1))
}
}
if (motion.slideOut) {
let entryFrame = Math.max(motion.slideIn ? startFrame + MIN_FRAME_GAP : startFrame, endFrame - duration)
if (motion.slideIn && entryFrame <= exitFrame) {
entryFrame = Math.min(endFrame - MIN_FRAME_GAP, Math.max(exitFrame, entryFrame))
}
entryFrame = Math.min(entryFrame, endFrame - MIN_FRAME_GAP)
if (entryFrame < startFrame) {
entryFrame = startFrame
}
if (entryFrame < endFrame) {
pushPoint(points, entryFrame, 0, value => clamp(value, -1, 1))
pushPoint(points, endFrame, 1, value => clamp(value, -1, 1))
}
}
return sortPoints(points)
}
function buildFadePoints(motion, clip, fps, clipFrames) {
if (!motion.fadeIn && !motion.fadeOut) {
return null
}
const points = []
const duration = getMotionDurationFrames(fps, clipFrames)
const startFrame = getClipStartFrame(clip, fps)
const endFrame = getClipEndFrame(clip, fps, clipFrames)
let fadeInEnd = startFrame
if (motion.fadeIn) {
let fadeExit = Math.min(endFrame - (motion.fadeOut ? MIN_FRAME_GAP : 0), startFrame + duration)
if (fadeExit <= startFrame && endFrame - startFrame > MIN_FRAME_GAP) {
fadeExit = Math.min(endFrame - MIN_FRAME_GAP, startFrame + duration)
}
if (fadeExit > startFrame) {
fadeInEnd = fadeExit
pushPoint(points, startFrame, 0, value => clamp(value, 0, 1))
pushPoint(points, fadeExit, 1, value => clamp(value, 0, 1))
}
}
if (motion.fadeOut) {
let fadeStart = Math.max(motion.fadeIn ? startFrame + MIN_FRAME_GAP : startFrame, endFrame - duration)
if (motion.fadeIn && fadeStart <= fadeInEnd) {
fadeStart = Math.min(endFrame - MIN_FRAME_GAP, Math.max(fadeInEnd, fadeStart))
}
fadeStart = Math.min(fadeStart, endFrame - MIN_FRAME_GAP)
if (fadeStart < startFrame) {
fadeStart = startFrame
}
if (fadeStart < endFrame) {
pushPoint(points, fadeStart, 1, value => clamp(value, 0, 1))
pushPoint(points, endFrame, 0, value => clamp(value, 0, 1))
}
}
return sortPoints(points)
}
function buildZoomPoints(motion, clip, fps, clipFrames) {
if (!motion.zoomIn && !motion.zoomOut) {
return null
}
const points = []
const startFrame = getClipStartFrame(clip, fps)
const endFrame = getClipEndFrame(clip, fps, clipFrames)
if (motion.zoomIn && motion.zoomOut) {
const midpoint = Math.max(startFrame + 1, Math.min(endFrame - 1, startFrame + Math.round(clipFrames / 2)))
pushPoint(points, startFrame, DEFAULT_ZOOM_IN_START, value => clamp(value, 0, 1))
pushPoint(points, midpoint, DEFAULT_ZOOM_TARGET, value => clamp(value, 0, 1))
pushPoint(points, endFrame, DEFAULT_ZOOM_OUT_END, value => clamp(value, 0, 1))
} else if (motion.zoomIn) {
pushPoint(points, startFrame, DEFAULT_ZOOM_IN_START, value => clamp(value, 0, 1))
pushPoint(points, endFrame, DEFAULT_ZOOM_TARGET, value => clamp(value, 0, 1))
} else if (motion.zoomOut) {
pushPoint(points, startFrame, DEFAULT_ZOOM_TARGET, value => clamp(value, 0, 1))
pushPoint(points, endFrame, DEFAULT_ZOOM_OUT_END, value => clamp(value, 0, 1))
}
return sortPoints(points)
}
function detectSlideIn(points, fps, clipFrames, clipStartFrame) {
if (points.length < 2) {
return false
}
const duration = getMotionDurationFrames(fps, clipFrames)
const sorted = sortPoints(points)
const maxFrame = clipStartFrame + duration + FRAME_EPSILON
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const curr = sorted[i]
if (curr.co.X > maxFrame) {
break
}
if (prev.co.X < clipStartFrame - FRAME_EPSILON) {
continue
}
if ((curr.co.Y - prev.co.Y) > VALUE_EPSILON && prev.co.Y <= -0.5 && Math.abs(curr.co.Y) <= VALUE_EPSILON) {
return true
}
}
return false
}
function detectSlideOut(points, fps, clipFrames, clipStartFrame) {
if (points.length < 2) {
return false
}
const duration = getMotionDurationFrames(fps, clipFrames)
const startFrame = clipStartFrame + Math.max(0, clipFrames - duration) - FRAME_EPSILON
const endFrame = clipStartFrame + clipFrames + FRAME_EPSILON
const sorted = sortPoints(points)
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const curr = sorted[i]
if (curr.co.X < startFrame) {
continue
}
if (prev.co.X > endFrame) {
break
}
if ((curr.co.Y - prev.co.Y) > VALUE_EPSILON && curr.co.Y >= 0.5 && Math.abs(prev.co.Y) <= VALUE_EPSILON) {
return true
}
}
return false
}
function detectFadeIn(points, fps, clipFrames, clipStartFrame) {
if (points.length < 2) {
return false
}
const duration = getMotionDurationFrames(fps, clipFrames)
const sorted = sortPoints(points)
const maxFrame = clipStartFrame + duration + FRAME_EPSILON
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const curr = sorted[i]
if (curr.co.X > maxFrame) {
break
}
if (prev.co.X < clipStartFrame - FRAME_EPSILON) {
continue
}
if ((curr.co.Y - prev.co.Y) > VALUE_EPSILON && prev.co.Y <= VALUE_EPSILON && curr.co.Y >= 1 - VALUE_EPSILON) {
return true
}
}
return false
}
function detectFadeOut(points, fps, clipFrames, clipStartFrame) {
if (points.length < 2) {
return false
}
const duration = getMotionDurationFrames(fps, clipFrames)
const startFrame = clipStartFrame + Math.max(0, clipFrames - duration) - FRAME_EPSILON
const endFrame = clipStartFrame + clipFrames + FRAME_EPSILON
const sorted = sortPoints(points)
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const curr = sorted[i]
if (curr.co.X < startFrame) {
continue
}
if (prev.co.X > endFrame) {
break
}
if ((prev.co.Y - curr.co.Y) > VALUE_EPSILON && prev.co.Y >= 1 - VALUE_EPSILON && curr.co.Y <= VALUE_EPSILON) {
return true
}
}
return false
}
function detectZoom(points) {
const sorted = sortPoints(points)
let zoomIn = false
let zoomOut = false
for (let i = 1; i < sorted.length; i++) {
const prev = sorted[i - 1]
const curr = sorted[i]
if ((curr.co.Y - prev.co.Y) > VALUE_EPSILON) {
zoomIn = true
} else if ((prev.co.Y - curr.co.Y) > VALUE_EPSILON) {
zoomOut = true
}
}
return { zoomIn, zoomOut }
}
export function getClipMotionState(clip) {
const fps = getClipFps(clip)
const clipFrames = getClipFrameCount(clip)
const clipStartFrame = getClipStartFrame(clip, fps)
const motion = { ...DEFAULT_MOTION_STATE }
const slidePoints = getPoints(clip?.json?.location_x)
motion.slideIn = detectSlideIn(slidePoints, fps, clipFrames, clipStartFrame)
motion.slideOut = detectSlideOut(slidePoints, fps, clipFrames, clipStartFrame)
const alphaPoints = getPoints(clip?.json?.alpha)
motion.fadeIn = detectFadeIn(alphaPoints, fps, clipFrames, clipStartFrame)
motion.fadeOut = detectFadeOut(alphaPoints, fps, clipFrames, clipStartFrame)
const scalePoints = getPoints(clip?.json?.scale_x || clip?.json?.scale_y)
const zoomState = detectZoom(scalePoints)
motion.zoomIn = zoomState.zoomIn
motion.zoomOut = zoomState.zoomOut
return motion
}
function haveMotionStatesChanged(current, desired) {
const keys = Object.keys(DEFAULT_MOTION_STATE)
for (const key of keys) {
if (!!current[key] !== !!desired[key]) {
return true
}
}
return false
}
function getEffectIdentifiers(effects) {
const identifiers = []
for (let i = 0; i < effects.length; i++) {
identifiers.push(effectIdentifier(effects[i], i))
}
identifiers.sort()
return identifiers
}
function haveEffectsChanged(clip, appliedEffects) {
const current = sanitizeAppliedEffectsForUi(getClipEffectsWithoutCaptions(clip))
const desired = sanitizeAppliedEffectsForUi(appliedEffects)
const currentIds = getEffectIdentifiers(current)
const desiredIds = getEffectIdentifiers(desired)
if (currentIds.length !== desiredIds.length) {
return true
}
for (let i = 0; i < currentIds.length; i++) {
if (currentIds[i] !== desiredIds[i]) {
return true
}
}
return false
}
export function createClipWithEffectsForm(clip, form, options = {}) {
const nextClip = createClipWithCaptionForm(clip, form, options)
if (!nextClip || !nextClip.json) {
return nextClip
}
applyVolumeToClip(nextClip, form?.audioLevel)
applyMotionToClip(nextClip, form?.motion)
applyEffectsToClip(nextClip, form?.appliedEffects)
return nextClip
}
function generateEffectId() {
return uuidv4().replace(/-/g, '').slice(0, 10).toUpperCase()
}
function isBooleanLikeConfig(config) {
if (!config) {
return false
}
if (config.type === 'bool') {
return true
}
if ((config.type === 'int' || config.type === 'float') && config.min === 0 && config.max === 1) {
return true
}
return false
}
function buildKeyframedProperty(config) {
const interpolation = Number.isFinite(config?.interpolation) ? config.interpolation : 0
const handleType = Number.isFinite(config?.handle_type) ? config.handle_type : 0
const yValue = Number(config?.value) || 0
return {
Points: [
createPoint(1, yValue, interpolation, handleType)
]
}
}
function coerceNumericValue(config) {
if (!config) {
return 0
}
const raw = Number(config.value)
if (Number.isFinite(raw)) {
if (config.type === 'int') {
return Math.round(raw)
}
return raw
}
return 0
}
function coerceStringValue(config) {
if (!config) {
return ""
}
if (typeof config.value === 'string') {
return config.value
}
if (config.value === null || config.value === undefined || config.value === 0) {
if (typeof config.memo === 'string') {
return config.memo
}
return ""
}
return String(config.value)
}
function buildEffectProperty(propName, config) {
if (!config) {
return null
}
if (propName === 'id') {
return null
}
if (config.keyframe) {
return buildKeyframedProperty(config)
}
if (propName === 'parent_effect_id') {
return coerceStringValue(config)
}
if (propName === 'apply_before_clip') {
if (typeof config.value === 'boolean') {
return config.value
}
if (isBooleanLikeConfig(config)) {
return !!config.value
}
return false
}
if (isBooleanLikeConfig(config)) {
return !!config.value
}
if (config.type === 'string') {
return coerceStringValue(config)
}
if (config.type === 'int' || config.type === 'float') {
return coerceNumericValue(config)
}
return config.value
}
export function instantiateEffectFromDefinition(definition, fallbackIndex = 0) {
if (!definition) {
return null
}
const effect = {
class_name: definition.class_name || definition.slug || definition.value || definition.name || "",
type: definition.type || definition.class_name || definition.slug || definition.value || "",
name: definition.name || definition.label || definition.value || `Effect ${fallbackIndex + 1}`,
description: definition.description || "",
has_audio: !!definition.has_audio,
has_video: !!definition.has_video,
has_tracked_object: !!definition.has_tracked_object,
order: typeof definition.order === 'number' ? definition.order : 0,
position: 0,
start: 0,
end: 0,
duration: 0,
layer: 0,
apply_before_clip: false,
parent_effect_id: "",
id: generateEffectId()
}
if (definition.ui) {
effect.ui = deepClone(definition.ui)
}
if (Array.isArray(definition.properties)) {
for (const entry of definition.properties) {
if (!Array.isArray(entry) || entry.length < 2) {
continue
}
const [propName, config] = entry
if (!propName) {
continue
}
const value = buildEffectProperty(propName, config)
if (value === null || value === undefined) {
continue
}
effect[propName] = value
}
}
if (!effect.type) {
effect.type = effect.class_name || effect.name
}
if (!effect.value) {
effect.value = definition.id || definition.slug || definition.value || effect.name
}
effect.apply_before_clip = !!effect.apply_before_clip
if (typeof effect.parent_effect_id !== 'string') {
effect.parent_effect_id = ''
}
if (!effect.has_tracked_object) {
effect.has_tracked_object = false
}
effect.label = effect.name
return effect
}
export function syncClipMotionWithClip(clip, existingMotionState = null) {
if (!clip || !clip.json) {
return clip
}
const motionState = existingMotionState
? sanitizeMotionState(existingMotionState)
: getClipMotionState(clip)
applyMotionToClip(clip, motionState)
return clip
}
export function hasClipEffectsChanges(clip, form) {
if (!clip || !form) {
return false
}
if (hasCaptionChanges(clip, form)) {
return true
}
const desiredAudio = sanitizeAudioLevel(form.audioLevel)
if (Math.abs(getClipAudioLevelPercent(clip) - desiredAudio) > 0.5) {
return true
}
const currentMotion = getClipMotionState(clip)
const desiredMotion = sanitizeMotionState(form.motion)
if (haveMotionStatesChanged(currentMotion, desiredMotion)) {
return true
}
if (haveEffectsChanged(clip, form.appliedEffects)) {
return true
}
return false
}
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