开发一个简单的vlog系统

利用空余时间慢慢做

Github

https://github.com/mhlx/vlog

说在前面

单纯的vlog系统其实并不适合个人,其中一个主要原因就是视频的成本太大,成本体现在两个方面,一个就是视频的处理,很多时候,这非常耗时,并且需要大量服务器资源,另一个就是视频的传输,了解视频服务商的都知道,这就是烧钱的,个人用户根本承担不起

视频的处理

这里的处理主要体现在视频的截取(用于预览),视频的压缩,以及视频封面的提取,这里并不包括背景音乐等其他花里胡哨的功能,有更专业的软件做这些事情,通过 ffmpeg,这些都能够完成。

关于MP4格式视频的压缩

ffmpeg官网有很详细的文档,https://trac.ffmpeg.org/wiki/Encode/H.264#FAQ ,这里只简单的采用了crf方式,crf的值一般在17~28之间,越大视频越不清晰,但相对来说,视频体积也会越小。

比如

ffmpeg -i a.mp4 -crf 28 scale=w=1280:h=1280:force_original_aspect_ratio=decrease  b.mp4

这条命令将会把a.mp4文件转化为720p(16:9)的b.mp4文件,文件大小有1.5g缩小到了80m。

下面是主要的处理服务

package me.qyh.blog.file;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;

import me.qyh.blog.utils.FileUtils;

public class VideoProcessor {

	private final ExecutorService es = Executors.newSingleThreadExecutor();
	private final Map<String, ProcessInfo> map = new ConcurrentHashMap<>();

	/**
	 * 取消一个视频转化
	 * 
	 * @param id
	 */
	public void cancel(String id) {
		map.entrySet().removeIf(entry -> {
			return entry.getValue().getId().equals(id);
		});
	}

	/**
	 * 获取当前转化视频列表
	 * 
	 * @return
	 */
	public List<ProcessInfo> getCurrentProcessList() {
		List<ProcessInfo> infos = new ArrayList<>(this.map.values());
		infos.sort(Comparator.comparingLong(p -> p.timestamp));
		return List.copyOf(infos);
	}
    
    	/**
	 * 获取视频封面图片
	 * <p>
	 * <b>目标为PNG格式图片</b>
	 * </p>
	 * 
	 * @param src
	 * @param output
	 * @throws IOException
	 */
	public void getVideoPoster(Path src, Path output) throws IOException {
		Path temp = Files.createTempFile(null, ".png");
		try {
			List<String> commands = Arrays.asList(getFfmpegPath("ffmpeg"), "-loglevel", "error", "-y", "-ss",
					"00:00:00", "-i", src.toString(), "-vframes", "1", "-q:v", "2", temp.toString());
			ProcessBuilder builder = new ProcessBuilder(commands);
			builder.redirectErrorStream(true);
			Process process = builder.start();
			execCommands(process, null);
			FileUtils.move(temp, output);
		} finally {
			FileUtils.deleteQuietly(temp);
		}
	}

	/**
	 * 将视频转化为指定尺寸的mp4
	 * 
	 * <pre>
	 * <b>如果两个输出文件相同,如果第一个还在处理中,将会直接返回第一个的处理情况,而不会处理第二个</b>
	 * </pre>
	 * 
	 * @param size    视频大小(视频只会缩小,不会放大)
	 * @param src     原视频文件
	 * @param output  现在视频文件
	 * @param seconds 需要视频时长,从0开始计时,如果小于等于0,则转化全部视频
	 * @throws IOException
	 */
	public ProcessInfo toMP4(int size, Path src, Path output, int seconds) throws IOException {
		ProcessInfo notify = new ProcessInfo(src, seconds);
		ProcessInfo current = map.putIfAbsent(output.toString(), notify);
		if (current != null) {
			return current;
		}
		es.submit(() -> {
			if (notify.status == 3) {
				return;
			}
			Path temp = null;
			try {
				VideoInfo info = readVideo(src);
				notify.info = info;
				temp = Files.createTempFile(null, ".mp4");
				List<String> cmdList = new ArrayList<>(
						Arrays.asList(getFfmpegPath("ffmpeg"), "-i", src.toString(), "-y"));
				if (seconds > 0 && seconds < info.getDuration()) {
					cmdList.add("-ss");
					cmdList.add("00:00:00");
					cmdList.add("-t");
					cmdList.add(String.valueOf(seconds));
				}
				if (info.getWidth() > size || info.getHeight() > size) {
					cmdList.add("-vf");
					cmdList.add("scale=w=" + size + ":h=" + size + ":force_original_aspect_ratio=decrease");
				}
				cmdList.addAll(Arrays.asList("-crf", String.valueOf(28), "-max_muxing_queue_size", "9999", "-c:v",
						"h264", "-c:a", "aac", "-map_metadata", "-1", temp.toString()));
				ProcessBuilder builder = new ProcessBuilder(cmdList);
				builder.redirectErrorStream(true);
				Process process = builder.start();
				notify.process = process;
				try {
					execCommands(process, line -> {
						if (line.startsWith("frame=")) {
							String[] processInfos = line.split(" ");
							for (String processInfo : processInfos) {
								String[] pairs = processInfo.split("=");
								if (pairs[0].equals("time")) {
									notify.status = 1;
									notify.processed = toSeconds(pairs[1]);
								}
							}
						} else if (notify.status == 1) {
							notify.status = 2;
							notify.cdl.countDown();
							map.entrySet().removeIf(entry -> {
								return entry.getValue() == notify;
							});
						}
					});
				} catch (IOException e) {
					// do nothing if user call cancel method on notify;
					if (notify.status != 3) {
						throw e;
					}
				}
				FileUtils.move(temp, output);
			} catch (Throwable e) {
				notify.status = 4;
				notify.cdl.countDown();
				throw new RuntimeException(e);
			} finally {
				if (temp != null)
					FileUtils.deleteQuietly(temp);
			}
		});
		return notify;
	}

	/**
	 * 读取视频信息尺寸已经时长信息
	 * 
	 * @param src
	 * @return
	 * @throws IOException
	 */
	public VideoInfo readVideo(Path src) throws IOException {
		List<String> commands = Arrays.asList(getFfmpegPath("ffprobe"), "-v", "error", "-select_streams", "v:0",
				"-show_entries", "stream=width,height,duration", "-of", "default=noprint_wrappers=1:nokey=1",
				src.toString());

		ProcessBuilder builder = new ProcessBuilder(commands);
		builder.redirectErrorStream(true);
		Process process = builder.start();

		String result = execCommands(process, null);
		String[] lines = result.split(System.lineSeparator());
		if (lines.length != 3) {
			throw new IOException("获取视频信息失败:" + result);
		}
		int width = Integer.parseInt(lines[0]);
		int height = Integer.parseInt(lines[1]);
		try {
			return new VideoInfo(width, height, Double.parseDouble(lines[2]));
		} catch (NumberFormatException e) {
			List<String> commands2 = Arrays.asList(getFfmpegPath("ffprobe"), "-v", "error", "-show_entries",
					"format=duration", "-of", "default=noprint_wrappers=1:nokey=1", src.toString());
			ProcessBuilder builder2 = new ProcessBuilder(commands2);
			builder2.redirectErrorStream(true);
			Process process2 = builder.start();
			String _result = execCommands(process2, null);
			return new VideoInfo(width, height, Double.parseDouble(_result));
		}
	}

	private String execCommands(Process process, Consumer<String> lineConsumer) throws IOException {
		try (InputStream is = process.getInputStream();
				BufferedReader br = new BufferedReader(new InputStreamReader(is))) {
			StringBuilder sb = new StringBuilder();
			String line;
			while ((line = br.readLine()) != null) {
				if (lineConsumer != null) {
					lineConsumer.accept(line);
				}
				sb.append(line);
				sb.append(System.lineSeparator());
			}
			try {
				if (process.waitFor() == 0)
					return sb.toString();
			} catch (InterruptedException e) {
				Thread.currentThread().interrupt();
				throw new RuntimeException(e);
			}
			throw new IOException(process + " exec error :" + null);
		}
	}

	private double toSeconds(String time) {
		String[] times = time.split(":");
		double sec = 0;
		sec += Double.parseDouble(times[0]) * 3600;
		sec += Double.parseDouble(times[1]) * 60;
		sec += Double.parseDouble(times[2]);
		return sec;
	}

	private String getFfmpegPath(String name) throws IOException {
		return new File("d:/ffmpeg/bin", name).getCanonicalPath();
	}

	public final class ProcessInfo {
		private volatile int status;// 0 not start,1:processing,2:end,3:cancel
		private double processed;
		private VideoInfo info;
		private Process process;
		private final String id;
		private final Path file;
		private final int seconds;
		private final long timestamp;
		private final CountDownLatch cdl = new CountDownLatch(1);

		public ProcessInfo(Path file, int seconds) {
			super();
			this.id = UUID.randomUUID().toString().replace("-", "");
			this.file = file;
			this.seconds = seconds;
			this.timestamp = System.currentTimeMillis();
		}

		/**
		 * 等待视频处理完毕
		 * 
		 * @return 处理结果 <br>
		 *         2. 正常处理完毕<br>
		 *         3. 取消处理<br>
		 *         4. 处理失败
		 * @throws InterruptedException
		 */
		public int waitForComplete() throws InterruptedException {
			cdl.await();
			return this.status;
		}

		void cancel() {
			if (process != null) {
				process.destroyForcibly();
			}
			this.status = 3;
			this.cdl.countDown();
		}

		public int getStatus() {
			return status;
		}

		public String getId() {
			return id;
		}

		public String getFileName() {
			return file.getFileName().toString();
		}

		public double getPercent() {
			if (status == 0) {
				return 0D;
			}
			if (status == 2) {
				return 100D;
			}
			return Math.min(100D, Double.parseDouble(new DecimalFormat("##.##").format(
					processed * 100 / (seconds > 0 ? Math.min(seconds, info.getDuration()) : info.getDuration()))));
		}
	}

	public static void main(String[] args) throws IOException, InterruptedException {
		VideoProcessor vp = new VideoProcessor();
		vp.toMP4(1280, Paths.get("f:/1234.mp4"), Paths.get("f:/12345.mp4"), 1);
		vp.es.shutdown();
		vp.es.awaitTermination(Long.MAX_VALUE, TimeUnit.SECONDS);
	}

}

视频的存储

为了方便,我们首先把文件存储在本地

根据vlog的特性,我们以 yyyy/MM/dd 的目录来存储视频文件,这样我们通过 /yyyy/MM/dd/文件名称 就可以访问视频原文件,但由于原文件一般都比较大,我们负责输出压缩后的文件以及其他文件,这样,可以创建两个文件夹,一个用于存放原文件,一个用于存放处理后的文件,例如:

/root文件夹/yyyy/MM/dd/文件名称
/thumb文件夹/yyyy/MM/dd/文件名称/处理后的文件名

最终存储,访问代码如下:

public class VlogStore {

	private final Path root;
	private final Path thumb;
	private final VideoProcessor videoProcessor;
	private final DateTimeFormatter dtf = DateTimeFormatter.ofPattern("yyyy/MM/dd");

	private static final String POSTER = "poster";
	private static final String THUMB = "thumb.mp4";
	private static final String COMPRESSED = "compressed.mp4";

	private final int videoSize;
	private final int thumbSeconds;
	private final int[] posterSizes;

	public VlogStore(VlogProperties vlogProperties) {
		super();
		this.root = Paths.get(vlogProperties.getRoot());
		this.thumb = Paths.get(vlogProperties.getThumb());
		this.videoProcessor = new VideoProcessor(vlogProperties.getFfmpegPath());
		this.videoSize = vlogProperties.getSize();
		this.thumbSeconds = vlogProperties.getThumbSeconds();
		this.posterSizes = vlogProperties.getPosterSizes();
	}

	public Optional<Resource> getVideo(String path) {
		Path thumbnail = this.thumb.resolve(path);
		if (!FileUtils.isSub(thumbnail, thumb) || Files.isDirectory(thumbnail)) {
			return Optional.empty();
		}
		if (Files.exists(thumbnail)) {
			return Optional.of(new FileSystemResource(thumbnail));
		}
		Path video = root.resolve(path);
		if (Files.exists(video)) {
			return Optional.empty();
		}
		Path parent = video.getParent();
		String name = video.getFileName().toString();
		boolean poster = parent.getFileName().toString().equals(POSTER);
		if (!name.equals(THUMB) && !name.equals(COMPRESSED) && !poster) {
			return Optional.empty();
		}
		if (!FileUtils.isSub(parent, root) || (!Files.isRegularFile(parent)) && !poster) {
			return Optional.empty();
		}
		if (!poster) {
			int seconds = name.equals(THUMB) ? thumbSeconds : -1;
			int status = 0;
			try {
				status = videoProcessor.toMP4(videoSize, parent, thumbnail, seconds).waitForComplete();
			} catch (IOException e) {
				throw new RuntimeException(e.getMessage(), e);
			} catch (InterruptedException e) {
				Thread.currentThread().interrupt();
				throw new RuntimeException(e.getMessage(), e);
			}
			if (status != 2) {
				return Optional.empty();
			}
		} else {
			if (!name.endsWith(".png") || name.length() == 4) {
				return Optional.empty();
			}
			int size;
			try {
				size = Integer.parseInt(name.substring(0, name.length() - 4));
			} catch (NumberFormatException e) {
				return Optional.empty();
			}
			if (!Arrays.stream(posterSizes).anyMatch(s -> s == size)) {
				return Optional.empty();
			}
			parent = parent.getParent();
			if (!Files.isRegularFile(parent)) {
				return Optional.empty();
			}
			try {
				videoProcessor.getVideoPoster(size, parent, thumbnail, 0);
			} catch (IOException e) {
				throw new RuntimeException(e.getMessage(), e);
			}
		}
		return Optional.of(new FileSystemResource(thumbnail));
	}

	public VideoInfo store(MultipartFile file) throws IOException, LogicException {
		Path temp = Files.createTempFile(null, ".mp4");
		try {
			file.transferTo(temp);
			VideoInfo vi;
			try {
				vi = videoProcessor.readVideo(temp);
			} catch (Exception e) {
				throw new LogicException("无法读取的视频文件");
			}

			Path dir = root.resolve(dtf.format(LocalDate.now()));
			FileUtils.forceMkdir(dir);
			Path dest = dir.resolve(file.getOriginalFilename());

			FileUtils.move(temp, dest);
			return vi;
		} finally {
			FileUtils.deleteQuietly(temp);
		}
	}
}

视频的读取

在spring boot中,通过ResourceHttpRequestHandler来读取静态文件,但我们这里的静态文件根据url的不同需要动态处理,所以得额外扩展一下:

public class VlogResourceHandler extends ResourceHttpRequestHandler {

	private final VlogStore videoStore;

	public VlogResourceHandler(VlogStore videoStore) {
		super();
		this.videoStore = videoStore;
	}

	@Override
	protected Resource getResource(HttpServletRequest request) throws IOException {
		String path = (String) request.getAttribute(HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE);
		if (path == null) {
			throw new IllegalStateException("Required request attribute '"
					+ HandlerMapping.PATH_WITHIN_HANDLER_MAPPING_ATTRIBUTE + "' is not set");
		}
		return this.videoStore.getVideo(path).orElse(null);
	}
}

创建完自己的ResourceHttpRequestHandler之后,就是安装,如果通过WebMvcConfigurer,无法直接配置,我们只能再创建一个额外的 SimpleUrlMapping

@Configuration
public class WebConfig implements WebMvcConfigurer {

	@Bean
	public SimpleUrlHandlerMapping fileMapping(VlogStore vlogStore, ContentNegotiationManager contentNegotiationManager,
			@Qualifier("mvcUrlPathHelper") UrlPathHelper urlPathHelper,
			@Qualifier("mvcPathMatcher") PathMatcher pathMatcher, WebApplicationContext context) throws Exception {
		VlogResourceHandler handler = new VlogResourceHandler(vlogStore);
		handler.setApplicationContext(context);
		handler.setServletContext(context.getServletContext());
		if (urlPathHelper != null) {
			handler.setUrlPathHelper(urlPathHelper);
		}
		if (contentNegotiationManager != null) {
			handler.setContentNegotiationManager(contentNegotiationManager);
		}
		handler.afterPropertiesSet();
		SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping(Map.of("/vlog/**", handler));
		mapping.setOrder(Ordered.LOWEST_PRECEDENCE - 10);
		mapping.setPathMatcher(pathMatcher);
		mapping.setUrlPathHelper(urlPathHelper);
		return mapping;
	}

}

文件访问保护

单纯的文件访问是没有受到保护的,但是vlog一般会有权限保护(假设这里只有密码和私人保护),那么对应的文件同样应该受到保护,在这种情况下,可以设置一个上下文用来储存用户输入的密码以及登录凭证

package me.qyh.vlog;

import java.util.HashMap;
import java.util.Map;

public class VlogContext {

	private static final ThreadLocal<Map<String, String>> PWD_LOCAL = ThreadLocal.withInitial(HashMap::new);
	private static final ThreadLocal<Boolean> AUTH_LOCAL = ThreadLocal.withInitial(() -> Boolean.FALSE);

	public static void clear() {
		PWD_LOCAL.remove();
		AUTH_LOCAL.remove();
	}

	/**
	 * 获取密码上下文
	 * 
	 * @return
	 */
	public static Map<String, String> getPwdMap() {
		return PWD_LOCAL.get();
	}

	/**
	 * 是否已经登录
	 * 
	 * @return
	 */
	public static boolean hasAuthenticated() {
		return AUTH_LOCAL.get();
	}

	public void setPwdMap(Map<String, String> pwdMap) {
		PWD_LOCAL.set(pwdMap);
	}

	public void setAuthenticated(boolean authenticated) {
		AUTH_LOCAL.set(authenticated);
	}

}

上下文的设置很简单,可以通过一个 Filter或者HandlerInterceptor来实现,但VlogResourceHandler不会受到HandlerInterceptor的拦截,所以采用过滤器更方便。

接下来,只要在需要安全校验的时候通过上下文匹配密码或凭证就可以了

public class SecurityChecker {

	public static void check(SecurityProtect protect) {
		if (protect.isPrivate()) {
			if (!VlogContext.hasAuthenticated()) {
				throw new AuthenticationException();
			}
		} else {
			String pwd = protect.getPassword();
			String key = protect.getPasswordKey();
			String currentPwd = VlogContext.getPwdMap().get(key);
			if (pwd != null && key != null && !pwd.equals(currentPwd)) {
				throw new PasswordAuthFailException(currentPwd);
			}
		}
	}
}

VlogStore中,设置一个Map用来储存受保护的文件路径和密码:

private final Map<String, String> securityPaths = new ConcurrentHashMap<>();

最后在访问文件前进行校验

private void securityCheck(String path) {
  final String p = FileUtils.cleanPath(path);
  for (Map.Entry<String, String> entry : this.securityPaths.entrySet()) {
      String key = entry.getKey();
      if (p.startsWith(key + '/')) {
          final String value = entry.getValue();
          SecurityChecker.check(new SecurityProtect() {

              @Override
              public boolean isPrivate() {
                  return value.isEmpty();
              }

              @Override
              public String getPasswordKey() {
                  return p;
              }

              @Override
              public String getPassword() {
                  return value.isEmpty() ? null : value;
              }
          });
          break;
      }
  }
}