main.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541
  1. /*
  2. Cine Studio - core state model
  3. CS.project holds everything that is serialized into a .cine file:
  4. media pool, tracks, clips and project settings. CS.state holds
  5. transient editor state (playhead, selection, zoom, tool).
  6. */
  7. "use strict";
  8. window.CS = window.CS || {};
  9. CS.VIDEO_EXTS = ["mp4", "webm", "mov", "mkv", "m4v", "avi", "ogv"];
  10. CS.AUDIO_EXTS = ["mp3", "wav", "aac", "flac", "ogg", "m4a", "opus"];
  11. CS.IMAGE_EXTS = ["png", "jpg", "jpeg", "gif", "webp", "bmp"];
  12. CS.PROJECT_EXT = "cine";
  13. CS.APP_ROOT = "user:/Cine Studio";
  14. CS.IMAGE_DEFAULT_DURATION = 5;
  15. CS.inArozOS = function () {
  16. return typeof ao_module_agirun !== "undefined" && window.location.protocol !== "file:" &&
  17. !CS._forceStandalone;
  18. };
  19. /* ---------- project factory ---------- */
  20. CS.defaultClipProps = function () {
  21. return {
  22. x: 0, y: 0,
  23. scale: 100,
  24. rotation: 0,
  25. opacity: 100,
  26. crop: "fit",
  27. preset: "default",
  28. exposure: 0,
  29. contrast: 0,
  30. saturation: 1,
  31. volume: 100,
  32. speed: 1,
  33. blend: "normal",
  34. flipH: false,
  35. flipV: false,
  36. effects: [],
  37. transition: null
  38. };
  39. };
  40. CS.clipSpeed = function (clip) {
  41. var v = clip.props && clip.props.speed;
  42. return (v && v > 0) ? v : 1;
  43. };
  44. CS.newProject = function (opts) {
  45. opts = opts || {};
  46. CS.project = {
  47. app: "CineStudio",
  48. version: 1,
  49. name: opts.name || "My Project",
  50. filePath: "",
  51. fileName: "",
  52. settings: {
  53. width: opts.width || 1920,
  54. height: opts.height || 1080,
  55. fps: opts.fps || 30
  56. },
  57. media: [],
  58. tracks: [
  59. { id: "V1", kind: "video", name: "Video 1", visible: true, muted: false },
  60. { id: "A1", kind: "audio", name: "Audio 1", visible: true, muted: false },
  61. { id: "A2", kind: "audio", name: "Audio 2", visible: true, muted: false }
  62. ],
  63. clips: []
  64. };
  65. CS.state = {
  66. playhead: 0,
  67. playing: false,
  68. selectedClipId: null,
  69. selectedMediaId: null,
  70. zoom: 40, // pixels per second
  71. tool: "select",
  72. previewZoom: "fit",
  73. safeArea: false,
  74. loop: false,
  75. snap: true,
  76. binKind: "all",
  77. binSearch: "",
  78. binView: "grid",
  79. selectedClipIds: [],
  80. dirty: false
  81. };
  82. CS.project.markers = [];
  83. CS.history = { stack: [], index: -1 };
  84. CS.pushHistory("New Project");
  85. if (CS.player) { CS.player.reset(); }
  86. };
  87. /* ---------- lookups ---------- */
  88. CS.getMedia = function (id) {
  89. for (var i = 0; i < CS.project.media.length; i++) {
  90. if (CS.project.media[i].id === id) { return CS.project.media[i]; }
  91. }
  92. return null;
  93. };
  94. CS.getClip = function (id) {
  95. for (var i = 0; i < CS.project.clips.length; i++) {
  96. if (CS.project.clips[i].id === id) { return CS.project.clips[i]; }
  97. }
  98. return null;
  99. };
  100. CS.getTrack = function (id) {
  101. for (var i = 0; i < CS.project.tracks.length; i++) {
  102. if (CS.project.tracks[i].id === id) { return CS.project.tracks[i]; }
  103. }
  104. return null;
  105. };
  106. CS.selectedClip = function () {
  107. return CS.state.selectedClipId ? CS.getClip(CS.state.selectedClipId) : null;
  108. };
  109. //Video tracks top-down (V2 above V1), then audio tracks in order (A1, A2, ...)
  110. CS.tracksInDisplayOrder = function () {
  111. var video = CS.project.tracks.filter(function (t) { return t.kind === "video"; });
  112. var audio = CS.project.tracks.filter(function (t) { return t.kind === "audio"; });
  113. video.sort(function (a, b) { return b.id.localeCompare(a.id, undefined, { numeric: true }); });
  114. audio.sort(function (a, b) { return a.id.localeCompare(b.id, undefined, { numeric: true }); });
  115. return video.concat(audio);
  116. };
  117. //Video tracks bottom-up for compositing (V1 first, higher tracks painted over)
  118. CS.videoTracksInRenderOrder = function () {
  119. var video = CS.project.tracks.filter(function (t) { return t.kind === "video"; });
  120. video.sort(function (a, b) { return a.id.localeCompare(b.id, undefined, { numeric: true }); });
  121. return video;
  122. };
  123. CS.clipsOnTrack = function (trackId) {
  124. return CS.project.clips
  125. .filter(function (c) { return c.trackId === trackId; })
  126. .sort(function (a, b) { return a.start - b.start; });
  127. };
  128. //Duration on the timeline: source range divided by playback speed
  129. CS.clipDuration = function (clip) {
  130. return (clip.out - clip.in) / CS.clipSpeed(clip);
  131. };
  132. CS.clipEnd = function (clip) {
  133. return clip.start + CS.clipDuration(clip);
  134. };
  135. CS.timelineDuration = function () {
  136. var end = 0;
  137. CS.project.clips.forEach(function (c) {
  138. var e = CS.clipEnd(c);
  139. if (e > end) { end = e; }
  140. });
  141. return end;
  142. };
  143. //Sorted unique clip boundaries, used by prev/next edit point buttons
  144. CS.editPoints = function () {
  145. var pts = [0];
  146. CS.project.clips.forEach(function (c) {
  147. pts.push(c.start);
  148. pts.push(CS.clipEnd(c));
  149. });
  150. pts.sort(function (a, b) { return a - b; });
  151. return pts.filter(function (p, i) { return i === 0 || p - pts[i - 1] > 0.0001; });
  152. };
  153. /* ---------- history (undo / redo) ---------- */
  154. CS.pushHistory = function (label) {
  155. var snap = {
  156. label: label,
  157. clips: JSON.parse(JSON.stringify(CS.project.clips)),
  158. tracks: JSON.parse(JSON.stringify(CS.project.tracks))
  159. };
  160. CS.history.stack = CS.history.stack.slice(0, CS.history.index + 1);
  161. CS.history.stack.push(snap);
  162. if (CS.history.stack.length > 100) { CS.history.stack.shift(); }
  163. CS.history.index = CS.history.stack.length - 1;
  164. };
  165. //Call after any timeline mutation: records history and refreshes UI
  166. CS.commit = function (label) {
  167. CS.pushHistory(label);
  168. CS.markDirty();
  169. CS.timeline.render();
  170. CS.inspector.render();
  171. CS.player.invalidate();
  172. };
  173. CS.applySnapshot = function (snap) {
  174. CS.project.clips = JSON.parse(JSON.stringify(snap.clips));
  175. CS.project.tracks = JSON.parse(JSON.stringify(snap.tracks));
  176. if (CS.state.selectedClipId && !CS.getClip(CS.state.selectedClipId)) {
  177. CS.state.selectedClipId = null;
  178. }
  179. CS.state.selectedClipIds = (CS.state.selectedClipIds || []).filter(function (id) {
  180. return !!CS.getClip(id);
  181. });
  182. CS.markDirty();
  183. CS.timeline.render();
  184. CS.inspector.render();
  185. CS.player.invalidate();
  186. };
  187. CS.undo = function () {
  188. if (CS.history.index <= 0) { CS.toast("Nothing to undo"); return; }
  189. CS.history.index--;
  190. CS.applySnapshot(CS.history.stack[CS.history.index]);
  191. };
  192. CS.redo = function () {
  193. if (CS.history.index >= CS.history.stack.length - 1) { CS.toast("Nothing to redo"); return; }
  194. CS.history.index++;
  195. CS.applySnapshot(CS.history.stack[CS.history.index]);
  196. };
  197. /* ---------- dirty / title state ---------- */
  198. CS.markDirty = function () {
  199. CS.state.dirty = true;
  200. CS.updateSaveState();
  201. };
  202. CS.markClean = function () {
  203. CS.state.dirty = false;
  204. CS.updateSaveState();
  205. //Saved on disk: the crash-recovery snapshot is no longer needed
  206. if (CS.session) { CS.session.clearSnapshot(); }
  207. };
  208. CS.updateSaveState = function () {
  209. var el = document.getElementById("save-state-icon");
  210. if (el) { CS.setIcon(el, CS.state.dirty ? "dot-circle" : "check-circle"); }
  211. var nameEl = document.getElementById("project-name");
  212. if (nameEl) { nameEl.textContent = CS.project.name; }
  213. var title = CS.project.name + (CS.state.dirty ? " (edited)" : "") + " - Cine Studio";
  214. if (typeof ao_module_setWindowTitle !== "undefined") {
  215. try { ao_module_setWindowTitle(title); } catch (e) { document.title = title; }
  216. } else {
  217. document.title = title;
  218. }
  219. };
  220. /* ---------- selection (multi-select aware) ---------- */
  221. //Primary selection; replaces any multi-selection with the single clip
  222. CS.selectClip = function (clipId) {
  223. CS.state.selectedClipId = clipId;
  224. CS.state.selectedClipIds = clipId ? [clipId] : [];
  225. CS.timeline.refreshSelection();
  226. CS.inspector.render();
  227. };
  228. //Shift-click: add / remove a clip from the selection set
  229. CS.toggleSelectClip = function (clipId) {
  230. var ids = CS.state.selectedClipIds || [];
  231. var idx = ids.indexOf(clipId);
  232. if (idx >= 0) {
  233. ids.splice(idx, 1);
  234. if (CS.state.selectedClipId === clipId) {
  235. CS.state.selectedClipId = ids.length ? ids[ids.length - 1] : null;
  236. }
  237. } else {
  238. ids.push(clipId);
  239. CS.state.selectedClipId = clipId;
  240. }
  241. CS.state.selectedClipIds = ids;
  242. CS.timeline.refreshSelection();
  243. CS.inspector.render();
  244. };
  245. CS.selectedClips = function () {
  246. return (CS.state.selectedClipIds || [])
  247. .map(function (id) { return CS.getClip(id); })
  248. .filter(function (c) { return !!c; });
  249. };
  250. CS.deleteSelectedClip = function () {
  251. var clips = CS.selectedClips();
  252. if (!clips.length) { CS.toast("No clip selected"); return; }
  253. var ids = clips.map(function (c) { return c.id; });
  254. CS.project.clips = CS.project.clips.filter(function (c) { return ids.indexOf(c.id) < 0; });
  255. CS.state.selectedClipId = null;
  256. CS.state.selectedClipIds = [];
  257. CS.commit(clips.length > 1 ? "Delete Clips" : "Delete Clip");
  258. };
  259. //Delete and close the gap: later clips on the same track shift left
  260. CS.rippleDeleteSelected = function () {
  261. var clips = CS.selectedClips();
  262. if (clips.length !== 1) {
  263. CS.toast(clips.length ? "Ripple delete works on a single clip" : "No clip selected");
  264. return;
  265. }
  266. var clip = clips[0];
  267. var dur = CS.clipDuration(clip);
  268. CS.project.clips = CS.project.clips.filter(function (c) { return c.id !== clip.id; });
  269. CS.clipsOnTrack(clip.trackId).forEach(function (c) {
  270. if (c.start >= clip.start - 0.0001) { c.start = Math.max(0, c.start - dur); }
  271. });
  272. CS.state.selectedClipId = null;
  273. CS.state.selectedClipIds = [];
  274. CS.commit("Ripple Delete");
  275. };
  276. /* ---------- clipboard (clips) ---------- */
  277. CS.clipClipboard = null;
  278. CS.copySelectedClips = function () {
  279. var clips = CS.selectedClips();
  280. if (!clips.length) { CS.toast("No clip selected"); return; }
  281. var minStart = Math.min.apply(null, clips.map(function (c) { return c.start; }));
  282. CS.clipClipboard = clips.map(function (c) {
  283. var copy = JSON.parse(JSON.stringify(c));
  284. copy.rel = c.start - minStart;
  285. return copy;
  286. });
  287. CS.toast("Copied " + clips.length + " clip" + (clips.length > 1 ? "s" : ""));
  288. };
  289. CS.pasteClipsAtPlayhead = function () {
  290. if (!CS.clipClipboard || !CS.clipClipboard.length) { CS.toast("Clip clipboard is empty"); return; }
  291. var newIds = [];
  292. CS.clipClipboard.forEach(function (src) {
  293. var clip = JSON.parse(JSON.stringify(src));
  294. delete clip.rel;
  295. clip.id = CS.uid();
  296. clip.start = CS.state.playhead + src.rel;
  297. if (!CS.getTrack(clip.trackId)) {
  298. //Original track is gone: fall back to the first compatible one
  299. var kind = (clip.kind === "title" || clip.kind === "color") ? "video"
  300. : (CS.getMedia(clip.mediaId) && CS.getMedia(clip.mediaId).type === "audio") ? "audio" : "video";
  301. var tracks = CS.project.tracks.filter(function (t) { return t.kind === kind; });
  302. if (!tracks.length) { return; }
  303. clip.trackId = tracks[0].id;
  304. }
  305. clip.start = CS.timeline.resolveOverlap(clip, clip.trackId, clip.start);
  306. CS.project.clips.push(clip);
  307. newIds.push(clip.id);
  308. });
  309. if (!newIds.length) { return; }
  310. CS.state.selectedClipIds = newIds;
  311. CS.state.selectedClipId = newIds[newIds.length - 1];
  312. CS.commit("Paste");
  313. };
  314. //Duplicate each selected clip right after itself on its own track
  315. CS.duplicateSelectedClips = function () {
  316. var clips = CS.selectedClips();
  317. if (!clips.length) { CS.toast("No clip selected"); return; }
  318. var newIds = [];
  319. clips.forEach(function (c) {
  320. var clip = JSON.parse(JSON.stringify(c));
  321. clip.id = CS.uid();
  322. clip.start = CS.timeline.resolveOverlap(clip, clip.trackId, CS.clipEnd(c));
  323. CS.project.clips.push(clip);
  324. newIds.push(clip.id);
  325. });
  326. CS.state.selectedClipIds = newIds;
  327. CS.state.selectedClipId = newIds[newIds.length - 1];
  328. CS.commit("Duplicate");
  329. };
  330. /* ---------- timeline markers ---------- */
  331. //Add a marker at the playhead, or remove one already there
  332. CS.toggleMarkerAtPlayhead = function () {
  333. if (!CS.project.markers) { CS.project.markers = []; }
  334. var t = CS.state.playhead;
  335. var eps = 4 / CS.state.zoom; //within a few pixels counts as "here"
  336. for (var i = 0; i < CS.project.markers.length; i++) {
  337. if (Math.abs(CS.project.markers[i].time - t) < eps) {
  338. CS.project.markers.splice(i, 1);
  339. CS.markDirty();
  340. CS.timeline.drawRuler();
  341. CS.toast("Marker removed");
  342. return;
  343. }
  344. }
  345. CS.project.markers.push({ id: CS.uid(), time: t });
  346. CS.project.markers.sort(function (a, b) { return a.time - b.time; });
  347. CS.markDirty();
  348. CS.timeline.drawRuler();
  349. CS.toast("Marker added");
  350. };
  351. //Jump to the next / previous marker relative to the playhead
  352. CS.gotoMarker = function (dir) {
  353. var ms = CS.project.markers || [];
  354. var t = CS.state.playhead;
  355. if (dir > 0) {
  356. for (var i = 0; i < ms.length; i++) {
  357. if (ms[i].time > t + 0.02) { CS.player.seek(ms[i].time); return; }
  358. }
  359. } else {
  360. for (var j = ms.length - 1; j >= 0; j--) {
  361. if (ms[j].time < t - 0.02) { CS.player.seek(ms[j].time); return; }
  362. }
  363. }
  364. };
  365. /* ---------- clip operations ---------- */
  366. //Split the clip under the playhead (prefers the selected clip)
  367. CS.splitAtPlayhead = function () {
  368. var t = CS.state.playhead;
  369. var candidates = CS.project.clips.filter(function (c) {
  370. return t > c.start + 0.02 && t < CS.clipEnd(c) - 0.02;
  371. });
  372. if (candidates.length === 0) { CS.toast("Playhead is not over a splittable clip"); return; }
  373. var clip = null;
  374. for (var i = 0; i < candidates.length; i++) {
  375. if (candidates[i].id === CS.state.selectedClipId) { clip = candidates[i]; }
  376. }
  377. if (!clip) { clip = candidates[0]; }
  378. CS.splitClip(clip, t);
  379. CS.commit("Split Clip");
  380. };
  381. CS.splitClip = function (clip, t) {
  382. var srcOffset = (t - clip.start) * CS.clipSpeed(clip);
  383. var right = JSON.parse(JSON.stringify(clip));
  384. right.id = CS.uid();
  385. right.start = t;
  386. right.in = clip.in + srcOffset;
  387. clip.out = clip.in + srcOffset;
  388. CS.project.clips.push(right);
  389. CS.state.selectedClipId = right.id;
  390. CS.state.selectedClipIds = [right.id];
  391. };
  392. //Place a media item on the timeline. Returns the new clip.
  393. CS.addClipToTimeline = function (media, trackId, startTime) {
  394. var track = CS.getTrack(trackId);
  395. if (!track) { return null; }
  396. var duration = media.type === "image" ? CS.IMAGE_DEFAULT_DURATION : (media.duration || 1);
  397. var clip = {
  398. id: CS.uid(),
  399. mediaId: media.id,
  400. trackId: trackId,
  401. start: Math.max(0, startTime),
  402. in: 0,
  403. out: duration,
  404. props: CS.defaultClipProps()
  405. };
  406. clip.start = CS.timeline.resolveOverlap(clip, trackId, clip.start);
  407. CS.project.clips.push(clip);
  408. return clip;
  409. };
  410. //Create a track without committing history (callers commit themselves)
  411. CS.createTrack = function (kind) {
  412. var prefix = kind === "video" ? "V" : "A";
  413. var maxN = 0;
  414. CS.project.tracks.forEach(function (t) {
  415. if (t.kind === kind) {
  416. var n = parseInt(t.id.substring(1), 10);
  417. if (n > maxN) { maxN = n; }
  418. }
  419. });
  420. var id = prefix + (maxN + 1);
  421. CS.project.tracks.push({
  422. id: id,
  423. kind: kind,
  424. name: (kind === "video" ? "Video " : "Audio ") + (maxN + 1),
  425. visible: true,
  426. muted: false
  427. });
  428. return id;
  429. };
  430. CS.addTrack = function (kind) {
  431. CS.createTrack(kind);
  432. CS.commit("Add Track");
  433. };
  434. //Split a video clip's embedded audio onto its own audio-track clip
  435. CS.detachAudio = function (clip) {
  436. var media = CS.getMedia(clip.mediaId);
  437. if (!media || media.type !== "video") { return; }
  438. if (clip.props.audioDetached) { CS.toast("Audio is already detached"); return; }
  439. //Find an audio track with room at this position, or add one
  440. var trackId = null;
  441. var audioTracks = CS.project.tracks.filter(function (t) { return t.kind === "audio"; });
  442. for (var i = 0; i < audioTracks.length; i++) {
  443. var busy = CS.clipsOnTrack(audioTracks[i].id).some(function (c) {
  444. return clip.start < CS.clipEnd(c) && CS.clipEnd(clip) > c.start;
  445. });
  446. if (!busy) { trackId = audioTracks[i].id; break; }
  447. }
  448. if (!trackId) { trackId = CS.createTrack("audio"); }
  449. var audioClip = JSON.parse(JSON.stringify(clip));
  450. audioClip.id = CS.uid();
  451. audioClip.trackId = trackId;
  452. audioClip.props.transition = null;
  453. audioClip.props.effects = (clip.props.effects || []).filter(function (e) {
  454. return e.type === "fadein" || e.type === "fadeout";
  455. });
  456. CS.project.clips.push(audioClip);
  457. //Silence the original video clip's own audio
  458. clip.props.volume = 0;
  459. clip.props.audioDetached = true;
  460. //Waveform for the detached clip if the container's audio can be decoded
  461. if (!media.peaks) { CS.media.computePeaks(media); }
  462. CS.selectClip(audioClip.id);
  463. CS.commit("Detach Audio");
  464. CS.toast("Audio detached to " + trackId);
  465. };
  466. /* ---------- backend bootstrap ---------- */
  467. //Make sure the per-user app folders exist (no-op outside ArozOS)
  468. CS.ensureAppFolders = function () {
  469. if (!CS.inArozOS()) { return; }
  470. ao_module_agirun("Cine Studio/backend/ensuredir.js", {}, function () {}, function () {});
  471. };
  472. //Ask the server whether ffmpeg is available (enables MP4 export path)
  473. CS.serverFFmpeg = false;
  474. CS.checkServerFFmpeg = function () {
  475. if (!CS.inArozOS()) { return; }
  476. ao_module_agirun("Cine Studio/backend/ffmpegtools.js", { action: "check" }, function (resp) {
  477. try {
  478. var data = typeof resp === "string" ? JSON.parse(resp) : resp;
  479. CS.serverFFmpeg = !!data.ffmpeg;
  480. } catch (e) { CS.serverFFmpeg = false; }
  481. }, function () { CS.serverFFmpeg = false; });
  482. };