Java将文件转二维码导出MP4
Java将文件转二维码导出MP4
pom文件
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>qr-sender</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>qr-sender</name>
<properties>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- 二维码生成 -->
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<!-- 纯 Java 的 MP4(H.264) 编码,把二维码帧合成循环播放视频 -->
<dependency>
<groupId>org.jcodec</groupId>
<artifactId>jcodec</artifactId>
<version>0.2.5</version>
</dependency>
<dependency>
<groupId>org.jcodec</groupId>
<artifactId>jcodec-javase</artifactId>
<version>0.2.5</version>
</dependency>
</dependencies>
<build>
<finalName>qr-sender</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.8.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.4.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<filters>
<filter>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/*.SF</exclude>
<exclude>META-INF/*.DSA</exclude>
<exclude>META-INF/*.RSA</exclude>
</excludes>
</filter>
</filters>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>com.example.qr.sender.SenderMain</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
代码
- FileQrSender.java
java
package com.example.qr.sender;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import javax.swing.AbstractAction;
import javax.swing.ActionMap;
import javax.swing.ImageIcon;
import javax.swing.InputMap;
import javax.swing.JComponent;
import javax.swing.JFrame;
import javax.swing.JLabel;
import javax.swing.JPanel;
import javax.swing.KeyStroke;
import javax.swing.SwingConstants;
import javax.swing.SwingUtilities;
import javax.swing.Timer;
import java.awt.BorderLayout;
import java.awt.Color;
import java.awt.Font;
import java.awt.GraphicsConfiguration;
import java.awt.GraphicsDevice;
import java.awt.GraphicsEnvironment;
import java.awt.Rectangle;
import java.awt.event.ActionEvent;
import java.awt.event.KeyEvent;
import java.awt.image.BufferedImage;
import java.util.Locale;
import java.util.concurrent.CountDownLatch;
/**
* 全屏播放端:把文件分片逐帧渲染成二维码并循环播放,
* 另一台设备用摄像头反复拍摄,直到收齐所有分片。
*/
final class FileQrSender {
private final FileSplitter splitter;
private final ErrorCorrectionLevel ecc;
private final int intervalMs;
private final QrFrameFactory factory;
private final CountDownLatch finished = new CountDownLatch(1);
private JFrame window;
private JLabel qrLabel;
private JLabel infoLabel;
private Timer timer;
private int cursor;
private int round = 1;
private boolean paused;
private int qrPixels = 900;
FileQrSender(FileSplitter splitter, ErrorCorrectionLevel ecc, int intervalMs) {
if (intervalMs < 50) {
throw new IllegalArgumentException("切换间隔不能小于 50 毫秒");
}
this.splitter = splitter;
this.ecc = ecc;
this.intervalMs = intervalMs;
this.factory = new QrFrameFactory(splitter, ecc);
}
/** 启动播放,直到用户按 ESC 退出 */
void start() throws Exception {
printEstimate();
SwingUtilities.invokeAndWait(this::buildWindow);
SwingUtilities.invokeLater(() -> {
showFrame(0);
timer = new Timer(intervalMs, e -> next());
timer.start();
});
finished.await();
}
private void printEstimate() throws Exception {
int version = QrCodec.estimateVersion(factory.payloadFor(0), ecc);
System.out.println("文件 : " + splitter.getFile().getAbsolutePath()
+ " (" + FileSplitter.humanSize(splitter.getSize()) + ")");
System.out.println("分片 : " + splitter.getTotalFrames() + " 帧,每帧 " + splitter.getChunkSize() + " 字节");
System.out.println("二维码版本 : " + version + "(1~40,建议 ≤25)");
System.out.println("播放间隔 : " + intervalMs + " 毫秒/帧");
System.out.println("每轮耗时 : 约 " + (splitter.getTotalFrames() * (long) intervalMs / 1000) + " 秒");
if (version > 25) {
System.out.println("提示 : 版本偏高,摄像头可能难以识别,建议增大 --interval 或减小 --chunk");
}
System.out.println("操作 : [空格] 暂停/继续 [←/→] 暂停时手动切帧 [ESC] 退出");
}
private void buildWindow() {
qrLabel = new JLabel();
qrLabel.setHorizontalAlignment(SwingConstants.CENTER);
infoLabel = new JLabel("", SwingConstants.CENTER);
infoLabel.setForeground(new Color(200, 200, 200));
infoLabel.setFont(new Font(Font.SANS_SERIF, Font.PLAIN, 18));
JPanel panel = new JPanel(new BorderLayout());
panel.setBackground(Color.BLACK);
panel.add(qrLabel, BorderLayout.CENTER);
panel.add(infoLabel, BorderLayout.SOUTH);
window = new JFrame("二维码文件发送端");
window.setUndecorated(true);
window.setDefaultCloseOperation(JFrame.DO_NOTHING_ON_CLOSE);
window.setContentPane(panel);
window.addWindowListener(new java.awt.event.WindowAdapter() {
@Override
public void windowClosing(java.awt.event.WindowEvent e) {
quit();
}
});
bindKeys();
GraphicsDevice device = GraphicsEnvironment.getLocalGraphicsEnvironment().getDefaultScreenDevice();
GraphicsConfiguration configuration = device.getDefaultConfiguration();
Rectangle bounds = configuration.getBounds();
// 按物理像素生成二维码,避免高分屏上被放大后发虚
double scale = configuration.getDefaultTransform().getScaleX();
qrPixels = (int) (Math.min(bounds.width, bounds.height) * 0.84 * scale);
boolean fullscreen = device.isFullScreenSupported();
if (fullscreen) {
try {
device.setFullScreenWindow(window);
} catch (RuntimeException e) {
fullscreen = false;
}
}
if (!fullscreen) {
window.setExtendedState(JFrame.MAXIMIZED_BOTH);
window.setVisible(true);
}
}
private void bindKeys() {
InputMap inputMap = window.getRootPane().getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW);
ActionMap actionMap = window.getRootPane().getActionMap();
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "quit");
actionMap.put("quit", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
quit();
}
});
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_SPACE, 0), "toggle");
actionMap.put("toggle", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
togglePause();
}
});
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_LEFT, 0), "prev");
actionMap.put("prev", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
step(-1);
}
});
inputMap.put(KeyStroke.getKeyStroke(KeyEvent.VK_RIGHT, 0), "next");
actionMap.put("next", new AbstractAction() {
@Override
public void actionPerformed(ActionEvent e) {
step(1);
}
});
}
private void next() {
if (paused) {
return;
}
int nextIndex = (cursor + 1) % splitter.getTotalFrames();
if (nextIndex == 0) {
round++;
}
cursor = nextIndex;
showFrame(cursor);
}
private void step(int delta) {
if (!paused) {
return;
}
int total = splitter.getTotalFrames();
cursor = (cursor + delta + total) % total;
showFrame(cursor);
}
private void showFrame(int index) {
try {
BufferedImage image = factory.render(index, qrPixels);
qrLabel.setIcon(new ImageIcon(image));
infoLabel.setText(String.format(Locale.ROOT,
"%s 第 %d/%d 帧 第 %d 轮 %s [空格]暂停 [ESC]退出",
paused ? "已暂停" : "播放中", index + 1, splitter.getTotalFrames(), round,
FileSplitter.humanSize(splitter.getSize())));
} catch (Exception e) {
System.out.println("渲染分片失败:" + e.getMessage());
}
}
private void togglePause() {
paused = !paused;
if (paused) {
timer.stop();
} else {
timer.start();
}
showFrame(cursor);
}
private void quit() {
if (timer != null) {
timer.stop();
}
if (window != null) {
window.dispose();
}
finished.countDown();
}
}
- FileSplitter.java
java
package com.example.qr.sender;
import java.io.Closeable;
import java.io.File;
import java.io.IOException;
import java.io.RandomAccessFile;
import java.util.Locale;
/** 把文件切成固定大小的分片,并按帧协议生成二维码文本 */
final class FileSplitter implements Closeable {
private static final int MIN_CHUNK = 32;
private static final long MAX_SIZE = Integer.MAX_VALUE;
private final File file;
private final int chunkSize;
private final long size;
private final int totalFrames;
private final String sha256;
private final String fileId;
private final String fileName;
private final RandomAccessFile source;
FileSplitter(File file, int chunkSize) throws IOException {
if (!file.isFile()) {
throw new IllegalArgumentException("文件不存在:" + file.getAbsolutePath());
}
if (chunkSize < MIN_CHUNK) {
throw new IllegalArgumentException("每帧字节数不能小于 " + MIN_CHUNK);
}
long length = file.length();
if (length <= 0) {
throw new IllegalArgumentException("文件为空:" + file.getAbsolutePath());
}
if (length > MAX_SIZE) {
throw new IllegalArgumentException("暂不支持超过 2GB 的文件");
}
this.file = file;
this.chunkSize = chunkSize;
this.size = length;
this.totalFrames = (int) ((length + chunkSize - 1) / chunkSize);
this.sha256 = FrameProtocol.sha256Hex(file);
this.fileId = sha256.substring(0, 8);
this.fileName = file.getName();
this.source = new RandomAccessFile(file, "r");
}
/** 第 index 帧的二维码文本,salt 用于规避个别数据无法识别的情况 */
String frameText(int index, int salt) throws IOException {
return FrameProtocol.encode(fileId, index, totalFrames, size, sha256, fileName, salt, readChunk(index));
}
private byte[] readChunk(int index) throws IOException {
long position = (long) index * chunkSize;
int length = (int) Math.min(chunkSize, size - position);
byte[] buffer = new byte[length];
source.seek(position);
source.readFully(buffer);
return buffer;
}
File getFile() {
return file;
}
long getSize() {
return size;
}
int getTotalFrames() {
return totalFrames;
}
int getChunkSize() {
return chunkSize;
}
@Override
public void close() throws IOException {
source.close();
}
static String humanSize(long bytes) {
if (bytes < 1024) {
return bytes + " B";
}
if (bytes < 1024 * 1024) {
return String.format(Locale.ROOT, "%.1f KB", bytes / 1024.0);
}
return String.format(Locale.ROOT, "%.2f MB", bytes / (1024.0 * 1024.0));
}
}
- FrameProtocol.java
java
package com.example.qr.sender;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* 帧协议:每一帧二维码承载的文件分片格式为
* <pre>FQ1|文件标识|帧序号|总帧数|文件字节数|SHA-256|文件名(Base64)|盐值|分片数据(Base64)</pre>
* 所有字段均为 ASCII,避免二维码字节模式下的字符集猜测问题。
* 盐值用于规避个别数据生成的二维码无法被识别的极端情况,接收端直接忽略。
*/
public final class FrameProtocol {
/** 协议魔数,用于快速过滤非本程序的二维码内容 */
public static final String MAGIC = "FQ1";
private static final Base64.Encoder B64_ENCODER = Base64.getEncoder();
private static final Base64.Decoder B64_DECODER = Base64.getDecoder();
private FrameProtocol() {
}
/** 解析后的一帧数据 */
public static final class Frame {
public final String fileId;
public final int index;
public final int total;
public final long fileSize;
public final String sha256;
public final String fileName;
public final byte[] data;
Frame(String fileId, int index, int total, long fileSize,
String sha256, String fileName, byte[] data) {
this.fileId = fileId;
this.index = index;
this.total = total;
this.fileSize = fileSize;
this.sha256 = sha256;
this.fileName = fileName;
this.data = data;
}
}
/** 拼装一帧二维码文本 */
public static String encode(String fileId, int index, int total, long fileSize,
String sha256, String fileName, int salt, byte[] data) {
String name = B64_ENCODER.encodeToString(fileName.getBytes(StandardCharsets.UTF_8));
return MAGIC + "|" + fileId
+ "|" + index
+ "|" + total
+ "|" + fileSize
+ "|" + sha256
+ "|" + name
+ "|" + salt
+ "|" + B64_ENCODER.encodeToString(data);
}
/** 解析二维码文本,不属于本协议或解析失败时返回 null */
public static Frame parse(String text) {
if (text == null || !text.startsWith(MAGIC + "|")) {
return null;
}
String[] parts = text.split("\\|", 9);
if (parts.length != 9) {
return null;
}
try {
int index = Integer.parseInt(parts[2]);
int total = Integer.parseInt(parts[3]);
long fileSize = Long.parseLong(parts[4]);
String sha256 = parts[5];
if (total <= 0 || index < 0 || index >= total || fileSize <= 0 || sha256.length() != 64) {
return null;
}
String fileName = new String(B64_DECODER.decode(parts[6]), StandardCharsets.UTF_8);
byte[] data = B64_DECODER.decode(parts[8]);
if (data.length == 0) {
return null;
}
return new Frame(parts[1], index, total, fileSize, sha256, fileName, data);
} catch (RuntimeException e) {
// 编号非法或 Base64 损坏,丢弃该帧
return null;
}
}
/** 计算文件的 SHA-256(十六进制小写) */
public static String sha256Hex(File file) throws IOException {
MessageDigest digest = newDigest();
try (InputStream in = new FileInputStream(file)) {
byte[] buffer = new byte[64 * 1024];
int read;
while ((read = in.read(buffer)) != -1) {
digest.update(buffer, 0, read);
}
}
return toHex(digest.digest());
}
/** 计算字节数组的 SHA-256(十六进制小写) */
public static String sha256Hex(byte[] data) {
return toHex(newDigest().digest(data));
}
private static MessageDigest newDigest() {
try {
return MessageDigest.getInstance("SHA-256");
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("当前 JVM 不支持 SHA-256", e);
}
}
private static String toHex(byte[] bytes) {
StringBuilder sb = new StringBuilder(bytes.length * 2);
for (byte b : bytes) {
sb.append(Character.forDigit((b >> 4) & 0xF, 16));
sb.append(Character.forDigit(b & 0xF, 16));
}
return sb.toString();
}
}
- QrCodec.java
java
package com.example.qr.sender;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.EncodeHintType;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.NotFoundException;
import com.google.zxing.Result;
import com.google.zxing.WriterException;
import com.google.zxing.client.j2se.BufferedImageLuminanceSource;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.common.HybridBinarizer;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import com.google.zxing.qrcode.encoder.Encoder;
import com.google.zxing.qrcode.encoder.QRCode;
import java.awt.image.BufferedImage;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
/** 二维码生成与识别 */
public final class QrCodec {
private QrCodec() {
}
/** 把文本渲染成边长为 pixels 的黑白二维码图片 */
public static BufferedImage encode(String content, ErrorCorrectionLevel ecc, int pixels) {
Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
hints.put(EncodeHintType.ERROR_CORRECTION, ecc);
// 静区留白(标准要求 4 个模块),摄像头识别更稳
hints.put(EncodeHintType.MARGIN, 4);
try {
BitMatrix matrix = new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, pixels, pixels, hints);
int width = matrix.getWidth();
int height = matrix.getHeight();
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY);
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
image.setRGB(x, y, matrix.get(x, y) ? 0xFF000000 : 0xFFFFFFFF);
}
}
return image;
} catch (WriterException e) {
throw new IllegalArgumentException("二维码生成失败(数据过长?):" + e.getMessage(), e);
}
}
/** 识别图片中的二维码,失败返回 null */
public static String decode(BufferedImage image) {
if (image == null) {
return null;
}
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(new BufferedImageLuminanceSource(image)));
// 先做快速检测,失败后再用加强模式(多二值化方式 + 旋转尝试)兜底
String text = tryDecode(bitmap, false);
if (text == null) {
text = tryDecode(bitmap, true);
}
return text;
}
private static String tryDecode(BinaryBitmap bitmap, boolean tryHarder) {
Map<DecodeHintType, Object> hints = new EnumMap<>(DecodeHintType.class);
hints.put(DecodeHintType.POSSIBLE_FORMATS, EnumSet.of(BarcodeFormat.QR_CODE));
if (tryHarder) {
hints.put(DecodeHintType.TRY_HARDER, Boolean.TRUE);
}
try {
Result result = new MultiFormatReader().decode(bitmap, hints);
return result == null ? null : result.getText();
} catch (NotFoundException e) {
return null;
} catch (RuntimeException e) {
// 极端图像(过曝、全黑)可能抛出意外异常,按未识别处理
return null;
}
}
/** 预估二维码版本号(1~40,越大越难被摄像头识别),失败返回 -1 */
public static int estimateVersion(String content, ErrorCorrectionLevel ecc) {
Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
hints.put(EncodeHintType.CHARACTER_SET, "UTF-8");
try {
QRCode qrCode = Encoder.encode(content, ecc, hints);
return qrCode.getVersion().getVersionNumber();
} catch (WriterException e) {
return -1;
}
}
}
- QrFrameFactory.java
java
package com.example.qr.sender;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import java.awt.image.BufferedImage;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
/**
* 生成并自检每一帧二维码。
* <p>极少数数据分片生成的二维码连 ZXing 自己都识别不出来(换像素尺寸也无效),
* 这类帧如果直接播放,接收端将永远收不齐。这里通过给协议加一个盐值字段重试,
* 直到该二维码能被稳定识别为止;盐值对接收端透明。</p>
*/
final class QrFrameFactory {
/** 自检用的渲染尺寸,足够大以保证失真是数据本身的问题而非尺寸问题 */
private static final int PROBE_PIXELS = 900;
private static final int MAX_SALT = 20;
private final FileSplitter splitter;
private final ErrorCorrectionLevel ecc;
private final Map<Integer, Integer> saltByIndex = new HashMap<>();
QrFrameFactory(FileSplitter splitter, ErrorCorrectionLevel ecc) {
this.splitter = splitter;
this.ecc = ecc;
}
/** 渲染第 index 帧,返回的图片保证可被识别 */
BufferedImage render(int index, int pixels) throws IOException {
return QrCodec.encode(payloadFor(index), ecc, pixels);
}
/** 取得第 index 帧经过自检的二维码文本 */
String payloadFor(int index) throws IOException {
Integer cachedSalt = saltByIndex.get(index);
if (cachedSalt != null) {
return splitter.frameText(index, cachedSalt);
}
for (int salt = 0; salt <= MAX_SALT; salt++) {
String payload = splitter.frameText(index, salt);
if (QrCodec.decode(QrCodec.encode(payload, ecc, PROBE_PIXELS)) != null) {
saltByIndex.put(index, salt);
return payload;
}
}
throw new IllegalStateException("第 " + index + " 帧无论换用哪个盐值都无法生成可识别的二维码,"
+ "请尝试增大 --chunk 或改用 --ecc M");
}
}
- SenderMain.java
java
package com.example.qr.sender;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import java.io.File;
import java.util.HashMap;
import java.util.Map;
/**
* 生成端命令行入口:
* <pre>
* play <文件> [--chunk 400] [--interval 500] [--ecc L]
* video <文件> --out 输出.mp4 [--chunk 400] [--fps 2] [--loops 3] [--size 1080p]
* </pre>
*/
public final class SenderMain {
private static final int DEFAULT_CHUNK = 400;
private static final int DEFAULT_INTERVAL = 500;
private static final int DEFAULT_FPS = 2;
private static final int DEFAULT_LOOPS = 3;
private SenderMain() {
}
public static void main(String[] args) throws Exception {
if (args.length < 2) {
printUsage();
return;
}
String command = args[0].toLowerCase();
File file = new File(args[1]);
Map<String, String> options = parseOptions(args, 2);
if (!file.isFile()) {
System.out.println("文件不存在:" + file.getAbsolutePath());
return;
}
int chunk = parseInt(options, "chunk", DEFAULT_CHUNK);
ErrorCorrectionLevel ecc = parseEcc(options.getOrDefault("ecc", "L"));
if ("play".equals(command)) {
int interval = parseInt(options, "interval", DEFAULT_INTERVAL);
try (FileSplitter splitter = new FileSplitter(file, chunk)) {
new FileQrSender(splitter, ecc, interval).start();
}
} else if ("video".equals(command)) {
String out = options.get("out");
if (out == null) {
System.out.println("请用 --out 指定输出视频路径,例如 --out qr.mp4");
return;
}
int fps = parseInt(options, "fps", DEFAULT_FPS);
int loops = parseInt(options, "loops", DEFAULT_LOOPS);
int[] size = parseSizeOption(options.getOrDefault("size", "1080p"));
try (FileSplitter splitter = new FileSplitter(file, chunk)) {
new VideoExporter(splitter, ecc, fps, loops, size[0], size[1]).export(new File(out));
}
} else {
printUsage();
}
}
private static int[] parseSizeOption(String value) {
try {
return VideoExporter.parseSize(value);
} catch (IllegalArgumentException e) {
System.out.println(e.getMessage() + ",使用 1080p");
return VideoExporter.parseSize("1080p");
}
}
private static Map<String, String> parseOptions(String[] args, int from) {
Map<String, String> options = new HashMap<>();
for (int i = from; i < args.length; i++) {
String arg = args[i];
if (!arg.startsWith("--")) {
continue;
}
int eq = arg.indexOf('=');
if (eq > 0) {
options.put(arg.substring(2, eq).toLowerCase(), arg.substring(eq + 1));
} else if (i + 1 < args.length && !args[i + 1].startsWith("--")) {
options.put(arg.substring(2).toLowerCase(), args[++i]);
} else {
options.put(arg.substring(2).toLowerCase(), "true");
}
}
return options;
}
private static int parseInt(Map<String, String> options, String key, int defaultValue) {
String value = options.get(key);
if (value == null) {
return defaultValue;
}
try {
return Integer.parseInt(value);
} catch (NumberFormatException e) {
System.out.println("参数 --" + key + " 需要整数,使用默认值 " + defaultValue);
return defaultValue;
}
}
private static ErrorCorrectionLevel parseEcc(String value) {
switch (value.toUpperCase()) {
case "L":
return ErrorCorrectionLevel.L;
case "M":
return ErrorCorrectionLevel.M;
case "Q":
return ErrorCorrectionLevel.Q;
case "H":
return ErrorCorrectionLevel.H;
default:
System.out.println("未知纠错级别 " + value + ",使用 L");
return ErrorCorrectionLevel.L;
}
}
private static void printUsage() {
System.out.println("二维码文件生成端(屏幕 -> 摄像头,无需联网)");
System.out.println();
System.out.println("全屏播放二维码:");
System.out.println(" java -jar qr-sender.jar play <文件> [--chunk 400] [--interval 500] [--ecc L]");
System.out.println();
System.out.println("导出循环播放视频:");
System.out.println(" java -jar qr-sender.jar video <文件> --out qr.mp4 [--chunk 400] [--fps 2] [--loops 3] [--size 1080p]");
System.out.println();
System.out.println("参数说明:");
System.out.println(" --chunk 每帧承载的原始字节数,默认 400(越大帧数越少,但二维码版本越高)");
System.out.println(" --interval 全屏播放时每帧停留毫秒数,默认 500");
System.out.println(" --out 视频输出路径");
System.out.println(" --fps 视频帧率,即每秒切换几个二维码,默认 2");
System.out.println(" --loops 视频内整体重复轮数,默认 3");
System.out.println(" --size 视频画面尺寸,可选 1080p / 720p / 宽x高,默认 1080p");
System.out.println(" --ecc 纠错级别 L/M/Q/H,默认 L");
System.out.println();
System.out.println("提示:二维码每帧仅几百字节,实际传输速度约 1~5 KB/s,建议传输几 MB 以内的文件。");
}
}
- VideoExporter.java
java
package com.example.qr.sender;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import org.jcodec.api.awt.AWTSequenceEncoder;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.image.BufferedImage;
import java.io.File;
import java.io.IOException;
/**
* 把所有二维码帧编码成一个 MP4 视频,并按指定轮数整体重复,
* 这样用任意播放器循环播放即可,无需在本机运行 Java 程序。
*/
final class VideoExporter {
private final FileSplitter splitter;
private final ErrorCorrectionLevel ecc;
private final int fps;
private final int loops;
private final int width;
private final int height;
private final QrFrameFactory factory;
VideoExporter(FileSplitter splitter, ErrorCorrectionLevel ecc, int fps, int loops, int width, int height) {
if (fps < 1 || fps > 30) {
throw new IllegalArgumentException("帧率需在 1~30 之间");
}
if (loops < 1) {
throw new IllegalArgumentException("循环轮数至少为 1");
}
this.splitter = splitter;
this.ecc = ecc;
this.fps = fps;
this.loops = loops;
this.width = width;
this.height = height;
this.factory = new QrFrameFactory(splitter, ecc);
}
void export(File target) throws Exception {
File parent = target.getParentFile();
if (parent != null && !parent.isDirectory() && !parent.mkdirs()) {
throw new IOException("无法创建输出目录:" + parent.getAbsolutePath());
}
int totalFrames = splitter.getTotalFrames();
int version = QrCodec.estimateVersion(factory.payloadFor(0), ecc);
System.out.println("文件 : " + splitter.getFile().getAbsolutePath()
+ " (" + FileSplitter.humanSize(splitter.getSize()) + ")");
System.out.println("分片 : " + totalFrames + " 帧,每帧 " + splitter.getChunkSize() + " 字节");
System.out.println("二维码版本: " + version + "(1~40,建议 ≤25)");
System.out.println("视频参数 : " + width + "x" + height + "," + fps + " fps,循环 " + loops + " 轮,共 "
+ ((long) totalFrames * loops) + " 个画面");
System.out.println("输出 : " + target.getAbsolutePath());
// 二维码占画面高度的 92%,其余留白
int qrPixels = (int) (Math.min(width, height) * 0.92);
BufferedImage canvas = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
AWTSequenceEncoder encoder = AWTSequenceEncoder.createSequenceEncoder(target, fps);
long encoded = 0;
long total = (long) totalFrames * loops;
try {
for (int loop = 0; loop < loops; loop++) {
for (int index = 0; index < totalFrames; index++) {
BufferedImage qr = factory.render(index, qrPixels);
encoder.encodeImage(compose(canvas, qr));
encoded++;
if (encoded % 25 == 0 || encoded == total) {
System.out.println("编码进度 : " + encoded + "/" + total
+ "(" + (encoded * 100 / total) + "%)");
}
}
}
} finally {
encoder.finish();
}
System.out.println("完成 : " + target.getAbsolutePath() + " (" + FileSplitter.humanSize(target.length()) + ")");
System.out.println("提示 : 播放该视频并让摄像头对准屏幕,接收端会自动收齐所有分片");
}
private static BufferedImage compose(BufferedImage canvas, BufferedImage qr) {
Graphics2D g = canvas.createGraphics();
g.setColor(Color.WHITE);
g.fillRect(0, 0, canvas.getWidth(), canvas.getHeight());
g.drawImage(qr, (canvas.getWidth() - qr.getWidth()) / 2, (canvas.getHeight() - qr.getHeight()) / 2, null);
g.dispose();
return canvas;
}
/** 解析 1080p / 720p / 宽x高 形式的画面尺寸 */
static int[] parseSize(String value) {
String normalized = value.toLowerCase().trim();
if ("1080p".equals(normalized)) {
return new int[]{1920, 1080};
}
if ("720p".equals(normalized)) {
return new int[]{1280, 720};
}
int x = normalized.indexOf('x');
if (x > 0) {
try {
int width = Integer.parseInt(normalized.substring(0, x));
int height = Integer.parseInt(normalized.substring(x + 1));
// H.264 宏块对齐:宽高需为 16 的整数倍
return new int[]{width / 16 * 16, height / 16 * 16};
} catch (NumberFormatException ignored) {
// 落到默认值
}
}
throw new IllegalArgumentException("无法识别的画面尺寸:" + value + ",可用 1080p / 720p / 1600x1200");
}
}
命令
bash
java -jar qr-sender.jar video <要传输的文件> --out qr.mp4
例如:
bash
java -jar qr-sender.jar video ~/Documents/report.pdf --out qr.mp4 --chunk 400 --fps 2 --loops 3 --size 1080p
执行后会打印分片数、二维码版本、每轮耗时等信息。把生成的 qr.mp4 拷到播放端(可以用任意播放器,务必全屏、不要缩放变形、关闭"自动旋转")。
不生成视频,发送端直接全屏轮播二维码,接收端同时扫描。
bash
java -jar qr-sender.jar play <要传输的文件> --chunk 400 --interval 500 --ecc L
命令参数
java -jar qr-sender.jar play <文件> [--chunk 400] [--interval 500] [--ecc L]
java -jar qr-sender.jar video <文件> --out 输出.mp4 [--chunk 400] [--fps 2] [--loops 3] [--size 1080p] [--ecc L]
| 参数 | 默认值 | 说明 |
|---|---|---|
--chunk |
400 |
每帧承载的原始字节数。越大总帧数越少、传输越快,但二维码版本更高、更难识别 |
--interval |
500 |
play 模式下每帧停留毫秒数 |
--out |
无(必填) | video 模式的视频输出路径 |
--fps |
2 |
视频帧率,即每秒切换几个二维码 |
--loops |
3 |
视频内整体重复播放的轮数,用于容忍漏帧 |
--size |
1080p |
视频画面尺寸,可选 1080p / 720p / 宽x高(宽高会向下取整到 16 的倍数,H.264 宏块对齐) |
--ecc |
L |
二维码纠错级别 L/M/Q/H,越高越抗污损但容量越小 |
单文件上限 2 GB,单个分片不得小于 32 字节。