Java将文件转二维码导出MP4

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>

代码

  1. 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();
    }
}
  1. 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));
    }
}
  1. 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();
    }
}
  1. 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;
        }
    }
}
  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");
    }
}
  1. 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  &lt;文件&gt; [--chunk 400] [--interval 500] [--ecc L]
 *   video &lt;文件&gt; --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 以内的文件。");
    }
}
  1. 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 字节。

相关推荐
曹牧3 小时前
JDK重定向目录
java·开发语言
郑州光合科技余经理3 小时前
同城小程序开发:同一笔单在用户端和商家端各显示到哪
android·java·开发语言·前端·后端·uni-app·php
code2cat4 小时前
【随笔】RAG检索缓存:复用证据之前,先把权限和版本放进缓存键
java·人工智能·缓存·知识库·rag
\光辉岁月/4 小时前
2.spring5-IOC
java·容器·ioc·注解
java_nnnn5 小时前
Spring MVC小案例(1)
java·后端·spring·mvc
西凉的悲伤5 小时前
Spring Boot 条件装配注解详解
java·springboot·1024程序员节·条件装配
木井巳5 小时前
【动态规划: 路径问题】不同路径
java·算法·leetcode·动态规划
打工仔折腾 AI5 小时前
AI Agent 工程化实战(二):5 分钟跑通 Deep Agent 并逐层拆解底层链路
java·数据库·人工智能·后端·python·langchain·ai agent 实战
秋名RG5 小时前
(二)存储、CPU 与指令执行——程序为什么快,又为什么慢
java·开发语言