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="['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>
......@@ -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')
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
}
......@@ -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