Commit c38d2b64 by Jonathan Thomas

Merge branch 'simple-improvements' into 'develop'

New release of simple-editor (lots of improvements)

See merge request !15
parents cbc53f7c 845cacf1
Pipeline #14941 passed with stage
in 1 minute 24 seconds
This source diff could not be displayed because it is too large. You can view the blob instead.
{
"name": "simple-editor",
"version": "0.1.0",
"version": "0.2.0",
"description": "A simple video editor built with JavaScript, Node.js, and Vue 3 (powered by OpenShot Cloud API)",
"homepage": "http://gitlab.openshot.org/public-projects/simple-editor",
"license": "MIT",
......@@ -16,42 +16,52 @@
"lint": "vue-cli-service lint"
},
"dependencies": {
"@popperjs/core": "^2.11.7",
"axios": "^1.4.0",
"bootstrap": "^5.2.3",
"core-js": "^3.30.2",
"uuid": "^9.0.0",
"vue": "^3.2.47",
"vue-gtag": "^2.0.1",
"vue-router": "^4.1.6",
"@popperjs/core": "^2.11.8",
"axios": "^1.13.2",
"bootstrap": "^5.3.8",
"core-js": "^3.46.0",
"uuid": "^13.0.0",
"vue": "^3.5.24",
"vue-gtag": "^3.6.3",
"vue-router": "^4.6.3",
"vuex": "^4.1.0"
},
"devDependencies": {
"@types/node": "^20.1.1",
"@vue/cli-plugin-babel": "^5.0.8",
"@vue/cli-plugin-router": "^5.0.8",
"@vue/cli-plugin-vuex": "^5.0.8",
"@vue/cli-service": "^5.0.8",
"@vue/compiler-sfc": "^3.2.47",
"babel-eslint": "^10.1.0",
"eslint-plugin-vue": "^9.11.1",
"eslint": "^6.7.2",
"path-browserify": "^1.0.1"
"@babel/eslint-parser": "^7.28.5",
"@types/node": "^24.10.1",
"@vue/cli-plugin-babel": "^5.0.9",
"@vue/cli-plugin-eslint": "^5.0.9",
"@vue/cli-plugin-router": "^5.0.9",
"@vue/cli-plugin-vuex": "^5.0.9",
"@vue/cli-service": "^5.0.9",
"@vue/compiler-sfc": "^3.5.24",
"eslint": "^8.57.0",
"eslint-plugin-vue": "^10.5.1",
"path-browserify": "^1.0.1",
"vue-eslint-parser": "^10.2.0"
},
"eslintConfig": {
"root": true,
"env": {
"node": true
"node": true,
"browser": true
},
"extends": [
"plugin:vue/vue3-essential",
"plugin:vue/essential",
"eslint:recommended"
],
"parser": "vue-eslint-parser",
"parserOptions": {
"parser": "babel-eslint"
"parser": "@babel/eslint-parser",
"requireConfigFile": false,
"ecmaVersion": 2020,
"sourceType": "module"
},
"rules": {}
"rules": {
"vue/multi-word-component-names": "off"
}
},
"browserslist": [
"> 1%",
"last 2 versions",
......
<template>
<div
class="modal fade clip-effects-modal"
ref="clipEffectsModal"
tabindex="-1"
aria-labelledby="clipEffectsModalLabel"
aria-hidden="true"
>
<div class="modal-dialog modal-dialog-centered modal-lg clip-effects-dialog">
<div class="modal-content">
<div class="modal-header border-0 align-items-start">
<div>
<h5 class="modal-title" id="clipEffectsModalLabel">Clip Effects</h5>
<p v-if="clipDisplayLabel" class="clip-meta mb-0 text-muted">
{{ clipDisplayLabel }}
</p>
</div>
<button
type="button"
class="btn-close"
aria-label="Close"
@click="handleCancel"
></button>
</div>
<div class="modal-body">
<section class="effects-panel">
<header>Text Caption</header>
<div class="panel-body">
<div class="row g-3 align-items-end">
<div class="col-sm-6">
<label class="form-label" for="clipEffectsTextInput">Text</label>
<input
id="clipEffectsTextInput"
type="text"
class="form-control"
placeholder="Enter caption text"
ref="textInput"
v-model="form.text"
/>
</div>
<div class="col-sm-6">
<label class="form-label d-block">Position</label>
<div class="btn-group position-group w-100" role="group" aria-label="Text position">
<button
v-for="option in captionPositions"
:key="option.value"
type="button"
class="btn btn-outline-secondary flex-fill"
:class="{ active: form.position === option.value }"
@click="updatePosition(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
</div>
</section>
<section class="effects-panel">
<header>Audio</header>
<div class="panel-body">
<div class="audio-controls">
<span class="fw-semibold text-muted small">Level: {{ form.audioLevel }}%</span>
<input
type="range"
class="form-range audio-slider"
min="0"
max="100"
step="1"
v-model.number="form.audioLevel"
aria-label="Audio level"
/>
</div>
</div>
</section>
<section class="effects-panel">
<header>Motion</header>
<div class="panel-body motion-grid">
<div
v-for="preset in motionPresets"
:key="preset.key"
class="motion-preset"
>
<p class="mb-2 fw-semibold text-uppercase small">{{ preset.label }}</p>
<div class="btn-group w-100" role="group" :aria-label="`${preset.label} motion options`">
<button
type="button"
class="btn btn-outline-secondary flex-fill"
:class="{ active: form.motion[preset.inKey] }"
@click="toggleMotion(preset.inKey)"
>
In
</button>
<button
type="button"
class="btn btn-outline-secondary flex-fill"
:class="{ active: form.motion[preset.outKey] }"
@click="toggleMotion(preset.outKey)"
>
Out
</button>
</div>
</div>
</div>
</section>
<section class="effects-panel">
<header>Effects</header>
<div class="panel-body">
<div class="row g-2 align-items-end">
<div class="col-md-8 col-sm-12">
<label class="form-label" for="availableEffects">Add effect</label>
<select
id="availableEffects"
class="form-select"
v-model="form.selectedEffectId"
>
<option value="" disabled>Select effect</option>
<option
v-for="effect in normalizedEffectOptions"
:key="effect.value"
:value="effect.value"
>
{{ effect.label }}
</option>
</select>
</div>
<div class="col-md-4 col-sm-12 d-grid">
<label class="form-label invisible">Add</label>
<button
type="button"
class="btn btn-outline-primary"
:disabled="!form.selectedEffectId"
@click="appendSelectedEffect"
>
<span class="me-2">Add</span>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-plus-lg" viewBox="0 0 16 16">
<path d="M8 2a.5.5 0 0 1 .5.5v5h5a.5.5 0 0 1 0 1h-5v5a.5.5 0 0 1-1 0v-5H2.5a.5.5 0 0 1 0-1H7V2.5A.5.5 0 0 1 8 2z"/>
</svg>
</button>
</div>
</div>
<div class="applied-effects mt-4">
<ul v-if="form.appliedEffects.length" class="list-unstyled mb-0">
<li
v-for="effect in form.appliedEffects"
:key="effect.value"
class="applied-item"
>
<span>{{ effect.label }}</span>
<button
type="button"
class="btn btn-link text-decoration-none text-danger p-0"
@click="removeEffect(effect.value)"
>
Remove
</button>
</li>
</ul>
</div>
</div>
</section>
</div>
<div class="modal-footer justify-content-between border-0 pt-0">
<button type="button" class="btn btn-outline-secondary" @click="handleReset">
Reset
</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-light" @click="handleCancel">
Cancel
</button>
<button type="button" class="btn btn-primary" @click="handleSave">
Save
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { Modal } from "bootstrap";
import { mapState } from "vuex";
import {
DEFAULT_CAPTION_POSITION,
getClipCaptionState,
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",
data() {
return {
modalInstance: null,
modalShownHandler: null,
clipContext: {
clipIndex: null,
clipName: "",
fileName: "",
clipId: null,
clipUrl: null
},
activeClip: null,
form: this.createEmptyForm(),
snapshot: null,
sampleEffectOptions: [
{ value: "blur", label: "Blur" },
{ value: "glow", label: "Glow" },
{ value: "pop", label: "Pop" },
{ value: "bounce", label: "Bounce" }
],
captionPositions: [
{ value: "top", label: "Top" },
{ value: "center", label: "Center" },
{ value: "bottom", label: "Bottom" }
],
motionPresets: [
{ key: "slide", label: "Slide", inKey: "slideIn", outKey: "slideOut" },
{ key: "fade", label: "Fade", inKey: "fadeIn", outKey: "fadeOut" },
{ key: "zoom", label: "Zoom", inKey: "zoomIn", outKey: "zoomOut" }
]
}
},
computed: {
...mapState(['effectCatalog']),
clipDisplayLabel() {
if (!this.clipContext.clipName && !this.clipContext.fileName) {
return ""
}
const label = this.clipContext.clipName || this.clipContext.fileName
if (this.clipContext.clipIndex) {
return `${label} (Clip ${this.clipContext.clipIndex})`
}
return label
},
normalizedEffectOptions() {
let source = []
if (Array.isArray(this.effectCatalog) && this.effectCatalog.length) {
source = this.effectCatalog
} else {
source = this.sampleEffectOptions
}
const filtered = source.filter(effect => !isEffectDisallowed(effect))
const normalized = filtered.map((effect, index) => normalizeEffectOption(effect, index))
return sortEffectsAlphabetically(normalized)
}
},
methods: {
createEmptyForm() {
return {
text: "",
position: DEFAULT_CAPTION_POSITION,
audioLevel: DEFAULT_AUDIO_LEVEL,
motion: { ...DEFAULT_MOTION_STATE },
selectedEffectId: "",
appliedEffects: []
}
},
ensureModalInstance() {
if (!this.modalInstance) {
this.modalInstance = new Modal(this.$refs.clipEffectsModal, {
backdrop: "static"
})
this.modalShownHandler = () => this.focusTextInput()
this.$refs.clipEffectsModal.addEventListener('shown.bs.modal', this.modalShownHandler)
}
return this.modalInstance
},
open(payload = {}) {
this.clipContext = {
clipIndex: payload.clipIndex ?? null,
clipName: payload.clip?.name || payload.clipName || "",
fileName: payload.fileName || payload.clip?.name || "",
clipId: payload.clip?.id ?? payload.clipId ?? null,
clipUrl: payload.clip?.url ?? payload.clipUrl ?? null
}
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
: (typeof payload.captionPosition === "string" ? payload.captionPosition : null)
const positionFromTop = typeof payload.captionTop === "number"
? topValueToCaptionPosition(payload.captionTop)
: clipCaptionState.position
const resolvedPosition = overridePosition
? normalizeCaptionPosition(overridePosition)
: positionFromTop
const appliedSource = Array.isArray(payload.appliedEffects) && payload.appliedEffects.length > 0
? payload.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 : clipEffectsState.audioLevel,
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()
})
},
close() {
if (this.modalInstance) {
this.modalInstance.hide()
}
},
handleReset() {
this.form = this.createEmptyForm()
const payload = {
clip: this.activeClip,
clipId: this.clipContext.clipId,
clipUrl: this.clipContext.clipUrl,
clipIndex: this.clipContext.clipIndex,
clipName: this.clipContext.clipName,
fileName: this.clipContext.fileName,
form: JSON.parse(JSON.stringify(this.form)),
hasCustomizations: false
}
this.$emit('reset', payload)
},
handleCancel() {
if (this.snapshot) {
this.form = JSON.parse(JSON.stringify(this.snapshot))
}
this.close()
this.$emit('cancel', { clip: this.clipContext })
},
handleSave() {
const payload = {
clip: this.activeClip,
clipId: this.clipContext.clipId,
clipUrl: this.clipContext.clipUrl,
clipIndex: this.clipContext.clipIndex,
clipName: this.clipContext.clipName,
fileName: this.clipContext.fileName,
form: JSON.parse(JSON.stringify(this.form)),
hasCustomizations: this.formHasCustomizations(this.form)
}
this.$emit('save', payload)
this.close()
},
updatePosition(value) {
this.form.position = value
},
toggleMotion(key) {
this.form.motion[key] = !this.form.motion[key]
},
appendSelectedEffect() {
if (!this.form.selectedEffectId) {
return
}
const exists = this.form.appliedEffects.some(effect => effect.value === this.form.selectedEffectId)
if (exists) {
this.form.selectedEffectId = ""
return
}
const selected = this.normalizedEffectOptions.find(effect => effect.value === this.form.selectedEffectId)
if (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 = ""
},
removeEffect(value) {
this.form.appliedEffects = this.form.appliedEffects.filter(effect => effect.value !== value)
},
focusTextInput() {
if (this.$refs.textInput) {
this.$refs.textInput.focus()
this.$refs.textInput.select()
}
},
formHasCustomizations(form) {
const defaults = this.createEmptyForm()
if ((form?.text ?? "") !== defaults.text) {
return true
}
if (normalizeCaptionPosition(form?.position) !== defaults.position) {
return true
}
if (typeof form?.audioLevel === "number" && form.audioLevel !== defaults.audioLevel) {
return true
}
const motion = form?.motion || {}
const motionDefaults = defaults.motion
const motionKeys = Object.keys(motionDefaults)
for (const key of motionKeys) {
if (!!motion[key] !== !!motionDefaults[key]) {
return true
}
}
if (Array.isArray(form?.appliedEffects) && form.appliedEffects.length > 0) {
return true
}
return false
},
sortAppliedEffects() {
this.form.appliedEffects = sortEffectsAlphabetically(this.form.appliedEffects || [])
}
},
mounted() {
this.ensureModalInstance()
},
unmounted() {
if (this.modalInstance) {
this.modalInstance.hide()
this.modalInstance = null
}
if (this.modalShownHandler && this.$refs.clipEffectsModal) {
this.$refs.clipEffectsModal.removeEventListener('shown.bs.modal', this.modalShownHandler)
}
}
}
</script>
<style scoped>
.clip-effects-modal .modal-dialog {
max-width: 720px;
}
.clip-effects-modal .modal-content {
border-radius: 0.85rem;
}
.clip-effects-modal .modal-body {
padding: 1rem 1.25rem;
}
.clip-effects-modal header {
font-weight: 600;
text-transform: uppercase;
font-size: 0.8rem;
letter-spacing: 0.08em;
color: #6c757d;
}
.clip-effects-modal .effects-panel + .effects-panel {
margin-top: 1.25rem;
}
.clip-effects-modal .panel-body {
margin-top: 0.5rem;
background: #f8f9fa;
border-radius: 0.65rem;
padding: 0.9rem 1rem;
}
.clip-effects-modal .motion-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 0.75rem;
}
.clip-effects-modal .motion-preset {
border: 1px solid rgba(0, 0, 0, 0.05);
border-radius: 0.6rem;
padding: 0.85rem;
background-color: #fff;
}
.clip-effects-modal .applied-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.55rem 0.8rem;
border-radius: 0.45rem;
background-color: #fff;
border: 1px solid rgba(0, 0, 0, 0.05);
margin-bottom: 0.35rem;
}
.clip-effects-modal .position-group .btn {
min-width: 80px;
}
.clip-effects-modal .modal-footer {
background: #fdfdfd;
border-top: 1px solid rgba(0, 0, 0, 0.05);
padding: 0.75rem 1.25rem 1.25rem;
}
.clip-effects-modal .clip-meta {
font-size: 0.85rem;
}
.clip-effects-modal .audio-controls {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.clip-effects-modal .audio-slider {
min-width: 180px;
flex: 1 1 160px;
}
.clip-effects-modal .applied-effects {
margin-top: 0.75rem;
}
.clip-effects-modal .effects-panel select,
.clip-effects-modal .effects-panel input[type="text"] {
font-size: 0.95rem;
}
</style>
......@@ -11,20 +11,44 @@
</div>
<!-- Scrolling container for clips -->
<div class="container p-2 scrolling-container">
<div class="container p-2 scrolling-container" ref="scrollContainer">
<div class="col-12">
<div class="row gy-2 gx-2 mb-2" v-for="(clip, index) in thumbnailedClips" :key="clip.id">
<div v-if="isDragging && thumbnailedClips.length > 0"
class="drag-buffer drag-buffer-top"
@dragover.prevent="onDragOverBeforeFirst"
@drop.prevent="onDropBeforeFirst">
</div>
<div
class="row gy-2 gx-2 mb-2 clip-row"
v-for="(clip, index) in thumbnailedClips"
:key="clip.id"
:class="{ 'drag-source': draggingClip && draggingClip.id === clip.id }"
:draggable="thumbnailedClips.length > 1"
@dragstart="onDragStart($event, clip, index)"
@dragover.prevent="onDragOver($event, index)"
@dragleave="onDragLeave($event, index)"
@drop.prevent="onDrop($event, index)"
@dragend="onDragEnd"
>
<div class="col-12 text-center img-parent">
<div class="drop-indicator drop-indicator-top" v-if="shouldShowIndicator(index, 'before')"></div>
<div class="drop-indicator drop-indicator-bottom" v-if="shouldShowIndicator(index, 'after')"></div>
<!-- Clip sequence # badge -->
<span class="clip-badge badge">{{ index + 1 }}</span>
<!-- Clip text icon -->
<div class="context-menu">
<span @click.prevent="toggleText(clip)" class="menu-icon" title="Add Text">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-textarea-t" viewBox="0 0 16 16">
<path d="M1.5 2.5A1.5 1.5 0 0 1 3 1h10a1.5 1.5 0 0 1 1.5 1.5v3.563a2 2 0 0 1 0 3.874V13.5A1.5 1.5 0 0 1 13 15H3a1.5 1.5 0 0 1-1.5-1.5V9.937a2 2 0 0 1 0-3.874V2.5zm1 3.563a2 2 0 0 1 0 3.874V13.5a.5.5 0 0 0 .5.5h10a.5.5 0 0 0 .5-.5V9.937a2 2 0 0 1 0-3.874V2.5A.5.5 0 0 0 13 2H3a.5.5 0 0 0-.5.5v3.563zM2 7a1 1 0 1 0 0 2 1 1 0 0 0 0-2zm12 0a1 1 0 1 0 0 2 1 1 0 0 0 0-2z"/>
<path d="M11.434 4H4.566L4.5 5.994h.386c.21-1.252.612-1.446 2.173-1.495l.343-.011v6.343c0 .537-.116.665-1.049.748V12h3.294v-.421c-.938-.083-1.054-.21-1.054-.748V4.488l.348.01c1.56.05 1.963.244 2.173 1.496h.386L11.434 4z"/>
<!-- Clip effects icon -->
<div class="context-menu" @mousedown.stop>
<span
@click.prevent="openClipEffectsModal(clip, index)"
class="menu-icon toggle-icon effects-trigger"
:class="{ active: hasClipEnhancements(clip) }"
title="Effects and Text Captions"
draggable="false"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-stars" viewBox="0 0 16 16">
<path d="M7.5 2.5a.5.5 0 0 1 .5.5c0 .654.291 1.304.854 1.866.562.563 1.212.854 1.866.854a.5.5 0 0 1 0 1c-.654 0-1.304.291-1.866.854-.563.562-.854 1.212-.854 1.866a.5.5 0 0 1-1 0c0-.654-.291-1.304-.854-1.866C5.084 6.511 4.434 6.22 3.78 6.22a.5.5 0 0 1 0-1c.654 0 1.304-.291 1.866-.854C6.208 3.804 6.5 3.154 6.5 2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M2.5 9.5a.5.5 0 0 1 .5.5c0 .379.168.756.494 1.082.326.326.703.494 1.082.494a.5.5 0 0 1 0 1 2.58 2.58 0 0 0-1.082.494A1.53 1.53 0 0 0 3 14.152a.5.5 0 0 1-1 0 1.53 1.53 0 0 0-.494-1.082A2.58 2.58 0 0 0 .152 12.5a.5.5 0 0 1 0-1c.379 0 .756-.168 1.082-.494.326-.326.494-.703.494-1.082a.5.5 0 0 1 .5-.5zM12.5.5a.5.5 0 0 1 .5.5c0 .506.225 1.005.659 1.439.434.434.933.659 1.439.659a.5.5 0 0 1 0 1 2.74 2.74 0 0 0-1.439.659A2.04 2.04 0 0 0 13 5.196a.5.5 0 0 1-1 0 2.04 2.04 0 0 0-.659-1.439A2.74 2.74 0 0 0 9.902 3.1a.5.5 0 0 1 0-1c.506 0 1.005-.225 1.439-.659C11.774 1.505 12 .506 12 0a.5.5 0 0 1 .5-.5z"/>
</svg>
</span>
......@@ -37,26 +61,25 @@
<li v-if="index > 0"><a class="dropdown-item" href="#" @click="moveClipBtn('up', clip, index, index - 1)">Move Up</a></li>
<li v-if="index < clips.length - 1"><a class="dropdown-item" href="#" @click="moveClipBtn('down', clip, index, index + 1)">Move Down</a></li>
<li v-if="index > 0 || index < clips.length - 1"><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" @click="toggleSelection(clip)">Edit</a></li>
<li><a class="dropdown-item" href="#" @click="deleteClipBtn(clip.id)">Delete</a></li>
</ul>
</span>
</div>
<!-- Clip text input -->
<div v-if="clip.json.hasOwnProperty('text')" class="mb-3 clip-text">
<input @blur="updateTextInput($event, clip)" type="text" class="form-control" maxlength="35" placeholder="Enter text here" :value="clip.json.text">
</div>
<!-- Clip length label -->
<div ref="clip" class="clip-label">
{{ (clip.end - clip.start).toFixed(1) }} Seconds
</div>
<!-- Clip thumbnail image -->
<img @click="toggleSelection(clip)" :class="getSelectedClass(clip)" class="img-fluid img-thumbnail clip-thumbnail" @load="thumbnailLoaded($event, clip)" :title="clip.name" :src="clip.thumbnail"/>
<img @click="toggleSelection(clip)" :class="getSelectedClass(clip)" class="img-fluid img-thumbnail clip-thumbnail" @load="thumbnailLoaded($event, clip)" :title="clip.name" :src="clip.thumbnail" draggable="false"/>
</div>
</div>
<div v-if="isDragging && thumbnailedClips.length > 0"
class="drag-buffer drag-buffer-bottom"
@dragover.prevent="onDragOverAfterLast"
@drop.prevent="onDropAfterLast">
</div>
</div>
</div>
</div>
......@@ -72,7 +95,12 @@
<div class="modal-body">
<!-- Progress Bar for Export -->
<div v-show="!current_export.output" class="col-sm-12">
<div v-show="isExportInProgress" class="col-sm-12 mb-2">
<p class="text-muted small mb-1">Please wait while your video is being exported.</p>
</div>
<!-- Progress Bar for Export -->
<div v-show="isExportInProgress" class="col-sm-12">
<div class="progress align-middle">
<div class="progress-bar" role="progressbar" v-bind:style="{ width: current_export.progress + '%'}" :aria-valuenow="current_export.progress" aria-valuemin="0" aria-valuemax="100"></div>
</div>
......@@ -85,97 +113,383 @@
</video>
</div>
<div v-if="isDemoMode" class="col-sm-12 mt-4">
<CloudInstancePromo
compact
label="Demo Preview"
title="Want to export in higher resolutions?"
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" ref="Close" data-bs-dismiss="modal">Cancel</button>
<a v-if="current_export.output" :href="current_export.output" class="btn btn-info" download target="_blank">Download</a>
<button v-if="!current_export.output" type="button" class="btn btn-primary" @click="startExport" :disabled="exporting">Export</button>
<button v-if="current_export.output" type="button" class="btn btn-primary" @click="startExport" :disabled="exporting">Export Again</button>
<div class="modal-footer d-flex align-items-center gap-2 flex-wrap">
<button type="button"
class="btn btn-outline-secondary me-auto"
ref="Close"
data-bs-dismiss="modal">
Cancel
</button>
<div class="d-flex align-items-center gap-2">
<div v-if="current_export.output" class="btn-group">
<button type="button"
class="btn btn-success dropdown-toggle"
data-bs-toggle="dropdown"
aria-expanded="false">
Download
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a class="dropdown-item"
:href="exportDownloadUrl || '#'"
download
rel="noopener">
Download Video (*.mp4)
</a>
</li>
<li>
<a class="dropdown-item"
:href="projectDownloadUrl || '#'"
rel="noopener">
Download OpenShot Project (*.zip)
</a>
</li>
</ul>
</div>
<button
v-if="!current_export.output"
type="button"
class="btn btn-primary"
@click="startExport"
:disabled="exporting">
Export
</button>
<button
v-if="current_export.output"
type="button"
class="btn btn-primary"
@click="startExport"
:disabled="exporting">
Export Again
</button>
</div>
</div>
</div>
</div>
</div>
<ClipEffectsModal
ref="clipEffectsModal"
@save="handleClipEffectsSave"
@reset="handleClipEffectsReset"
/>
</template>
<script>
import {mapActions, mapState, mapMutations, mapGetters} from "vuex"
import { Modal } from "bootstrap"
import caption from "../data/caption.json"
import ClipEffectsModal from "./ClipEffectsModal"
import CloudInstancePromo from "./CloudInstancePromo.vue"
import { createClipWithEffectsForm, hasClipEffectsChanges } from "../utils/clipEffects"
export default {
name: "Clips.vue",
props: ['project'],
components: {
ClipEffectsModal,
CloudInstancePromo
},
data() {
return {
show_spinner: false,
exporting: false
exporting: false,
draggingClip: null,
draggingIndex: null,
dragOverIndex: null,
dragOverPosition: null,
autoScrollDirection: 0,
autoScrollFrame: null,
dragListenersActive: false,
autoExportedProjectId: null
}
},
methods: {
toggleText(clipObj) {
if (Object.prototype.hasOwnProperty.call(clipObj.json, 'text')) {
delete clipObj.json.text
} else {
clipObj.json.text = ""
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,
clipIndex: index + 1,
clipName: clipObj?.name,
fileName,
appliedEffects
})
}
let payload = { data: clipObj }
this.editClip(payload)
},
updateTextInput(e, clipObj) {
clipObj.json.text = e.target.value
let payload = { data: clipObj }
this.editClip(payload)
hasClipEnhancements(clipObj) {
if (!clipObj || !clipObj.json) {
return false
}
const hasFxFlag = !!clipObj.json.fx_enabled
const hasAppliedEffects = Array.isArray(clipObj.json.effects) && clipObj.json.effects.length > 0
return hasFxFlag || hasAppliedEffects
},
async startExport() {
this.exporting = true
// Search for existing caption effect (we can re-use it)
let caption_results = this.effects.filter(e => e.layer == 999 && e.position == 0.0 && e.type == 'Caption')
let effect_payload = {}
let is_effect_new = false
// Get or create caption effect object
if (caption_results.length == 1) {
effect_payload = caption_results[0]
effect_payload.end = 0
effect_payload.json = caption
async handleClipEffectsSave(payload) {
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj || !payload.form) {
return
}
await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
},
async handleClipEffectsReset(payload) {
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj || !payload.form) {
return
}
await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
},
async persistClipEffects(clipObj, form, fxEnabled) {
if (!clipObj || !form) {
return
}
const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasClipEffectsChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) {
return
}
const updatedClip = createClipWithEffectsForm(clipObj, form, { fxEnabled })
if (!updatedClip) {
return
}
const editPayload = { data: updatedClip, latest: false, thumbnail: false }
await this.editClip(editPayload)
Object.assign(clipObj, updatedClip)
},
onDragStart(event, clipObj, index) {
if (this.thumbnailedClips.length <= 1) {
event.preventDefault()
return
}
this.draggingClip = clipObj
this.draggingIndex = index
this.dragOverIndex = null
this.dragOverPosition = null
if (event.dataTransfer) {
event.dataTransfer.effectAllowed = 'move'
try {
event.dataTransfer.setData('text/plain', clipObj.id)
} catch (err) {
// ignore drag image errors
}
}
if (!this.dragListenersActive && typeof window !== 'undefined') {
window.addEventListener('dragover', this.onGlobalDragOver)
this.dragListenersActive = true
}
},
onDragOver(event, index) {
if (!this.draggingClip) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
const dropBefore = (event.clientY - bounds.top) < bounds.height / 2
this.dragOverIndex = index
this.dragOverPosition = dropBefore ? 'before' : 'after'
},
onDragLeave(event, index) {
if (!this.draggingClip) {
return
}
if (event.relatedTarget && event.currentTarget.contains(event.relatedTarget)) {
return
}
if (this.dragOverIndex === index) {
this.dragOverIndex = null
this.dragOverPosition = null
}
},
onDrop(event, index) {
if (!this.draggingClip) {
return
}
const bounds = event.currentTarget.getBoundingClientRect()
const fallback = (event.clientY - bounds.top) < bounds.height / 2
const dropBefore = this.dragOverIndex === index && this.dragOverPosition
? this.dragOverPosition === 'before'
: fallback
this.handleDrop(index, dropBefore)
},
onGlobalDragOver(event) {
if (!this.draggingClip) {
this.stopAutoScroll()
return
}
const container = this.$refs.scrollContainer
if (!container) {
return
}
const rect = container.getBoundingClientRect()
const buffer = 35
const canScrollUp = container.scrollTop > 0
const canScrollDown = container.scrollTop + container.clientHeight < container.scrollHeight
if ((event.clientY < rect.top || event.clientY < rect.top + buffer) && canScrollUp) {
this.startAutoScroll(-1)
} else if ((event.clientY > rect.bottom || event.clientY > rect.bottom - buffer) && canScrollDown) {
this.startAutoScroll(1)
} else {
is_effect_new = true
effect_payload = {
position: 0,
start: 0,
end: 0,
layer: 999,
project: this.project.url,
title: 'Caption',
type: 'Caption',
json: caption
this.stopAutoScroll()
}
},
onDragOverBeforeFirst() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.dragOverIndex = 0
this.dragOverPosition = 'before'
},
onDropBeforeFirst() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.handleDrop(0, true)
},
onDragOverAfterLast() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.dragOverIndex = this.thumbnailedClips.length - 1
this.dragOverPosition = 'after'
},
onDropAfterLast() {
if (!this.draggingClip || !this.thumbnailedClips.length) {
return
}
this.handleDrop(this.thumbnailedClips.length - 1, false)
},
onDragEnd() {
this.resetDragState()
},
handleDrop(targetIndex, dropBefore) {
if (!this.draggingClip) {
return
}
const current = this.draggingIndex
if (current === null || current === undefined || targetIndex === null || targetIndex === undefined) {
this.resetDragState()
return
}
targetIndex = Math.max(0, Math.min(targetIndex, this.thumbnailedClips.length - 1))
let dest = this.calculateDestinationIndex(current, targetIndex, dropBefore)
dest = Math.max(0, Math.min(dest, this.thumbnailedClips.length - 1))
if (dest === current) {
this.resetDragState()
return
}
let payload = { clip: this.draggingClip, current, dest }
this.moveClip(payload)
this.resetDragState()
},
calculateDestinationIndex(current, target, dropBefore) {
if (dropBefore) {
let dest = target
if (current < target) {
dest -= 1
}
return dest
}
// Generate caption text (w/ timestamps)
caption.caption_text = ""
for (let clip of this.thumbnailedClips) {
if (clip.json.text) {
let clip_right_edge = clip.position + (clip.end - clip.start)
if (clip_right_edge > effect_payload.end) {
effect_payload.end = clip_right_edge
}
let start_timestamp = new Date(clip.position * 1000).toISOString().substr(11, 12)
let end_timestamp = new Date((clip.position + (clip.end - clip.start)) * 1000).toISOString().substr(11, 12)
caption.caption_text += `${start_timestamp} --> ${end_timestamp}\n${clip.json.text}\n\n`
let dest = target + 1
if (current <= target) {
dest -= 1
}
return Math.max(0, dest)
},
shouldShowIndicator(index, position) {
return !!this.draggingClip && this.dragOverIndex === index && this.dragOverPosition === position
},
resetDragState() {
this.draggingClip = null
this.draggingIndex = null
this.dragOverIndex = null
this.dragOverPosition = null
this.stopAutoScroll()
this.removeDragListeners()
},
startAutoScroll(direction) {
if (this.autoScrollDirection === direction) {
return
}
if (!direction) {
this.stopAutoScroll()
return
}
const raf = typeof window !== 'undefined' ? window.requestAnimationFrame : null
if (!raf) {
return
}
this.autoScrollDirection = direction
if (this.autoScrollFrame) {
const caf = typeof window !== 'undefined' ? window.cancelAnimationFrame : null
if (caf) {
caf(this.autoScrollFrame)
}
this.autoScrollFrame = null
}
this.autoScrollFrame = raf(() => this.performAutoScroll())
},
performAutoScroll() {
if (!this.autoScrollDirection) {
return
}
// Save caption effect (if any text is needed)
if (effect_payload.end > 0) {
if (is_effect_new) {
await this.createEffect(effect_payload)
} else {
await this.updateEffect(effect_payload)
const container = this.$refs.scrollContainer
if (!container) {
this.stopAutoScroll()
return
}
const maxScrollTop = container.scrollHeight - container.clientHeight
if (maxScrollTop <= 0) {
this.stopAutoScroll()
return
}
const scrollDelta = this.autoScrollDirection * 12
const nextScroll = Math.max(0, Math.min(maxScrollTop, container.scrollTop + scrollDelta))
if (nextScroll === container.scrollTop) {
this.stopAutoScroll()
return
}
container.scrollTop = nextScroll
const raf = typeof window !== 'undefined' ? window.requestAnimationFrame : null
if (!raf) {
this.stopAutoScroll()
return
}
this.autoScrollFrame = raf(() => this.performAutoScroll())
},
stopAutoScroll() {
this.autoScrollDirection = 0
if (this.autoScrollFrame) {
const caf = typeof window !== 'undefined' ? window.cancelAnimationFrame : null
if (caf) {
caf(this.autoScrollFrame)
}
this.autoScrollFrame = null
}
},
removeDragListeners() {
if (this.dragListenersActive) {
if (typeof window !== 'undefined') {
window.removeEventListener('dragover', this.onGlobalDragOver)
}
this.dragListenersActive = false
}
},
async startExport() {
this.exporting = true
if (this.$store && this.$store.commit) {
this.$store.commit('setExport', { progress: 0.0 })
}
// Create export
let payload = {
"export_type": "video",
"video_format": "mp4",
......@@ -223,13 +537,73 @@ export default {
showExportModal() {
let myModal = new Modal(this.$refs.exportModal)
myModal.show()
this.$nextTick(() => {
this.maybeAutoExport()
})
},
maybeAutoExport() {
if (!this.project || !this.project.id) {
return
}
if (this.autoExportedProjectId === this.project.id) {
return
}
const hasExistingExport = !!(this.current_export?.id || this.current_export?.output || (this.current_export && this.current_export.progress > 0))
if (hasExistingExport) {
this.autoExportedProjectId = this.project.id
return
}
this.autoExportedProjectId = this.project.id
if (!this.exporting) {
this.startExport()
}
},
...mapActions(['loadClips', 'createClip', 'deleteClip', 'editClip', 'moveClip', 'createExport', 'createEffect', 'updateEffect']),
...mapActions(['loadClips', 'createClip', 'deleteClip', 'editClip', 'moveClip', 'createExport']),
...mapMutations((['setPreviewClip', 'setClips', 'setScrollToClip']))
},
computed: {
...mapGetters(['totalClipDuration', 'thumbnailedClips']),
...mapState(['clips', 'preview', 'scrollToClip', 'current_export', 'effects'])
...mapState(['clips', 'preview', 'scrollToClip', 'current_export']),
isDragging() {
return !!this.draggingClip
},
isDemoMode() {
const apiUrl = (process.env.VUE_APP_OPENSHOT_API_URL || '').trim()
if (!apiUrl) {
return false
}
const normalized = apiUrl.replace(/\/+$/, '').toLowerCase()
const host = normalized.replace(/^https?:\/\//, '')
return host.startsWith('cloud.openshot.org')
},
isExportInProgress() {
const hasPendingRecord = !!(this.current_export && this.current_export.id && !this.current_export.output)
return !!this.exporting || hasPendingRecord
},
exportDownloadUrl() {
if (!this.current_export || !this.current_export.url) {
return this.current_export?.output || null
}
const trimmed = this.current_export.url.replace(/\/+$/, '')
return `${trimmed}/download/`
},
projectDownloadUrl() {
if (!this.project) {
return null
}
if (this.project.url) {
try {
const base = this.project.url.endsWith('/') ? this.project.url : `${this.project.url}/`
return new URL('download/', base).toString()
} catch (err) {
// fall through to ID-based path below
}
}
if (this.project.id) {
return `/projects/${this.project.id}/download/`
}
return null
}
},
watch: {
scrollToClip() {
......@@ -259,6 +633,8 @@ export default {
},
unmounted() {
this.setClips([])
this.stopAutoScroll()
this.removeDragListeners()
}
}
</script>
......@@ -275,6 +651,12 @@ export default {
cursor: pointer;
width: 100%;
}
.clip-row {
position: relative;
}
.drag-buffer {
height: 14px;
}
.clip-btn {
float: right;
}
......@@ -284,17 +666,68 @@ export default {
.context-menu {
color: #ffffff;
position: absolute;
top: 0%;
right: 0%;
padding-top: 10px;
padding-right: 10px;
top: 4px;
right: 8px;
padding: 6px;
display: inline-flex;
align-items: center;
gap: 6px;
border-radius: 6px;
}
.menu-icon {
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
.context-menu .btn-group.dropstart {
margin-left: 0;
}
.context-menu .btn-group.dropstart .dropdown-toggle::before {
display: none;
}
.toggle-icon {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
border-radius: 4px;
transition: background-color 0.15s ease, color 0.15s ease;
}
.toggle-icon.active {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.25);
}
.effects-trigger {
padding: 4px;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.45);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
transition: background-color 0.15s ease, color 0.15s ease;
}
.effects-trigger:hover {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.2);
}
.menu-icon:hover {
color: #0d6efd;
}
.drop-indicator {
position: absolute;
left: 6px;
right: 6px;
height: 4px;
background-color: #0d6efd;
border-radius: 4px;
box-shadow: 0 0 8px rgba(13, 110, 253, 0.5);
z-index: 2000;
}
.drop-indicator-top {
top: -2px;
}
.drop-indicator-bottom {
bottom: -2px;
}
.text-menu {
color: #ffffff;
position: absolute;
......@@ -333,30 +766,14 @@ export default {
position: absolute;
bottom: 5%;
left: 5%;
padding: 8px;
opacity: 0.75;
background-color: #000000;
border-radius: 4px;
border-radius: 11px;
z-index: 1000;
border: 1px solid rgba(255, 255, 255, 0.85);
}
video {
background-color: #000000;
width: 100%;
}
.clip-text {
position: absolute;
width: 75%;
top: 0%;
padding-left: 5%;
padding-top: 10px;
}
input.form-control {
background-color: rgba(255, 255, 255, 0.20);
color: #fff;
border-color: #0d6efd;
filter: drop-shadow(0px 0px 1px #000000);
}
.form-control::placeholder {
filter: none;
color: #e3e3e3;
}
</style>
\ No newline at end of file
</style>
<template>
<div :class="['cloud-instance-promo card border-0 shadow-sm', { 'promo-compact': compact }]">
<div :class="['card-body', compact ? 'py-3 px-3' : 'py-4 px-4']">
<div class="row g-3 align-items-center">
<div class="col-12 col-md-7 text-center text-md-start">
<p class="text-uppercase text-muted fw-semibold small mb-1">{{ label }}</p>
<h6 class="fw-semibold mb-1">{{ title }}</h6>
<p v-if="subtitleHtml" class="text-muted small mb-0" v-html="subtitleHtml"></p>
<p v-else-if="subtitle" class="text-muted small mb-0">{{ subtitle }}</p>
</div>
<div class="col-12 col-md-5">
<div class="d-flex flex-column align-items-center gap-2">
<a
v-for="provider in providers"
:key="provider.name"
class="btn btn-light border promo-provider-btn w-75 mx-auto"
:href="provider.href"
:target="provider.target"
:rel="provider.rel"
:title="provider.title"
>
<img :src="provider.icon" :alt="provider.name" height="22">
</a>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
name: "CloudInstancePromo",
props: {
label: {
type: String,
default: "Ready for more?"
},
title: {
type: String,
default: "Launch your own instance"
},
subtitle: {
type: String,
default: "Deploy OpenShot Cloud API on AWS, Azure, or Google to access full-length exports, full resolution, faster rendering, and your own scalable cloud pipeline."
},
subtitleHtml: {
type: String,
default: ""
},
compact: {
type: Boolean,
default: false
}
},
computed: {
providers() {
return [
{
name: "AWS",
icon: "//cdn.openshot.org/static/img/icons/aws.svg",
href: "https://aws.amazon.com/marketplace/pp/B074H87FSJ/",
target: "_blank",
rel: "noopener",
title: "Launch Instance: AWS"
},
{
name: "Azure",
icon: "//cdn.openshot.org/static/img/icons/azure.svg",
href: "https://azuremarketplace.microsoft.com/en-us/marketplace/apps/openshotstudiosllc.openshot-cloud-api",
target: "_blank",
rel: "noopener",
title: "Launch Instance: Azure"
},
{
name: "Google Cloud",
icon: "//cdn.openshot.org/static/img/icons/google.svg",
href: "https://console.cloud.google.com/marketplace/product/openshotstudios-public/openshot-video-editing-cloud-api",
target: "_blank",
rel: "noopener",
title: "Launch Instance: Google Cloud"
}
]
}
}
}
</script>
<style scoped>
.cloud-instance-promo {
background: linear-gradient(180deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 1rem;
}
.promo-compact {
padding: 0;
}
.promo-provider-btn {
padding: 0.45rem 0.75rem;
border-radius: 999px;
transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.promo-provider-btn:hover {
transform: translateY(-1px);
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.1);
}
</style>
<template>
<!-- File header & Upload button -->
<div class="row">
<h3>Files <button type="button" class="btn btn-primary upload-btn" @click="chooseFiles">Upload</button></h3>
<div class="col-sm-12">
<div class="col-12">
<div class="files-header">
<h3 class="mb-0">Files</h3>
<div class="btn-group" role="group" aria-label="File actions">
<button
v-if="hasFiles"
type="button"
class="btn filter-btn"
:class="{ active: filterVisible }"
title="Toggle search filter"
:aria-pressed="filterVisible"
@click="toggleFilter"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-funnel" viewBox="0 0 16 16">
<path d="M1.5 1.5A.5.5 0 0 1 2 1h12a.5.5 0 0 1 .39.812L10 7.07v6.43a.5.5 0 0 1-.757.429l-2-1.2A.5.5 0 0 1 7 12.3V7.07L1.11 1.812A.5.5 0 0 1 1.5 1.5z"/>
</svg>
</button>
<button
type="button"
class="btn btn-primary upload-btn"
title="Upload new files"
@click="chooseFiles"
>
Upload
</button>
</div>
</div>
</div>
<div class="col-sm-12" v-if="filterVisible && hasFiles">
<div class="form-floating mb-3">
<input type="text" class="form-control" id="floatingInput" placeholder="Search" v-model="search">
<label for="floatingInput">Search</label>
......@@ -31,9 +58,11 @@
</ul>
</div>
<!-- File thumbnail image -->
<img @click="toggleSelection(file)" :class="getSelectedClass(file)" class="file-thumbnail img-fluid img-thumbnail" :title="file.name" :src="file.thumbnail"/>
<figcaption class="figure-caption">{{ getFileName(file) }}</figcaption>
<figure class="file-figure">
<!-- File thumbnail image -->
<img @click="toggleSelection(file)" :class="getSelectedClass(file)" class="file-thumbnail img-fluid img-thumbnail" :title="file.name" :src="file.thumbnail"/>
<figcaption class="figure-caption" :title="getBaseFileName(file)">{{ getBaseFileName(file) }}</figcaption>
</figure>
</div>
<!-- Upload progress bars -->
......@@ -63,13 +92,17 @@ export default {
data() {
return {
search: "",
show_spinner: false
show_spinner: false,
filterVisible: false
}
},
methods: {
chooseFiles() {
this.$refs.fileUpload.click()
},
toggleFilter() {
this.filterVisible = !this.filterVisible
},
async fileChanged(event) {
let results = []
for (let file of event.target.files) {
......@@ -112,20 +145,27 @@ export default {
return ''
}
},
getFileName(fileObject) {
let base = path.basename(fileObject.media)
if (base.length < 20) {
return base
} else {
return `${base.substr(0, 18)}...`
}
getBaseFileName(fileObject) {
return path.basename(fileObject.media)
},
...mapActions(['loadFiles', 'createFile', 'deleteFile']),
...mapMutations((['setPreviewFile', 'setPreviewClip', 'setFiles']))
},
computed: {
searchedFiles() {
return this.files.filter(file => path.basename(file.media).toLowerCase().includes(this.search.toLowerCase()) && file.thumbnail)
const query = this.filterVisible ? this.search.toLowerCase().trim() : ''
return this.files.filter(file => {
if (!file.thumbnail) {
return false
}
if (!query) {
return true
}
return path.basename(file.media).toLowerCase().includes(query)
})
},
hasFiles() {
return Array.isArray(this.files) && this.files.length > 0
},
...mapState(['files', 'preview', 'uploads'])
},
......@@ -147,19 +187,46 @@ export default {
height: 18vh;
overflow: auto;
}
.upload-btn {
float: right;
.files-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.filter-btn {
background-color: #e0e0e0;
border-color: #e0e0e0;
color: #495057;
display: inline-flex;
align-items: center;
justify-content: center;
}
.filter-btn svg {
display: block;
}
.filter-btn.active {
background-color: #0d6efd;
border-color: #0d6efd;
color: #fff;
}
.selected {
border: #0d6efd 4px solid;
}
.figure-caption {
font-size: 0.8em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
display: block;
width: 100%;
}
.file-thumbnail {
cursor: pointer;
width: 100%;
}
.file-figure {
margin: 0;
}
.context-menu {
color: #ffffff;
position: absolute;
......@@ -179,4 +246,4 @@ export default {
.dropdown-toggle {
filter: drop-shadow(0px 0px 2px #000000);
}
</style>
\ No newline at end of file
</style>
......@@ -2,33 +2,49 @@
<!-- Preview header -->
<div class="row">
<div class="col-xs-12">
<h3>Preview</h3>
<h3 v-if="previewHeading">{{ previewHeading }}</h3>
</div>
</div>
<div class="row">
<!-- Loading spinner -->
<div v-if="show_spinner" class="md-5 p-5 text-center spinner-container">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
<div class="row preview-stage-row" v-if="hasPreviewFile || show_spinner">
<div class="col-12" ref="previewStageWrapper">
<div class="preview-stage" :class="{'is-resizing': isResizingStage}" :style="previewStageStyle">
<button
v-if="canOpenClipEffects"
type="button"
class="preview-effects-button effects-trigger"
:class="{ active: hasPreviewEffectsChanges }"
title="Effects and Text Captions"
@click.stop="openPreviewEffects"
>
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" fill="currentColor" class="bi bi-stars" viewBox="0 0 16 16">
<path d="M7.5 2.5a.5.5 0 0 1 .5.5c0 .654.291 1.304.854 1.866.562.563 1.212.854 1.866.854a.5.5 0 0 1 0 1c-.654 0-1.304.291-1.866.854-.563.562-.854 1.212-.854 1.866a.5.5 0 0 1-1 0c0-.654-.291-1.304-.854-1.866C5.084 6.511 4.434 6.22 3.78 6.22a.5.5 0 0 1 0-1c.654 0 1.304-.291 1.866-.854C6.208 3.804 6.5 3.154 6.5 2.5a.5.5 0 0 1 .5-.5z"/>
<path d="M2.5 9.5a.5.5 0 0 1 .5.5c0 .379.168.756.494 1.082.326.326.703.494 1.082.494a.5.5 0 0 1 0 1 2.58 2.58 0 0 0-1.082.494A1.53 1.53 0 0 0 3 14.152a.5.5 0 0 1-1 0 1.53 1.53 0 0 0-.494-1.082A2.58 2.58 0 0 0 .152 12.5a.5.5 0 0 1 0-1c.379 0 .756-.168 1.082-.494.326-.326.494-.703.494-1.082a.5.5 0 0 1 .5-.5zM12.5.5a.5.5 0 0 1 .5.5c0 .506.225 1.005.659 1.439.434.434.933.659 1.439.659a.5.5 0 0 1 0 1 2.74 2.74 0 0 0-1.439.659A2.04 2.04 0 0 0 13 5.196a.5.5 0 0 1-1 0 2.04 2.04 0 0 0-.659-1.439A2.74 2.74 0 0 0 9.902 3.1a.5.5 0 0 1 0-1c.506 0 1.005-.225 1.439-.659C11.774 1.505 12 .506 12 0a.5.5 0 0 1 .5-.5z"/>
</svg>
</button>
<div v-if="show_spinner" class="preview-stage-spinner">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<video ref="video" v-if="!isImage && hasPreviewFile" v-show="media_loaded" @loadeddata="videoLoaded" @timeupdate="videoTimeUpdate" @pause="togglePause" @play="togglePause" loop preload="" poster="" class="preview-media">
<source :type="previewFormat" :src="preview.file.media" @error="mediaError">
</video>
<img v-if="isImage && hasPreviewFile" v-show="media_loaded" :src="preview.file.media" @load="imageLoaded" @error="mediaError" class="preview-media" />
<button type="button" class="preview-stage-resizer" title="Resize preview height" aria-label="Resize preview height"
@mousedown.prevent="startStageResize"
@touchstart.prevent="startStageResize"
@dblclick.stop.prevent="resetStageHeight">
<span class="visually-hidden">Resize preview height</span>
</button>
</div>
</div>
<!-- Preview Video/Image -->
<div class="col-sm-12">
<video ref="video" v-if="!isImage && hasPreviewFile" v-show="media_loaded" @loadeddata="videoLoaded" @timeupdate="videoTimeUpdate" @pause="togglePause" @play="togglePause" loop preload="" poster="" class="video-responsive">
<source :type="previewFormat" :src="preview.file.media" @error="mediaError">
</video>
<img v-if="isImage && hasPreviewFile" v-show="media_loaded" :src="preview.file.media" @load="imageLoaded" @error="mediaError" class="img-fluid img-thumbnail" />
</div>
</div>
<!-- Timeline Container -->
<div v-if="hasPreviewFile && media_loaded" class="d-flex flex-column d-sm-flex flex-sm-row">
<div v-if="hasPreviewFile" class="timeline-controls-row" ref="timelineSection">
<!-- Play/Pause button -->
<div class="col-sm-1 d-flex flex-column p-1">
<div class="timeline-button timeline-button-left">
<button title="Play/Pause Preview" type="button" class="btn btn-secondary timeline-btn" @click="togglePlayback">
<svg v-show="is_paused" xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-play-fill" viewBox="0 0 16 16">
<path d="m11.596 8.697-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/>
......@@ -40,8 +56,8 @@
</div>
<!-- Timeline w/ Clip Trimming -->
<div class="col-sm-10 d-flex flex-column p-1">
<div class="timeline" ref="timeline" @mousedown="seekToCursor($event)" @mousemove="drag($event)" @touchmove="drag($event)" @mouseup="endDrag" @touchend="endDrag" @mouseleave="endDrag">
<div class="timeline-track flex-fill">
<div class="timeline" ref="timeline" @mousedown="seekToCursor($event)" @mousemove="drag($event)" @touchmove="drag($event)" @mouseup="endDrag($event)" @touchend="endDrag($event)">
<div title="Drag Playback Position" ref="playhead" class="playhead" @mousedown="startDrag($event, 'playhead')" @touchstart="startDrag($event, 'playhead')" :style="{left: preview.position * 100.0 + '%'}"></div>
<div title="Drag to Trim Start" ref="start" class="marker marker_left" @click="seekToMarker('start')" @mousedown="startDrag($event, 'start')" @touchstart="startDrag($event, 'start')" :style="{left: preview.start * 100.0 + '%'}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="48" fill="currentColor" class="bi bi-grip-vertical" viewBox="0 0 16 16">
......@@ -53,18 +69,21 @@
<path d="M7 2a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 5a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zM7 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm-3 3a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0z"/>
</svg>
</div>
<div ref="clip" class="clip" :class="clipSizeClass" :style="{left: preview.start * 100.0 + '%', width: `calc(${(preview.end - preview.start) * 100.0}%)`}">
<div ref="clip" class="clip" :class="clipSizeClass" :style="{left: preview.start * 100.0 + '%', width: `calc(${(preview.end - preview.start) * 100.0}%)`}" @mousedown="startDrag($event, 'clip')" @touchstart="startDrag($event, 'clip')">
{{ preview.length.toFixed(1) }} Seconds
</div>
</div>
</div>
<!-- Add Clip button -->
<div class="col-sm-1 d-flex flex-column p-1">
<button title="Add Clip" type="button" class="btn btn-secondary timeline-btn" @click="createClipBtn">
<svg xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16">
<div class="timeline-button timeline-button-right">
<button :title="actionButtonTitle" type="button" class="btn btn-success timeline-btn d-flex flex-column justify-content-center align-items-center" @click="createClipBtn">
<svg v-if="!isEditingClip" xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-plus" viewBox="0 0 16 16">
<path d="M8 4a.5.5 0 0 1 .5.5v3h3a.5.5 0 0 1 0 1h-3v3a.5.5 0 0 1-1 0v-3h-3a.5.5 0 0 1 0-1h3v-3A.5.5 0 0 1 8 4z"/>
</svg>
<svg v-else xmlns="http://www.w3.org/2000/svg" width="25" height="25" fill="currentColor" class="bi bi-check-lg" viewBox="0 0 16 16">
<path d="M13.485 1.431a1 1 0 0 1 .084 1.41l-7 8a1 1 0 0 1-1.497.05l-3-3a1 1 0 0 1 1.414-1.414l2.235 2.236 6.356-7.272a1 1 0 0 1 1.408-.01z"/>
</svg>
</button>
</div>
</div>
......@@ -72,7 +91,7 @@
<!-- No Preview / No File message -->
<div v-if="!hasPreviewFile" class="mb-5 text-center" style="margin-top: 7em;">
<div class="col-lg-6 mx-auto">
<p class="lead">Upload or select a video to begin!</p>
<p class="lead">{{ emptyStateMessage }}</p>
</div>
</div>
......@@ -83,18 +102,59 @@
</div>
</div>
<ClipEffectsModal
ref="clipEffectsModal"
@save="handleClipEffectsSave"
@reset="handleClipEffectsReset"
/>
</template>
<script>
import {mapState, mapActions, mapGetters, mapMutations} from "vuex";
import {fixImageDuration} from "../store/axios";
import ClipEffectsModal from "./ClipEffectsModal";
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'];
const MEDIA_MIME_TYPES = {
mp4: 'video/mp4',
m4v: 'video/mp4',
mov: 'video/quicktime',
webm: 'video/webm',
ogv: 'video/ogg',
ogg: 'video/ogg',
mkv: 'video/x-matroska',
avi: 'video/x-msvideo',
mpg: 'video/mpeg',
mpeg: 'video/mpeg',
mp3: 'audio/mpeg',
wav: 'audio/wav',
aac: 'audio/aac',
m4a: 'audio/mp4',
flac: 'audio/flac',
oga: 'audio/ogg',
opus: 'audio/opus',
wma: 'audio/x-ms-wma',
aif: 'audio/aiff',
aiff: 'audio/aiff'
};
export default {
name: "Preview.vue",
props: ['project'],
components: {
ClipEffectsModal
},
data() {
return {
dragging_marker: null,
clipDragOffset: 0,
windowDragHandler: null,
windowEndHandler: null,
dragHasMoved: false,
dragStartPercent: 0.0,
is_paused: true,
marker_width: 12,
marker_width_percent: 0.0,
......@@ -102,7 +162,18 @@ export default {
show_spinner: false,
media_loaded: false,
media_error: false,
loaded_file_id: null
loaded_file_id: null,
stageHeightAuto: null,
stageHeightManual: null,
stageMeasureRaf: null,
stageResizeMoveHandler: null,
stageResizeEndHandler: null,
stageResizeStartY: 0,
stageResizeStartHeight: 0,
isResizingStage: false,
stageMinHeight: 220,
stageMaxHeight: 720,
pendingClipEffects: null
}
},
methods: {
......@@ -119,20 +190,27 @@ export default {
"json": {}
}
let payload = {project_id: this.project.id, project_url: this.project.url, data}
await this.createClip(payload)
const newClip = await this.createClip(payload)
if (newClip && this.pendingClipEffects && this.pendingClipEffects.form) {
await this.persistClipEffects(newClip, this.pendingClipEffects.form, !!this.pendingClipEffects.hasCustomizations)
}
this.pendingClipEffects = null
if (newClip) {
await this.normalizeClipPositions()
}
}
else
{
// 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)
// Re-order clips (and fix 'position' to be valid again)
let move_payload = {direction: null, clip: clipObj, current: -1, dest: -1 }
await this.moveClip(move_payload)
await this.normalizeClipPositions()
}
this.setPreviewFile(null)
},
......@@ -172,6 +250,7 @@ export default {
this.media_loaded = true
this.media_error = false
this.checkClipSize()
this.scheduleStageMeasure()
if (this.$refs.video && this.preview.clip) {
this.$refs.video.currentTime = this.preview.clip.start
}
......@@ -186,10 +265,12 @@ export default {
this.media_loaded = true
this.media_error = false
this.checkClipSize()
this.scheduleStageMeasure()
},
mediaError() {
this.show_spinner = false
this.media_error = true
this.scheduleStageMeasure()
},
checkClipSize() {
if (this.$refs.clip && this.$refs.clip.clientWidth < 80) {
......@@ -203,20 +284,48 @@ export default {
e.preventDefault()
e.stopPropagation()
}
this.dragHasMoved = false
if (this.$refs.timeline) {
this.dragStartPercent = this.getCursorPosition(e)
}
if (this.$refs.video) {
this.$refs.video.pause()
}
if (marker == 'clip') {
let percent_x = this.getCursorPosition(e)
let clip_length = this.preview.end - this.preview.start
this.clipDragOffset = percent_x - this.preview.start
if (!isFinite(this.clipDragOffset)) {
this.clipDragOffset = 0
} else {
this.clipDragOffset = Math.max(0, Math.min(clip_length, this.clipDragOffset))
}
}
this.dragging_marker = marker
this.attachGlobalDragListeners()
},
endDrag() {
endDrag(e) {
if (!this.dragging_marker) {
return
}
const marker = this.dragging_marker
const didMove = this.dragHasMoved
this.dragging_marker = null
this.clipDragOffset = 0
this.dragHasMoved = false
if (marker == 'clip' && !didMove) {
let percent_x = this.dragStartPercent
if (e) {
percent_x = this.getCursorPosition(e)
}
this.jumpPlayheadToPercent(percent_x)
}
this.detachGlobalDragListeners()
},
seekToCursor(e) {
if (!this.dragging_marker) {
let percent_x = this.getCursorPosition(e)
if (this.$refs.video) {
this.$refs.video.currentTime = percent_x * this.$refs.video.duration
}
this.jumpPlayheadToPercent(percent_x)
return false
}
},
......@@ -230,6 +339,10 @@ export default {
}
},
drag(e) {
if (!this.dragging_marker) {
return
}
this.dragHasMoved = true
this.checkClipSize()
if (this.dragging_marker == 'start') {
this.setPreview({start: Math.min(this.getCursorPosition(e), this.preview.end),
......@@ -249,6 +362,20 @@ export default {
if (this.$refs.video && isFinite(this.preview.start * this.$refs.video.duration)) {
this.$refs.video.currentTime = percent_x * this.$refs.video.duration
}
} else if (this.dragging_marker == 'clip') {
let percent_x = this.getCursorPosition(e)
let clip_length = this.preview.end - this.preview.start
let new_start = percent_x - this.clipDragOffset
if (!isFinite(new_start)) {
new_start = 0
}
new_start = Math.max(0, Math.min(1 - clip_length, new_start))
let new_end = new_start + clip_length
this.setPreview({start: new_start, end: new_end})
this.setPreviewPosition(new_start)
if (this.$refs.video && isFinite(new_start * this.$refs.video.duration)) {
this.$refs.video.currentTime = new_start * this.$refs.video.duration
}
}
},
getMarkerWidth() {
......@@ -282,12 +409,236 @@ export default {
let percent_x = (relative_x / timeline_bounds.width)
return percent_x
},
...mapActions(['createClip', 'editClip', 'moveClip']),
...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile'])
attachGlobalDragListeners() {
if (!this.windowDragHandler) {
this.windowDragHandler = (event) => this.drag(event)
}
if (!this.windowEndHandler) {
this.windowEndHandler = (event) => this.endDrag(event)
}
window.addEventListener('mousemove', this.windowDragHandler)
window.addEventListener('touchmove', this.windowDragHandler)
window.addEventListener('mouseup', this.windowEndHandler)
window.addEventListener('touchend', this.windowEndHandler)
window.addEventListener('touchcancel', this.windowEndHandler)
},
detachGlobalDragListeners() {
if (this.windowDragHandler) {
window.removeEventListener('mousemove', this.windowDragHandler)
window.removeEventListener('touchmove', this.windowDragHandler)
}
if (this.windowEndHandler) {
window.removeEventListener('mouseup', this.windowEndHandler)
window.removeEventListener('touchend', this.windowEndHandler)
window.removeEventListener('touchcancel', this.windowEndHandler)
}
},
jumpPlayheadToPercent(percent) {
const clamped = Math.max(0, Math.min(1, percent))
this.setPreviewPosition(clamped)
if (this.$refs.video && isFinite(clamped * this.$refs.video.duration)) {
this.$refs.video.currentTime = clamped * this.$refs.video.duration
}
},
scheduleStageMeasure() {
if (this.stageMeasureRaf) {
cancelAnimationFrame(this.stageMeasureRaf)
}
this.stageMeasureRaf = requestAnimationFrame(() => {
this.stageMeasureRaf = null
this.calculateStageHeight()
})
},
calculateStageHeight() {
if (!this.$refs.previewStageWrapper) {
this.stageHeightAuto = this.stageMinHeight
return
}
const wrapperRect = this.$refs.previewStageWrapper.getBoundingClientRect()
let timelineHeight = 220
if (this.$refs.timelineSection) {
const timelineRect = this.$refs.timelineSection.getBoundingClientRect()
timelineHeight = Math.max(120, timelineRect.height)
}
let available = window.innerHeight - wrapperRect.top - timelineHeight - 32
if (!isFinite(available)) {
available = this.stageMinHeight
}
const clamped = Math.max(this.stageMinHeight, Math.min(this.stageMaxHeight, available))
this.stageHeightAuto = clamped
},
onWindowResize() {
this.scheduleStageMeasure()
},
startStageResize(event) {
if (event && event.type === 'mousedown' && event.detail && event.detail > 1) {
return
}
if (event && event.cancelable) {
event.preventDefault()
}
const clientY = this.getClientY(event)
if (clientY === null) {
return
}
this.isResizingStage = true
this.stageResizeStartY = clientY
this.stageResizeStartHeight = this.currentStageHeight
this.stageHeightManual = this.stageResizeStartHeight
if (!this.stageResizeMoveHandler) {
this.stageResizeMoveHandler = (e) => this.handleStageResize(e)
}
if (!this.stageResizeEndHandler) {
this.stageResizeEndHandler = (e) => this.endStageResize(e)
}
window.addEventListener('mousemove', this.stageResizeMoveHandler)
window.addEventListener('touchmove', this.stageResizeMoveHandler)
window.addEventListener('mouseup', this.stageResizeEndHandler)
window.addEventListener('touchend', this.stageResizeEndHandler)
window.addEventListener('touchcancel', this.stageResizeEndHandler)
},
handleStageResize(event) {
if (event && event.cancelable) {
event.preventDefault()
}
const clientY = this.getClientY(event)
if (clientY === null) {
return
}
const delta = clientY - this.stageResizeStartY
const nextHeight = this.stageResizeStartHeight + delta
this.stageHeightManual = Math.max(this.stageMinHeight, Math.min(this.stageMaxHeight, nextHeight))
},
endStageResize() {
if (!this.isResizingStage) {
return
}
this.isResizingStage = false
this.detachStageResizeListeners()
},
detachStageResizeListeners() {
if (this.stageResizeMoveHandler) {
window.removeEventListener('mousemove', this.stageResizeMoveHandler)
window.removeEventListener('touchmove', this.stageResizeMoveHandler)
}
if (this.stageResizeEndHandler) {
window.removeEventListener('mouseup', this.stageResizeEndHandler)
window.removeEventListener('touchend', this.stageResizeEndHandler)
window.removeEventListener('touchcancel', this.stageResizeEndHandler)
}
},
getClientY(event) {
if (event.touches && event.touches.length) {
return event.touches[0].clientY
}
if (event.changedTouches && event.changedTouches.length) {
return event.changedTouches[0].clientY
}
if (typeof event.clientY === 'number') {
return event.clientY
}
return null
},
resetStageHeight() {
this.stageHeightManual = null
this.scheduleStageMeasure()
},
openPreviewEffects() {
if (!this.$refs.clipEffectsModal || !this.preview.file) {
return
}
const clipIndex = Array.isArray(this.clips) && this.preview.clip
? this.clips.findIndex(clip => clip.id === this.preview.clip.id)
: -1
const appliedEffects = Array.isArray(this.preview.clip?.json?.effects) ? this.preview.clip.json.effects : []
const pendingForm = !this.preview.clip && this.pendingClipEffects ? this.pendingClipEffects.form : null
this.$refs.clipEffectsModal.open({
clip: this.preview.clip || null,
clipIndex: clipIndex >= 0 ? clipIndex + 1 : null,
clipName: this.preview.clip?.name || this.getPreviewFileName(),
fileName: this.getPreviewFileName(),
text: pendingForm?.text,
position: pendingForm?.position,
audioLevel: pendingForm?.audioLevel,
motion: pendingForm?.motion,
appliedEffects: this.preview.clip ? appliedEffects : (pendingForm?.appliedEffects || [])
})
},
getPreviewFileName() {
if (!this.preview.file) {
return 'Current Clip'
}
return this.preview.file.name || this.preview.file.title || this.preview.file.filename || this.preview.file.media?.split('/').pop() || 'Current Clip'
},
async handleClipEffectsSave(payload) {
if (!payload.form) {
return
}
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj) {
this.pendingClipEffects = {
form: JSON.parse(JSON.stringify(payload.form)),
hasCustomizations: !!payload.hasCustomizations
}
return
}
this.pendingClipEffects = null
const updatedClip = await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
if (updatedClip && this.preview.clip && this.preview.clip.id === updatedClip.id) {
this.setPreviewClip(updatedClip)
}
},
async handleClipEffectsReset(payload) {
if (!payload.form) {
return
}
const clipObj = payload.clip || this.clips.find(clip => clip.id === payload.clipId)
if (!clipObj) {
this.pendingClipEffects = null
return
}
const updatedClip = await this.persistClipEffects(clipObj, payload.form, !!payload.hasCustomizations)
if (updatedClip && this.preview.clip && this.preview.clip.id === updatedClip.id) {
this.setPreviewClip(updatedClip)
}
},
async persistClipEffects(clipObj, form, fxEnabled) {
if (!clipObj || !form) {
return null
}
const currentFxEnabled = !!clipObj.json?.fx_enabled
const needsUpdate = hasClipEffectsChanges(clipObj, form) || currentFxEnabled !== fxEnabled
if (!needsUpdate) {
clipObj.json.fx_enabled = fxEnabled
return clipObj
}
const updatedClip = createClipWithEffectsForm(clipObj, form, { fxEnabled })
if (!updatedClip) {
return null
}
const editPayload = { data: updatedClip, latest: false, thumbnail: false }
await this.editClip(editPayload)
Object.assign(clipObj, updatedClip)
return clipObj
},
...mapActions(['createClip', 'editClip', 'normalizeClipPositions']),
...mapMutations(['setPreview', 'setPreviewPosition', 'setPreviewFile', 'setPreviewClip'])
},
computed: {
...mapState(['preview']),
...mapState(['preview', 'files', 'clips']),
...mapGetters(['totalClipDuration']),
isEditingClip() {
return !!this.preview.clip
},
actionButtonTitle() {
return this.isEditingClip ? 'Save Clip' : 'Add Clip'
},
previewHeading() {
if (!this.hasPreviewFile) {
return ''
}
return this.isEditingClip ? 'Edit Clip' : 'Create Clip'
},
hasPreviewFile() {
if (this.preview.file) {
return true
......@@ -295,17 +646,107 @@ export default {
return false
}
},
canOpenClipEffects() {
return this.hasPreviewFile
},
hasPreviewEffectsChanges() {
if (this.preview.clip) {
return !!this.preview.clip.json?.fx_enabled
}
return !!(this.pendingClipEffects && this.pendingClipEffects.hasCustomizations)
},
hasAnyFiles() {
return Array.isArray(this.files) && this.files.length > 0
},
hasAnyClips() {
return Array.isArray(this.clips) && this.clips.length > 0
},
emptyStateMessage() {
if (!this.hasAnyFiles) {
return 'Upload a file to begin'
}
if (this.hasAnyFiles && !this.hasAnyClips) {
return 'Choose a file to add your first clip'
}
return 'Choose a file or clip to continue'
},
mediaExtension() {
if (!this.preview.file || !this.preview.file.media) {
return ''
}
const mediaPath = this.preview.file.media.split('?')[0]
if (!mediaPath.includes('.')) {
return ''
}
return mediaPath.split('.').pop().toLowerCase()
},
isImage() {
return (this.preview.file && this.preview.file.json.vcodec == "QImage")
if (!this.preview.file) {
return false
}
const codec = (this.preview.file.json && this.preview.file.json.vcodec) ? this.preview.file.json.vcodec.toLowerCase() : ''
if (codec === 'qimage') {
return true
}
const type = (this.preview.file.json && this.preview.file.json.type) ? this.preview.file.json.type.toLowerCase() : ''
if (type === 'image') {
return true
}
const ext = this.mediaExtension
return !!ext && IMAGE_FILE_EXTENSIONS.includes(ext)
},
isAudioOnly() {
if (!this.preview.file) {
return false
}
const ext = this.mediaExtension
if (ext && AUDIO_FILE_EXTENSIONS.includes(ext)) {
return true
}
const type = (this.preview.file.json && this.preview.file.json.type) ? this.preview.file.json.type.toLowerCase() : ''
if (type === 'audio') {
return true
}
const codec = (this.preview.file.json && this.preview.file.json.vcodec) ? this.preview.file.json.vcodec.toLowerCase() : ''
const hasAudioCodec = !!(this.preview.file.json && this.preview.file.json.acodec)
return (!codec || codec === 'none') && hasAudioCodec
},
previewFormat() {
if (this.preview.file && this.preview.file.json.acodec == "mp3") {
if (!this.preview.file) {
return null
}
const ext = this.mediaExtension
if (ext && MEDIA_MIME_TYPES[ext]) {
return MEDIA_MIME_TYPES[ext]
}
if (this.isAudioOnly) {
return 'audio/mpeg'
} else {
return `video/${this.preview.file.media.split('.').pop()}`
}
return 'video/mp4'
},
currentStageHeight() {
let height = this.stageHeightManual
if (height === null || typeof height === 'undefined') {
height = this.stageHeightAuto
}
if (height === null || typeof height === 'undefined') {
height = this.stageMinHeight
}
return Math.max(this.stageMinHeight, Math.min(this.stageMaxHeight, height))
},
previewStageStyle() {
const height = this.currentStageHeight
return {
minHeight: `${this.stageMinHeight}px`,
maxHeight: `${this.stageMaxHeight}px`,
height: `${height}px`
}
}
},
mounted() {
this.scheduleStageMeasure()
window.addEventListener('resize', this.onWindowResize)
},
watch: {
'preview.file': function() {
this.media_error = false
......@@ -313,6 +754,7 @@ export default {
this.show_spinner = true
this.media_loaded = false
}
this.scheduleStageMeasure()
if (this.$refs.video) {
this.is_paused = true
this.$refs.video.pause()
......@@ -327,14 +769,153 @@ export default {
},
updated() {
this.marker_width_percent = this.getMarkerWidth()
},
beforeUnmount() {
this.detachGlobalDragListeners()
this.detachStageResizeListeners()
window.removeEventListener('resize', this.onWindowResize)
if (this.stageMeasureRaf) {
cancelAnimationFrame(this.stageMeasureRaf)
this.stageMeasureRaf = null
}
}
}
</script>
<style scoped>
video {
.preview-effects-button {
position: absolute;
top: 10px;
right: 10px;
border: none;
padding: 6px;
border-radius: 999px;
background-color: rgba(0, 0, 0, 0.5);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.6);
color: #ffffff;
display: inline-flex;
align-items: center;
justify-content: center;
transition: background-color 0.15s ease, color 0.15s ease;
}
.preview-effects-button.active {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.2);
}
.preview-effects-button:hover {
color: #0d6efd;
background-color: rgba(13, 110, 253, 0.2);
}
.preview-effects-button:focus-visible {
outline: 2px solid #0d6efd;
}
.preview-effects-button svg {
pointer-events: none;
}
.preview-stage-row {
margin-bottom: 1rem;
}
.preview-stage {
background-color: #000000;
border-radius: 8px;
padding: 0.5rem;
min-height: 220px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
transition: height 0.2s ease;
}
.preview-stage-spinner {
position: absolute;
inset: 0;
display: flex;
justify-content: center;
align-items: center;
pointer-events: none;
}
.preview-media {
width: 100%;
height: 100%;
object-fit: contain;
background-color: #000000;
border: none;
box-shadow: none;
}
.preview-stage-resizer {
position: absolute;
width: 22px;
height: 22px;
bottom: 4px;
right: 4px;
border: none;
background: transparent;
padding: 0;
cursor: ns-resize;
touch-action: none;
}
.preview-stage-resizer::after {
content: '';
position: absolute;
inset: 4px;
border-right: 2px solid rgba(255, 255, 255, 0.6);
border-bottom: 2px solid rgba(255, 255, 255, 0.6);
}
.preview-stage-resizer:focus-visible {
outline: 2px solid #0d6efd;
}
.preview-stage.is-resizing {
cursor: ns-resize;
}
.spinner-border {
margin-top: 0;
width: 4em;
height: 4em;
}
.timeline-controls-row {
display: flex;
flex-direction: row;
align-items: stretch;
gap: 0.5rem;
margin-bottom: 1.25rem;
flex-wrap: nowrap;
}
.timeline-button {
flex: 0 0 64px;
display: flex;
align-items: center;
}
.timeline-button-left {
justify-content: flex-start;
}
.timeline-button-right {
justify-content: flex-end;
}
.timeline-track {
flex: 1 1 auto;
display: flex;
min-width: 0;
}
@media (max-width: 576px) {
.timeline-controls-row {
margin-bottom: 1rem;
gap: 0.35rem;
}
.timeline-button {
flex-basis: 56px;
}
.preview-stage {
padding: 0.25rem;
}
.preview-stage-resizer {
width: 18px;
height: 18px;
}
.marker svg {
width: 12px;
height: 48px;
}
}
.timeline {
background-color: #000000;
......@@ -362,6 +943,11 @@ video {
font-size: 0.7em;
text-align: center;
padding-top: 15px;
cursor: grab;
user-select: none;
}
.clip:active {
cursor: grabbing;
}
.clip-too-small {
color: #0d6efd!important;
......@@ -390,12 +976,4 @@ video {
.img-thumbnail {
width: 100%!important;
}
.spinner-border {
margin-top: 100px;
width: 4em;
height: 4em;
}
.spinner-container {
min-height: 300px;
}
</style>
\ No newline at end of file
</style>
......@@ -41,8 +41,8 @@
</div>
<!-- Loading spinner -->
<div v-if="show_spinner" class="md-5 p-4 text-center">
<div class="spinner-border" role="status">
<div v-if="show_spinner" class="md-5 p-4 text-center project-list-spinner">
<div class="spinner-border project-spinner" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
......@@ -85,10 +85,51 @@
<input v-model="project_fps_den" type="text" class="form-control" placeholder="FPS Denominator">
</div>
<div v-if="!editing" class="mb-3">
<div class="form-check form-switch">
<input class="form-check-input"
type="checkbox"
id="loadArchiveSwitch"
v-model="load_archive_enabled"
@change="archiveSwitchChanged">
<label class="form-check-label" for="loadArchiveSwitch">Load Existing Project</label>
</div>
<div v-if="load_archive_enabled" class="mt-2">
<input ref="projectArchiveInput"
type="file"
class="d-none"
accept=".zip,.osp"
@change="onArchiveSelected">
<div class="d-flex align-items-center flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm" @click="triggerArchiveBrowse">
Browse Project
</button>
<span v-if="project_archive_file" class="text-muted archive-file-name">{{ project_archive_file.name }}</span>
<span v-else class="text-muted">No project selected</span>
<button v-if="project_archive_file"
type="button"
class="btn btn-link btn-sm text-decoration-none"
@click="clearArchiveSelection">
Clear
</button>
</div>
<div class="form-text">
Optional: load *.zip of an existing OpenShot project.
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" ref="CloseProjectModal" data-bs-dismiss="modal">Cancel</button>
<button v-if="!editing" type="button" class="btn btn-primary" @click="createProjectClick">Create Project</button>
<button v-if="!editing"
type="button"
class="btn btn-primary"
:disabled="creating_project"
@click="createProjectClick">
<span v-if="creating_project" class="spinner-border spinner-border-sm align-middle me-2 create-btn-spinner" role="status" aria-hidden="true"></span>
Create Project
</button>
<button v-if="editing" type="button" class="btn btn-primary" @click="updateProjectClick">Save Project</button>
</div>
</div>
......@@ -133,7 +174,10 @@ export default {
editing: false,
show_spinner: false,
selected_profile: 3031,
profiles
profiles,
project_archive_file: null,
creating_project: false,
load_archive_enabled: false
}
},
methods: {
......@@ -152,6 +196,7 @@ export default {
this.clearErrors()
this.selectedProject = project
this.editing = true
this.disableArchiveSection()
// Set profile to match current project settings
let profile_details = this.profiles.filter(p => p.width == project.width &&
......@@ -178,11 +223,26 @@ export default {
this.project_width = 1920
this.project_height = 1080
this.selected_profile = 3031
this.disableArchiveSection()
let myModal = new Modal(this.$refs.newProjectModal)
myModal.show()
},
hideNewProjectModal() {
if (!this.$refs.newProjectModal) {
return
}
let myModal = Modal.getInstance(this.$refs.newProjectModal)
if (!myModal) {
myModal = new Modal(this.$refs.newProjectModal)
}
myModal.hide()
},
async createProjectClick() {
if (this.creating_project) {
return
}
this.creating_project = true
let payload = {
"name": this.project_name,
"width": this.project_width,
......@@ -195,8 +255,65 @@ export default {
"json": {}
}
await this.createProject(payload)
this.$refs.CloseProjectModal.click();
try {
let project = await this.createProject(payload)
if (project && project.id) {
await this.maybeLoadArchive(project.id)
this.hideNewProjectModal()
this.$router.push(`/projects/${project.id}`)
} else if (!project) {
this.clearArchiveSelection()
}
} finally {
this.creating_project = false
}
},
triggerArchiveBrowse() {
if (this.$refs.projectArchiveInput) {
this.$refs.projectArchiveInput.click()
}
},
archiveSwitchChanged() {
if (!this.load_archive_enabled) {
this.clearArchiveSelection()
}
},
disableArchiveSection() {
this.load_archive_enabled = false
this.clearArchiveSelection()
},
onArchiveSelected(event) {
const files = event?.target?.files
if (!files || files.length === 0) {
this.project_archive_file = null
return
}
const file = files[0]
const fileName = file.name?.toLowerCase() || ''
if (!fileName.endsWith('.zip') && !fileName.endsWith('.osp')) {
this.clearArchiveSelection()
return
}
this.project_archive_file = file
},
clearArchiveSelection() {
this.project_archive_file = null
if (this.$refs.projectArchiveInput) {
this.$refs.projectArchiveInput.value = null
}
},
async maybeLoadArchive(projectId) {
if (!this.load_archive_enabled || !this.project_archive_file) {
return
}
try {
await this.loadProjectArchive({ projectId, file: this.project_archive_file })
} catch(err) {
console.error('Failed to load project archive', err)
} finally {
this.clearArchiveSelection()
this.load_archive_enabled = false
}
},
async updateProjectClick() {
this.selectedProject.name = this.project_name
......@@ -219,7 +336,7 @@ export default {
this.$refs.Close.click();
this.selectedProject = null
},
...mapActions(['loadProjects', 'createProject', 'deleteProject', 'updateProject']),
...mapActions(['loadProjects', 'createProject', 'deleteProject', 'updateProject', 'loadProjectArchive']),
...mapMutations(['setProject', 'clearErrors'])
},
computed: {
......@@ -240,7 +357,7 @@ export default {
button {
margin-left: 5px;
}
.spinner-border {
.project-spinner {
width: 3em!important;
height: 3em!important;
}
......@@ -293,4 +410,15 @@ export default {
min-width: 5em;
opacity: 0.8;
}
</style>
\ No newline at end of file
.archive-file-name {
max-width: 180px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.create-btn-spinner {
width: 0.9rem;
height: 0.9rem;
border-width: 0.12em;
}
</style>
......@@ -4,11 +4,18 @@ import router from './router'
import store from './store'
import "bootstrap/dist/css/bootstrap.min.css"
import "bootstrap"
import VueGtag from "vue-gtag";
import { createGtag } from "vue-gtag"
// Global site tag (gtag.js)
const ga_config = {
config: { id: process.env.VUE_APP_GA_ID }
const app = createApp(App)
const tagId = process.env.VUE_APP_GA_ID
if (tagId) {
app.use(createGtag({
tagId,
pageTracker: {
router
}
}))
}
createApp(App).use(VueGtag, ga_config, router).use(store).use(router).mount('#app')
app.use(store).use(router).mount('#app')
......@@ -14,6 +14,7 @@ export default createStore({
effects: [],
project: null,
current_export: { progress: 0.0 },
effectCatalog: [],
user: null,
preview: {
file: null,
......@@ -74,6 +75,9 @@ export default createStore({
effectObj
]
},
setEffectCatalog(state, effects) {
state.effectCatalog = effects || []
},
updateProjectThumbnail(state, payload) {
state.projects = [
...state.projects = state.projects.filter(p => p.id != payload.obj.id),
......@@ -90,6 +94,9 @@ export default createStore({
state.files.push(fileObj)
},
updateFileThumbnail(state, payload) {
if (!state.project?.url || !Array.isArray(payload.obj.projects)) {
return
}
if (payload.obj.projects.includes(state.project.url)) {
state.files = [
...state.files = state.files.filter(file => file.id != payload.obj.id),
......@@ -120,6 +127,9 @@ export default createStore({
state.clips = state.clips.filter(clip => clip.id != clip_id)
},
updateClipThumbnail(state, payload) {
if (!state.project?.url) {
return
}
if (state.project.url == payload.obj.project) {
state.clips = [
...state.clips = state.clips.filter(clip => clip.id != payload.obj.id),
......@@ -228,7 +238,7 @@ export default createStore({
commit('setUser', user_object)
} catch(err) {
commit('setUser', null)
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async loadProjects({commit, dispatch}) {
......@@ -242,24 +252,41 @@ export default createStore({
dispatch('attachThumbnail', payload)
}
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async getProject({commit}, project_id) {
try {
return instance.get(`projects/${project_id}/`)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async createProject({commit, dispatch}, payload) {
try {
const response = await instance.post('/projects/', payload)
commit('addProject', response)
commit('addProject', response.data)
let thumbnail_payload = { obj: response.data, frame: 1 }
dispatch('attachThumbnail', thumbnail_payload)
return response.data
} catch(err) {
commit('addError', err.response?.data || err.message)
return null
}
},
async loadProjectArchive({commit}, payload) {
if (!payload || !payload.projectId || !payload.file) {
return
}
try {
const formData = new FormData()
formData.append('archive', payload.file)
await instance.post(`/projects/${payload.projectId}/load/`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
throw err
}
},
async updateProject({commit}, payload) {
......@@ -267,7 +294,7 @@ export default createStore({
await instance.patch(`${payload.url}`, payload)
commit('setProject', payload)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async deleteProject({commit}, project_id) {
......@@ -275,7 +302,7 @@ export default createStore({
await instance.delete(`projects/${project_id}/`)
commit('deleteProject', project_id)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async loadFiles({commit, dispatch}, project_id) {
......@@ -292,7 +319,7 @@ export default createStore({
}
commit('setFiles', files)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async loadClips({commit, dispatch}, project_id) {
......@@ -311,7 +338,7 @@ export default createStore({
dispatch('attachThumbnail', thumbnail_payload)
}
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async createClip({dispatch, commit}, payload) {
......@@ -324,9 +351,11 @@ export default createStore({
let fps = clipObj.json.reader.fps.num / clipObj.json.reader.fps.den
let thumbnail_payload = { obj: clipObj, frame: clipObj.start * fps, latest: true }
dispatch('attachThumbnail', thumbnail_payload)
await dispatch('attachThumbnail', thumbnail_payload)
return clipObj
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
return null
}
},
async editClip({dispatch, commit}, payload) {
......@@ -341,40 +370,71 @@ export default createStore({
commit('setScrollToClip', payload.data)
}
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async moveClip({dispatch, getters}, payload) {
let local_clips = [...getters.thumbnailedClips]
let reordered_clips = local_clips
if (payload.current != payload.dest) {
reordered_clips = reorderArray(local_clips, payload.current, payload.dest)
if (!Array.isArray(local_clips) || local_clips.length === 0) {
return
}
if (payload.current === payload.dest || payload.current === undefined || payload.dest === undefined) {
return
}
let reordered_clips = reorderArray(local_clips, payload.current, payload.dest)
let pos = 0.0
for (let c of reordered_clips) {
c.position = pos
let edit_payload = { data: c, latest: false, thumbnail: false }
if (c.id == payload.clip.id) {
edit_payload.latest = true
let updates = []
const maxIndex = reordered_clips.length - 1
const affectedStart = Math.max(0, Math.min(payload.current, payload.dest))
const affectedEnd = Math.min(maxIndex, Math.max(payload.current, payload.dest))
for (let i = 0; i < reordered_clips.length; i++) {
let clip = reordered_clips[i]
let newPosition = pos
if (i >= affectedStart && i <= affectedEnd && clip.position !== newPosition) {
clip.position = newPosition
let edit_payload = { data: clip, latest: clip.id == payload.clip.id, thumbnail: false }
updates.push(edit_payload)
}
dispatch('editClip', edit_payload)
pos += (c.end - c.start)
pos += (clip.end - clip.start)
}
for (let edit_payload of updates) {
await dispatch('editClip', edit_payload)
}
await dispatch('normalizeClipPositions')
},
async deleteClip({commit}, clip_id) {
async deleteClip({commit, dispatch}, clip_id) {
try {
await instance.delete(`clips/${clip_id}/`)
commit('deleteClip', clip_id)
await dispatch('normalizeClipPositions')
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async deleteFile({commit, dispatch, state}, file_id) {
const normalizedFileId = typeof file_id === 'number' ? file_id : Number(file_id)
const relatedClips = state.clips.filter(clip => {
let clipFileId = clip?.fileObj?.id
if (!clipFileId && clip?.file) {
if (typeof clip.file === 'string') {
const parts = clip.file.split('/').filter(Boolean)
clipFileId = Number(parts[parts.length - 1])
} else if (typeof clip.file === 'number') {
clipFileId = clip.file
}
}
return clipFileId === normalizedFileId
})
for (const clip of relatedClips) {
await dispatch('deleteClip', clip.id)
}
},
async deleteFile({commit}, file_id) {
try {
await instance.delete(`files/${file_id}/`)
commit('deleteFile', file_id)
commit('deleteFile', normalizedFileId)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async createFile({commit, dispatch}, payload) {
......@@ -401,23 +461,35 @@ export default createStore({
dispatch('attachThumbnail', thumbnail_payload)
} catch(err) {
commit('removeUpload', uploadID)
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async attachThumbnail({commit}, payload) {
let Obj = payload.obj
if (!Obj) {
console.warn('attachThumbnail: missing object data', payload)
return
}
let objectType = null
if (payload.obj.file) {
if (Obj.file) {
// Clip passed in
Obj = payload.obj.fileObj
Obj = Obj.fileObj
objectType = 'clip'
} else if (payload.obj.media) {
} else if (Obj.media) {
// File passed in
objectType = 'file'
} else {
// Project passed in
objectType = 'project'
}
if (!Obj) {
console.warn('attachThumbnail: missing nested object data', payload)
return
}
if (!Obj.url) {
console.warn('attachThumbnail: object missing url', payload)
return
}
let sessionKey = `${objectType}-${Obj.id}-frame${payload.frame}`
if (payload.clobber) {
......@@ -464,7 +536,7 @@ export default createStore({
}
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async createExport({commit}, payload) {
......@@ -472,7 +544,7 @@ export default createStore({
const response = await instance.post('/exports/', payload)
commit('setExport', response.data)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async loadExports({commit}, project_id) {
......@@ -485,7 +557,7 @@ export default createStore({
commit('setExport', export_response.data)
}
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async checkExportProgress({commit}, exportObj) {
......@@ -493,7 +565,7 @@ export default createStore({
const export_response = await instance.get(exportObj.url)
commit('setExport', export_response.data)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async loadEffects({commit}, project_id) {
......@@ -501,7 +573,21 @@ export default createStore({
const effects_response = await instance.get(`projects/${project_id}/effects/`)
commit('setEffects', effects_response.data.results)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async loadEffectCatalog({state, commit}) {
if (state.effectCatalog && state.effectCatalog.length > 0) {
return state.effectCatalog
}
try {
const response = await instance.get('/info/effects/')
const data = response.data && response.data.results ? response.data.results : response.data
commit('setEffectCatalog', data || [])
return data
} catch(err) {
commit('addError', err.response?.data || err.message)
return []
}
},
async createEffect({commit}, payload) {
......@@ -509,7 +595,7 @@ export default createStore({
const response = await instance.post('/effects/', payload)
commit('addEffect', response.data)
} catch(err) {
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async updateEffect({commit}, payload) {
......@@ -518,7 +604,26 @@ export default createStore({
commit('setEffect', payload)
} catch(err) {
console.log(err)
commit('addError', err.response.data)
commit('addError', err.response?.data || err.message)
}
},
async normalizeClipPositions({getters, dispatch}) {
const clips = [...getters.thumbnailedClips]
if (!Array.isArray(clips) || clips.length === 0) {
return
}
let position = 0
const updates = []
for (let clip of clips) {
const duration = Math.max(0, (clip.end - clip.start))
if (Math.abs((clip.position ?? 0) - position) > 0.0001) {
clip.position = position
updates.push({ data: clip, latest: false, thumbnail: false })
}
position += duration
}
for (let payload of updates) {
await dispatch('editClip', payload)
}
},
},
......
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
}
......@@ -27,14 +27,15 @@
</div>
<!-- Description of OpenShot Cloud API -->
<div class="row">
<div class="col-md-12 ms-5 me-5">
<div class="row ms-5 me-5 g-4 align-items-stretch">
<div class="col-12 col-lg-7">
<p class="lead">
A simple video editor built with JavaScript, Node.js, and Vue 3 (powered by
<a href="https://www.openshot.org/cloud-api/">OpenShot Cloud API</a>).
This is a demo application for OpenShot Cloud API. You can log-in, upload files,
create/edit clips, move clips (up/down), export, and download a video.
This is a demo application for OpenShot Cloud API. You can log-in, create/load projects, upload files,
create/edit clips, move clips, add clip effects (text/captions, animations, volume, and effects),
export, and download a video or project file.
</p>
<p class="lead">
The source code is hosted on <a href="http://gitlab.openshot.org/public-projects/simple-editor">OpenShot
......@@ -43,18 +44,28 @@
as a quick and affordable starting point, to accelerate your own custom software development.
</p>
</div>
<div class="col-12 col-lg-5">
<CloudInstancePromo
label="Unlock the full experience"
title="Launch your own instance"
/>
</div>
</div>
</template>
<script>
export default {
data() {
return {
}
}
import CloudInstancePromo from '../components/CloudInstancePromo.vue'
export default {
components: {
CloudInstancePromo
},
data() {
return {}
}
}
</script>
<style scoped>
</style>
\ No newline at end of file
</style>
......@@ -37,7 +37,7 @@ export default {
this.checkExportProgress(this.activeExports[0])
}
},
...mapActions(['getProject', 'loadExports', 'checkExportProgress', 'loadEffects', 'attachThumbnail']),
...mapActions(['getProject', 'loadExports', 'checkExportProgress', 'loadEffects', 'loadEffectCatalog', 'attachThumbnail']),
...mapMutations(['addError', 'setProject', 'setExports'])
},
computed: {
......@@ -55,20 +55,23 @@ export default {
let project_id = this.$route.params.id
this.loadExports(project_id)
this.loadEffects(project_id)
this.loadEffectCatalog()
let results = await this.getProject(project_id)
this.setProject(results.data)
this.hasData = true
} catch(err) {
// Handle 404
this.addError(err.response.data)
this.addError(err.response?.data || err.message)
await this.$router.push(`/`)
}
this.exportPollInterval = setInterval(this.pollForExports, 1000)
},
unmounted() {
// Update project thumbnail
let thumbnail_payload = { obj: this.project, frame: 1, clobber: true }
this.attachThumbnail(thumbnail_payload)
if (this.project) {
let thumbnail_payload = { obj: this.project, frame: 1, clobber: true }
this.attachThumbnail(thumbnail_payload)
}
this.setProject(null)
this.setExports([])
if (this.exportPollInterval) {
......@@ -80,4 +83,4 @@ export default {
<style scoped>
</style>
\ No newline at end of file
</style>
<template>
<!-- Login container -->
<div v-if="getCloudApiUrl == 'https://cloud.openshot.org'" class="row ps-5 pe-5 justify-content-center">
<div class="col-xs-12 col-md-6 col-lg-4 text-center">
<div v-if="isDemoEnvironment" class="row ps-5 pe-5 justify-content-center">
<div class="col-xs-12 col-md-8 col-lg-6">
<div class="alert alert-primary" role="alert">
<h4>Demo Credentials:</h4>
<strong>User:</strong> demo-cloud, <strong>Password:</strong> demo-password
<p class="pt-3 fw-light">All demo projects are deleted every 12 hours, and are limited to 4 seconds in length.</p>
<div>
<p class="m-0"><strong>Launch</strong> an instance (with no limitations):</p>
<a class="btn btn-lg btn-primary launch-instance" href="https://aws.amazon.com/marketplace/pp/B074H87FSJ/" target="_blank" style="margin: 5px; background-color: #ffffff;" title="Launch Instance: AWS"><img src="//cdn.openshot.org/static/img/icons/aws.svg" height="24"></a>
<a class="btn btn-lg btn-primary launch-instance" href="https://azuremarketplace.microsoft.com/en-us/marketplace/apps/openshotstudiosllc.openshot-cloud-api" target="_blank" style="margin: 5px; background-color: #ffffff;" title="Launch Instance: Azure"><img src="//cdn.openshot.org/static/img/icons/azure.svg" height="24"></a>
<a class="btn btn-lg btn-primary launch-instance" href="javascript:alert('Google Cloud - Coming Soon...')" style="margin: 5px; background-color: #ffffff;" title="Launch Instance: Google Cloud"><img src="//cdn.openshot.org/static/img/icons/google.svg" height="24"></a>
</div>
<h4 class="mb-2">Demo Credentials</h4>
<p class="mb-2">
<strong>User:</strong> demo-cloud,
<strong>Password:</strong> demo-password
</p>
<CloudInstancePromo
label="Unlock the full experience"
title="Launch your own instance"
:subtitle-html="launchSubtitleHtml"
/>
</div>
</div>
</div>
......@@ -34,9 +35,13 @@
<script>
import { mapActions, mapGetters, mapMutations } from 'vuex'
import CloudInstancePromo from '../components/CloudInstancePromo.vue'
export default {
name: "Login.vue",
components: {
CloudInstancePromo
},
data() {
return {
username: null,
......@@ -66,6 +71,18 @@ export default {
getCloudApiUrl() {
return process.env.VUE_APP_OPENSHOT_API_URL
},
isDemoEnvironment() {
const apiUrl = (this.getCloudApiUrl || '').trim()
if (!apiUrl) {
return false
}
const normalized = apiUrl.replace(/\/+$/, '').toLowerCase()
return normalized === 'https://cloud.openshot.org'
},
launchSubtitleHtml() {
const link = '<a href="https://www.openshot.org/cloud-api/" target="_blank" rel="noopener">OpenShot Cloud API</a>'
return `Deploy ${link} on AWS, Azure, or Google Cloud for full-length exports and persistent projects.`
},
...mapGetters(['isAuthenticated'])
},
}
......@@ -73,4 +90,4 @@ export default {
<style scoped>
</style>
\ No newline at end of file
</style>
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